Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions lib/core/database/destructive_sql_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum DestructiveSqlType {
dropMaterializedView,
truncateTable,
unconditionalDelete,
unconditionalUpdate,
dropCollection,
deleteDocument,
redisDel,
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 =
Expand All @@ -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(),
),
);
}
}

Expand Down
87 changes: 87 additions & 0 deletions test/core/database/destructive_sql_detector_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 $$
Expand Down
Loading