diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 049591d00..059a54d52 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -291,6 +291,9 @@ function tokenizeAllowlistedShellWords(input) { } const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']); +// Keep only the lexical information needed for Bash's reserved word `time`. +// Quoted/escaped `time` is an external command, not a shell pipeline prefix. +const SHELL_TIME_TOKENS = new WeakMap(); /** * Quote-aware split of a command line into segments, with quotes removed from @@ -307,20 +310,30 @@ const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']); function quoteAwareSegments(input) { const segments = []; let words = []; + let timeTokens = new Set(); let current = ''; let hasWord = false; + let literalWord = true; let quote = null; let escaped = false; const flushWord = () => { - if (hasWord) words.push(current); + if (hasWord) { + if (literalWord && ['time', '-p', '--'].includes(current)) timeTokens.add(words.length); + words.push(current); + } current = ''; hasWord = false; + literalWord = true; }; const flushSegment = () => { flushWord(); - if (words.length) segments.push(words); + if (words.length) { + if (timeTokens.size) SHELL_TIME_TOKENS.set(words, timeTokens); + segments.push(words); + } words = []; + timeTokens = new Set(); }; const source = String(input || ''); @@ -345,6 +358,7 @@ function quoteAwareSegments(input) { i += 1; continue; } + literalWord = false; escaped = true; hasWord = true; continue; @@ -357,6 +371,7 @@ function quoteAwareSegments(input) { } if (ch === '"' || ch === "'") { quote = ch; + literalWord = false; hasWord = true; // entering a quote starts a word, even if its content is empty continue; } @@ -532,6 +547,8 @@ function wrapperValueOption(arg, valueFlags) { // Explicit external-launcher argv grammars for dd and shell-wrapper discovery. // Unknown flags do not justify guessing which later argument executes. +// This literal allowlist cannot prove arbitrary custom-wrapper semantics or +// resolve dynamically selected executables; quoted operand text stays data. const DD_LAUNCHER_OPTIONS = { xargs: { values: new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-J', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars', '--process-slot-var']), @@ -544,12 +561,38 @@ const DD_LAUNCHER_OPTIONS = { flags: new Set(['-v', '--verbose', '--foreground', '--preserve-status']) }, nice: { values: new Set(['-n', '--adjustment']), optional: new Set(), flags: new Set() }, - nohup: { values: new Set(), optional: new Set(), flags: new Set() } + nohup: { values: new Set(), optional: new Set(), flags: new Set() }, + time: { + values: new Set(['-f', '--format', '-o', '--output-file']), + optional: new Set(), + flags: new Set(['-p', '--portability', '-a', '--append', '-q', '--quiet', '-v', '--verbose']), + nonCommand: new Set(['--help', '-V', '--version']) + }, + stdbuf: { + values: new Set(['-i', '--input', '-o', '--output', '-e', '--error']), + optional: new Set(), flags: new Set() + }, + ionice: { + values: new Set(['-c', '--class', '-n', '--classdata']), + optional: new Set(), flags: new Set(['-t', '--ignore']), + // These modes query/change existing processes rather than launch argv. + nonCommand: new Set(['-p', '--pid', '-P', '--pgid', '-u', '--uid']) + }, + setsid: { + values: new Set(), optional: new Set(), + flags: new Set(['-c', '--ctty', '-f', '--fork', '-w', '--wait']) + } }; /** Return the command position after one explicitly supported launcher's options. */ function ddLauncherCommandIndex(argv, index, name) { - const { values, optional, flags } = DD_LAUNCHER_OPTIONS[name]; + const { values, optional, flags, nonCommand } = DD_LAUNCHER_OPTIONS[name]; + // GNU time uses getopt_long: unique prefixes resolve against its complete + // eight-option table, including terminating help/version. Other launchers + // retain their explicit spellings; this does not affect shell-keyword time. + const timeLongOptions = name === 'time' + ? [...values, ...flags, ...nonCommand].filter(flag => flag.startsWith('--')) + : null; index += 1; while (index < argv.length) { const arg = argv[index]; @@ -565,7 +608,13 @@ function ddLauncherCommandIndex(argv, index, name) { } if (arg.startsWith('--')) { const separator = arg.indexOf('='); - const flag = separator === -1 ? arg : arg.slice(0, separator); + let flag = separator === -1 ? arg : arg.slice(0, separator); + if (timeLongOptions && !timeLongOptions.includes(flag)) { + const matches = timeLongOptions.filter(option => option.startsWith(flag)); + if (matches.length !== 1) return argv.length; + [flag] = matches; + } + if (nonCommand && nonCommand.has(flag)) return argv.length; if (values.has(flag)) index += separator === -1 ? 2 : 1; else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1; else return argv.length; @@ -574,6 +623,7 @@ function ddLauncherCommandIndex(argv, index, name) { let consumesNext = false; for (let offset = 1; offset < arg.length; offset += 1) { const flag = `-${arg[offset]}`; + if (nonCommand && nonCommand.has(flag)) return argv.length; if (values.has(flag)) { consumesNext = offset + 1 === arg.length; break; @@ -602,12 +652,27 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers let argv = tokens.slice(); let index = 0; let allowAssignments = true; + let allowShellTime = allowShellBuiltins; + const timeTokens = SHELL_TIME_TOKENS.get(tokens); let budget = tokens.reduce((size, token) => size + token.length + 1, 1); while (index < argv.length && budget-- > 0) { - while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) index += 1; + while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) { + index += 1; + allowShellTime = false; + } if (index >= argv.length) return []; const base = commandBasename(argv[index]); + if (allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index)) { + // Current Bash accepts only raw -p and -- as reserved-time options. + // Quotes/escapes make them executable words, unlike external time argv. + // The next command/exec builtin or assignment keeps shell semantics. + index += 1; + if (argv[index] === '-p' && timeTokens.has(index)) index += 1; + if (argv[index] === '--' && timeTokens.has(index)) index += 1; + continue; + } if (allowShellBuiltins && base === 'command') { + allowShellTime = false; index += 1; while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { const flag = argv[index++]; @@ -619,6 +684,7 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers continue; } if (allowShellBuiltins && base === 'exec') { + allowShellTime = false; index += 1; while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') { const flag = argv[index]; @@ -639,11 +705,13 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers } if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) { index = ddLauncherCommandIndex(argv, index, base); + allowShellTime = false; allowShellBuiltins = false; allowAssignments = false; continue; } if (base === 'sudo' || base === 'doas') { + allowShellTime = false; allowShellBuiltins = false; allowAssignments = true; const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS; @@ -661,6 +729,7 @@ function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers continue; } if (base === 'env') { + allowShellTime = false; allowShellBuiltins = false; allowAssignments = true; index += 1; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 426e4efbe..eeaad8d18 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -184,6 +184,82 @@ function runDdRegressionTests() { else failed++; }; const destructive = [ + // GNU external time option names, prefixes and values stay distinct. + "/usr/bin/time -q dd of=output", + "/usr/bin/time --quiet dd if=input", + "/usr/bin/time --output-file report dd of=output", + "/usr/bin/time --output-file=report dd of=output", + "/usr/bin/time --output-file=dd dd of=output", + "/usr/bin/time --q dd of=output", + "/usr/bin/time --qui dd if=input", + "/usr/bin/time --output-f=report dd of=output", + "/usr/bin/time --o dd dd of=output", + "/usr/bin/time --a --f dd --o report --p --q --verb dd of=output", + "/usr/bin/time -qfFORMAT dd of=output", + "/usr/bin/time -apqvfdd dd if=input", + "/usr/bin/time -qo dd dd of=output", + "/usr/bin/time --quiet -- dd of=output", + "/usr/bin/time --format= --output-file=report --append --portability --quiet --verbose dd of=output", + "'time' '--quiet' dd of=output", + "command time --q dd of=output", + "env time --output-f=report dd of=output", + "sudo time -q dd of=output", + "find . -exec time --q dd of=output \\;", + "timeout 2 /usr/bin/time --q sh -c 'dd of=output'", + "/usr/bin/time --output-file='dd of=output' sh -c 'dd if=input'", + "/usr/bin/time --f='dd of=output' stdbuf -oL dd of=output", + "sh -c '\"time\" --q dd of=output'", + "time -- /usr/bin/time --q dd of=output", + // Current Bash reserved-time syntax keeps raw option identity. + "time -- dd of=output", + "time -p -- dd of=output", + "time -- command -p dd of=output", + "time -p -- exec dd if=input", + "time -- A=1 dd of=output", + "time -p -- sh -c 'dd of=output'", + "time -p -- time -- dd of=output", + "'time' '-p' '--' dd of=output", + "env time -p -- dd of=output", + "time -\\\np -- dd of=output", + // Literal launcher argv cases; these strings are never executed. + "time dd if=input", + "time -p dd of=output", + "time command -p dd if=input", + "time -p exec dd of=output", + "time A=1 dd of=output", + "/usr/bin/time dd if=input", + "/usr/bin/time -f dd dd of=output", + "/usr/bin/time -o dd -apv dd of=output", + "/usr/bin/time --format=dd --output=dd --append --portability --verbose dd of=output", + "\"time\" -f \"%e\" dd of=output", + "'time' -o report dd if=input", + "\\time -f dd dd of=output", + "command time -f dd dd of=output", + "env time -f dd dd of=output", + "sudo time -p dd of=output", + "find . -exec time -f dd dd of=output \\;", + "time -p sh -c 'dd of=output'", + "'time' -f dd sh -c 'dd if=input'", + "stdbuf -i0 -oL -e0 dd of=output", + "stdbuf --input=0 --output=L --error=0 dd if=input", + "stdbuf -o L -- dd if=input", + "ionice dd of=output", + "ionice -c 2 -n 7 -t dd if=input", + "ionice -tc2 -n7 dd of=output", + "ionice --class=idle --classdata=7 --ignore dd of=output", + "ionice -- dd if=input", + "setsid dd of=output", + "setsid -cfw dd if=input", + "setsid --ctty --fork --wait -- dd of=output", + "stdbuf -oL sh -c 'dd of=output'", + "ionice -c2 sh -c 'dd of=output'", + "setsid -w sh -c 'dd of=output'", + "time -p stdbuf -oL ionice -c2 setsid -f env -S 'dd of=output'", + "sudo -u root stdbuf -oL ionice -c2 setsid dd of=output", + "find . -exec stdbuf -oL setsid dd of=output \\;", + "find . -exec ionice -c2 setsid dd if=input \\;", + "sh -c 'time -p stdbuf -oL dd of=output'", + "setsid sh -c 'cat <