fix(gateguard): classify standard launcher and GNU time options

Preserve contributor history and current-main behavior while resolving the exact reviewed follow-up.

Source-PR: https://github.com/affaan-m/ECC/pull/2829
Source-Parent: d811349224
Review-Manifest-SHA256: a7209685b2a5726777f7450a5983c28add8759654969b09ba3e4b803fcfaf8bd
This commit is contained in:
affaan-m
2026-09-28 01:06:31 -04:00
parent d811349224
commit c4b18b452d
2 changed files with 263 additions and 6 deletions
+75 -6
View File
@@ -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;
+188
View File
@@ -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 <<EOF\n$(dd of=output)\nEOF'",
'dd if=/dev/zero of=/dev/sda',
'dd of=/dev/sda bs=1M',
'cat /dev/zero | dd of=/dev/sda',
@@ -306,6 +382,118 @@ function runDdRegressionTests() {
"find . -exec 'rm' {} \\;"
];
const passive = [
// GNU external time option names, prefixes and values stay distinct.
"/usr/bin/time --v dd of=output",
"/usr/bin/time --ver dd of=output",
"/usr/bin/time --quiet=value dd of=output",
"/usr/bin/time --q=value dd of=output",
"/usr/bin/time --append=dd dd of=output",
"/usr/bin/time --portability=dd dd of=output",
"/usr/bin/time --verbose=dd dd of=output",
"/usr/bin/time --help dd of=output",
"/usr/bin/time --h dd of=output",
"/usr/bin/time --he dd of=output",
"/usr/bin/time --version dd of=output",
"/usr/bin/time --vers dd of=output",
"/usr/bin/time -qV dd of=output",
"/usr/bin/time --q --help dd of=output",
"/usr/bin/time --output-file dd echo of=output",
"/usr/bin/time --output-file=dd echo of=output",
"/usr/bin/time --output-f=dd echo of=output",
"/usr/bin/time --o dd echo of=output",
"/usr/bin/time -qfdd echo of=output",
"/usr/bin/time --f=dd echo of=output",
"/usr/bin/time --output-file dd of=output",
"/usr/bin/time --output-file",
"/usr/bin/time --quiet --output-file",
"/usr/bin/time --unknown dd of=output",
"/usr/bin/time --quieter dd of=output",
"/usr/bin/time --=dd dd of=output",
"/usr/bin/time -- -q dd of=output",
"/usr/bin/time echo --quiet dd of=output",
"/usr/bin/time --q command dd of=output",
"/usr/bin/time --q echo 'dd of=output'",
"/usr/bin/time --output-file='sh -c dd of=output' echo safe",
"/usr/bin/time -q sh -c 'echo \"dd of=output\"'",
"echo '/usr/bin/time --q dd of=output'",
"find . -name 'time --q dd of=output' -print",
"find . -exec echo time --q dd of=output \\;",
"time -q dd of=output",
"time --quiet dd of=output",
"time --output-file=report dd of=output",
// Current Bash reserved-time syntax keeps raw option identity.
"time '-p' dd of=output",
"time \"-p\" dd of=output",
"time \\-p dd of=output",
"time -\\p dd of=output",
"time '--' dd of=output",
"time \"--\" dd of=output",
"time \\-- dd of=output",
"time -p '--' dd of=output",
"time -p \\-- dd of=output",
"time -- -p dd of=output",
"time -p -p dd of=output",
"time -p'' dd of=output",
"time --'' dd of=output",
"time -- command -v dd of=output",
"time -- echo 'dd of=output'",
// Literal launcher argv cases; these strings are never executed.
"time echo dd if=input",
"time -p command -v dd if=input",
"time command -pV dd of=output",
"time -p exec -a dd echo of=output",
"time -f dd of=output",
"/usr/bin/time -f dd echo of=output",
"/usr/bin/time --format=dd echo if=input",
"/usr/bin/time -o dd echo of=output",
"/usr/bin/time --output=dd echo if=input",
"/usr/bin/time -afdd echo if=input",
"/usr/bin/time --help dd of=output",
"/usr/bin/time --version dd if=input",
"'time' -f dd echo of=output",
"\\time -o dd echo if=input",
"command time -f dd echo if=input",
"env time command dd of=output",
"A=1 time command dd of=output",
"/usr/bin/time command dd of=output",
"'time' command dd of=output",
"stdbuf -o dd echo if=input",
"stdbuf --input=dd echo of=output",
"stdbuf -edd echo of=output",
"stdbuf --help dd if=input",
"stdbuf --version dd of=output",
"stdbuf -oL echo 'dd of=output'",
"stdbuf -oL command dd if=input",
"ionice -c dd echo of=output",
"ionice --classdata=dd echo if=input",
"ionice -p 1 dd of=output",
"ionice -p1 dd if=input",
"ionice --pid=1 dd of=output",
"ionice -P 1 dd if=input",
"ionice --pgid 1 dd of=output",
"ionice -u 1 dd if=input",
"ionice --uid=1 dd of=output",
"ionice -tc2 -p1 dd of=output",
"ionice -h dd if=input",
"ionice --help dd of=output",
"ionice -V dd of=output",
"ionice --version dd if=input",
"ionice -c2 echo 'dd if=input'",
"ionice -c2 command dd of=output",
"setsid -h dd if=input",
"setsid --help dd of=output",
"setsid -V dd of=output",
"setsid --version dd if=input",
"setsid -w echo dd of=output",
"setsid command dd if=input",
"time -p stdbuf -oL ionice -c2 setsid echo 'dd of=output'",
"setsid -w sh -c 'echo \"dd of=output\"'",
"time -p sh -c 'cat <<EOF\ndd of=output\nEOF'",
"echo 'time dd if=input; setsid dd of=output'",
"printf '%s' 'stdbuf -oL dd if=input'",
"find . -name \"time -p dd of=output\" -print",
"find . -exec echo setsid dd of=output \\;",
"env -S 'echo time dd if=input; setsid dd of=output'",
'echo dd if=input',
'echo dd of=/dev/sda',
'grep dd of=output file',