diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index bb800d28b..53d8424fe 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -359,6 +359,155 @@ function quoteAwareSegments(input) { const SHELL_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']); +/** + * SQL clients whose `-c`/`-e`/positional arguments carry SQL statements. + * Quoted SQL (e.g. `psql -c "drop table users"`) is invisible to the + * quote-stripping SQL regex, so it is re-checked here against dequoted + * tokens where quoted content is preserved (issue #3024). Restricted to + * known clients so `git commit -m "drop table"` and `echo "drop table"` + * stay allowed. + */ +const SQL_CLIENT_COMMANDS = new Set([ + 'psql', + 'postgres', + 'mysql', + 'mariadb', + 'sqlite3', + 'sqlite', + 'sqlcmd', + 'isql', + 'pgcli', + 'mycli', + 'duckdb', + 'bq', +]); + +/** + * Strip SQL string literals so phrases inside query data do not trigger + * the destructive detector (e.g. `SELECT 'drop table' ...` is a read). + * Handles single-quoted literals with '' escapes, double-quoted + * identifiers, and dollar-quoted blocks ($$...$$ and $tag$...$tag$). + * + * @param {string} input + * @returns {string} + */ +function stripSqlLiterals(input) { + return String(input || '') + .replace(/'(?:[^']|'')*'/g, "''") + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/(\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$)[\s\S]*?\1/g, '$$$$'); +} + +const SUDO_VALUE_FLAGS = new Set([ + '-u', + '--user', + '-g', + '--group', + '-U', + '--other-user', + '-p', + '--prompt', + '-C', + '--close-from', + '-D', + '--chdir', + '-h', + '--host', + '-r', + '--role', + '-t', + '--type', + '-T', + '--command-timeout', +]); + +/** + * Advance past `sudo`/`doas`/`env` wrappers including their flags and + * `VAR=value` assignments, so `sudo -u postgres psql ...` and + * `env PGUSER=postgres psql ...` still resolve to the real command. + * + * @param {string[]} tokens dequoted tokens for one segment + * @returns {number} index of the real command token + */ +function unwrapLeadWrappers(tokens) { + let index = 0; + for (let guard = 0; guard < 4; guard += 1) { + if (index >= tokens.length) return index; + const base = commandBasename(tokens[index]); + if (base === 'sudo' || base === 'doas') { + index += 1; + while (index < tokens.length) { + const flag = tokens[index]; + if (flag === '--') { + index += 1; + break; + } + if (flag === '-' || !flag.startsWith('-')) break; + if (SUDO_VALUE_FLAGS.has(flag)) { + index += 2; + continue; + } + if (/^--[^=]+=.*$/.test(flag)) { + index += 1; + continue; + } + index += 1; + } + continue; + } + if (base === 'env') { + index += 1; + while (index < tokens.length) { + const arg = tokens[index]; + if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') { + index += 1; + continue; + } + if (arg === '-u' || arg === '--unset') { + index += 2; + continue; + } + if (arg === '-C' || arg === '--chdir') { + index += 2; + continue; + } + if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) { + index += 1; + continue; + } + if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) { + index += 1; + continue; + } + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) { + index += 1; + continue; + } + break; + } + continue; + } + break; + } + return index; +} + +/** + * Detect destructive SQL passed as (possibly quoted) arguments to a known + * SQL client. Operates on dequoted tokens from `quoteAwareSegments`, so + * `psql -c "drop table users"` joins back to matchable text. + * + * @param {string[]} tokens dequoted tokens for one segment + * @returns {boolean} + */ +function isDestructiveSqlClient(tokens) { + if (!tokens || tokens.length === 0) return false; + const start = unwrapLeadWrappers(tokens); + if (start >= tokens.length) return false; + if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false; + return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' '))); +} + /** * Quote-aware destructive check: catches quoted command words, newline * separators, quoted `find -exec`, and `sh -c`/`bash -c` wrappers that evade @@ -374,10 +523,12 @@ function isDestructiveQuoteAware(raw, depth = 0) { if (tokens.length === 0) continue; if (isDestructiveRm(tokens)) return true; if (isDestructiveGit(tokens)) return true; + if (isDestructiveSqlClient(tokens)) return true; if (isDestructiveFindExec(tokens.join(' '))) return true; - const base = commandBasename(tokens[0]); + const wi = unwrapLeadWrappers(tokens); + const base = wi < tokens.length ? commandBasename(tokens[wi]) : ''; if (SHELL_WRAPPERS.has(base)) { - const ci = tokens.indexOf('-c'); + const ci = tokens.indexOf('-c', wi); if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) { return true; } diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index fc3ab3106..399cca83b 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1507,6 +1507,48 @@ function runTests() { passed++; else failed++; + if ( + test('denies quoted destructive SQL passed to SQL clients (issue #3024)', () => { + expectDestructiveDeny('psql -c "drop table users"', 'psql quoted drop table'); + expectDestructiveDeny("psql -c 'truncate audit_log'", 'psql quoted truncate'); + expectDestructiveDeny('mysql -e "delete from sessions"', 'mysql quoted delete'); + expectDestructiveDeny('sqlite3 app.db "DROP TABLE users"', 'sqlite3 quoted drop'); + }) + ) + passed++; + else failed++; + + if ( + test('denies quoted destructive SQL through sudo/env wrappers', () => { + expectDestructiveDeny('sudo -u postgres psql -c "drop table users"', 'sudo -u psql'); + expectDestructiveDeny('env PGUSER=postgres psql -c "drop table users"', 'env psql'); + expectDestructiveDeny('env PGPASSWORD=value psql -c "drop table users"', 'env PGPASSWORD psql'); + expectDestructiveDeny('env -C /tmp psql -c "drop table users"', 'env -C psql'); + expectDestructiveDeny('env --chdir /tmp psql -c "drop table users"', 'env --chdir psql'); + }) + ) + passed++; + else failed++; + + if ( + test('denies destructive SQL through wrapper sh -c chains', () => { + expectDestructiveDeny('sudo sh -c \'psql -c "drop table users"\'', 'sudo sh -c psql'); + expectDestructiveDeny('env sh -c \'psql -c "drop table users"\'', 'env sh -c psql'); + }) + ) + passed++; + else failed++; + + if ( + test('allows SQL string literals and non-SQL clients mentioning SQL', () => { + expectAllow('psql -c "SELECT \'drop table\' FROM audit_log"', 'SQL string literal'); + expectAllow('psql -c "SELECT $tag$drop table users$tag$ FROM t"', 'tagged dollar-quote literal'); + expectAllow('echo "drop table users"', 'echo SQL mention'); + }) + ) + passed++; + else failed++; + if ( test('allows destructive SQL prose inside a quoted heredoc', () => { expectAllow(