diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 21124f8..513bbd6 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -8,6 +8,7 @@ enum DestructiveSqlType { dropMaterializedView, truncateTable, unconditionalDelete, + unconditionalUpdate, dropCollection, deleteDocument, redisDel, @@ -25,6 +26,7 @@ enum DestructiveSqlType { DestructiveSqlType.dropMaterializedView => 'DROP MATERIALIZED VIEW', DestructiveSqlType.truncateTable => 'TRUNCATE TABLE', DestructiveSqlType.unconditionalDelete => 'UNCONDITIONAL DELETE', + DestructiveSqlType.unconditionalUpdate => 'UNCONDITIONAL UPDATE', DestructiveSqlType.dropCollection => 'DROP COLLECTION', DestructiveSqlType.deleteDocument => 'DELETE DOCUMENT', DestructiveSqlType.redisDel => 'DEL', @@ -41,6 +43,7 @@ enum DestructiveSqlType { DestructiveSqlType.dropTable => 'HIGH', DestructiveSqlType.truncateTable => 'HIGH', DestructiveSqlType.unconditionalDelete => 'HIGH', + DestructiveSqlType.unconditionalUpdate => 'HIGH', DestructiveSqlType.dropCollection => 'HIGH', DestructiveSqlType.deleteDocument => 'HIGH', DestructiveSqlType.redisDel => 'HIGH', @@ -80,6 +83,8 @@ class DestructiveSqlOperation { 'Quickly deletes all rows from table "$targetName" without transaction rollbacks in some engines.', DestructiveSqlType.unconditionalDelete => 'Deletes all rows from table "$targetName" (no WHERE clause detected).', + DestructiveSqlType.unconditionalUpdate => + 'Modifies all rows in table "$targetName" (no WHERE clause detected).', DestructiveSqlType.dropCollection => 'Permanently drops collection "$targetName" and all documents in it.', DestructiveSqlType.deleteDocument => @@ -158,6 +163,29 @@ abstract final class DestructiveSqlDetector { caseSensitive: false, ); + static final _updateRegex = RegExp( + r'^\s*UPDATE\s+(?:(?:LOW_PRIORITY|IGNORE|ONLY)\s+)*(?:(?:["`]?([a-zA-Z0-9_]+)["`]?\.)?["`]?([a-zA-Z0-9_]+)["`]?)', + caseSensitive: false, + ); + + /// True when [sanitized] has a WHERE outside any parentheses, so a WHERE + /// inside a subquery (`SET x = (SELECT ... WHERE ...)`) does not count. + static bool _hasTopLevelWhere(String sanitized) { + final top = StringBuffer(); + var depth = 0; + for (var i = 0; i < sanitized.length; i++) { + final ch = sanitized[i]; + if (ch == '(') { + depth++; + } else if (ch == ')') { + if (depth > 0) depth--; + } else if (depth == 0) { + top.write(ch); + } + } + return RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(top.toString()); + } + /// Strips comments and string literals to prevent false positives when keywords /// appear inside strings or comments. static String stripCommentsAndStrings(String sql) { @@ -449,9 +477,7 @@ abstract final class DestructiveSqlDetector { // 7. DELETE FROM table without WHERE final deleteMatch = _deleteRegex.firstMatch(sanitized); if (deleteMatch != null) { - final hasWhere = - RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); - if (!hasWhere) { + if (!_hasTopLevelWhere(sanitized)) { final schema = deleteMatch.group(1); final table = deleteMatch.group(2) ?? 'table'; final target = @@ -464,6 +490,25 @@ abstract final class DestructiveSqlDetector { ), ); } + continue; + } + + // 8. UPDATE table SET ... without WHERE + final updateMatch = _updateRegex.firstMatch(sanitized); + if (updateMatch != null && + RegExp(r'\bSET\b', caseSensitive: false).hasMatch(sanitized) && + !_hasTopLevelWhere(sanitized)) { + final schema = updateMatch.group(1); + final table = updateMatch.group(2) ?? 'table'; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + operations.add( + DestructiveSqlOperation( + type: DestructiveSqlType.unconditionalUpdate, + targetName: target, + rawStatement: rawStmt.trim(), + ), + ); } } diff --git a/test/core/database/destructive_sql_detector_test.dart b/test/core/database/destructive_sql_detector_test.dart index 71b5b5a..6b76b26 100644 --- a/test/core/database/destructive_sql_detector_test.dart +++ b/test/core/database/destructive_sql_detector_test.dart @@ -95,6 +95,93 @@ void main() { expect(res.isDestructive, isFalse); }); + test('detects unconditional UPDATE without WHERE', () { + final res = + DestructiveSqlDetector.inspect("UPDATE users SET role = 'admin';"); + expect(res.isDestructive, isTrue); + final op = res.operations.single; + expect(op.type, DestructiveSqlType.unconditionalUpdate); + expect(op.targetName, 'users'); + expect(op.type.riskLevel, 'HIGH'); + expect(op.description, contains('users')); + expect(res.maxRiskLevel, 'HIGH'); + }); + + test('unconditional UPDATE reports schema-qualified and quoted targets', () { + expect( + DestructiveSqlDetector.inspect('UPDATE public."accounts" SET balance = 0') + .operations + .single + .targetName, + 'public.accounts', + ); + expect( + DestructiveSqlDetector.inspect('UPDATE ONLY accounts a SET balance = 0') + .operations + .single + .targetName, + 'accounts', + ); + expect( + DestructiveSqlDetector.inspect('update `db`.`t` set x = 1') + .operations + .single + .targetName, + 'db.t', + ); + }); + + test('does NOT flag UPDATE with WHERE, including multi-line and lowercase', () { + expect( + DestructiveSqlDetector.inspect('UPDATE users SET a = 1 WHERE id = 1') + .isDestructive, + isFalse, + ); + expect( + DestructiveSqlDetector.inspect('update users\nset a = 1\nwhere id = 1;') + .isDestructive, + isFalse, + ); + }); + + test('a WHERE inside a SET subquery does not make UPDATE conditional', () { + final res = DestructiveSqlDetector.inspect( + 'UPDATE users SET plan = (SELECT p FROM plans WHERE p.id = 1)', + ); + expect(res.operations.single.type, DestructiveSqlType.unconditionalUpdate); + }); + + test('ignores WHERE hidden in comments or strings for UPDATE', () { + final res = DestructiveSqlDetector.inspect( + "UPDATE users SET note = 'where x' -- WHERE id = 1", + ); + expect(res.operations.single.type, DestructiveSqlType.unconditionalUpdate); + }); + + test('does not flag INSERT ... ON CONFLICT DO UPDATE or SELECT FOR UPDATE', + () { + expect( + DestructiveSqlDetector.inspect( + 'INSERT INTO t (id, n) VALUES (1, 2) ON CONFLICT (id) DO UPDATE SET n = 2', + ).isDestructive, + isFalse, + ); + expect( + DestructiveSqlDetector.inspect('SELECT * FROM t FOR UPDATE').isDestructive, + isFalse, + ); + }); + + test('flags each unconditional UPDATE in a multi-statement script', () { + final res = DestructiveSqlDetector.inspect( + 'UPDATE a SET x = 1; UPDATE b SET y = 2 WHERE id = 3; DELETE FROM c;', + ); + expect(res.operations.map((o) => o.type), [ + DestructiveSqlType.unconditionalUpdate, + DestructiveSqlType.unconditionalDelete, + ]); + }); + test('ignores destructive keywords inside dollar-quoted strings', () { final res = DestructiveSqlDetector.inspect(r''' CREATE OR REPLACE FUNCTION clean_data() RETURNS void AS $$