From 862dbd520498a31d2e3f19216e0d513bc6ce04eb Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 16 Jul 2026 14:32:43 -0500 Subject: [PATCH] test(fleet): harden static command parsing Co-Authored-By: Claude Opus 4.8 --- .../src/fleet/fleet-documentation.spec.ts | 241 ++++++++++++++++-- 1 file changed, 213 insertions(+), 28 deletions(-) diff --git a/packages/mosaic/src/fleet/fleet-documentation.spec.ts b/packages/mosaic/src/fleet/fleet-documentation.spec.ts index c2d45e3..437ec23 100644 --- a/packages/mosaic/src/fleet/fleet-documentation.spec.ts +++ b/packages/mosaic/src/fleet/fleet-documentation.spec.ts @@ -139,22 +139,25 @@ function shellCommandSegments(source: string): string[] { const segments: string[] = []; let segment = ''; let quote = ''; - let escaped = false; + let wordStarted = false; const flush = (): void => { if (segment.trim() !== '') segments.push(segment); segment = ''; + wordStarted = false; }; - for (const character of source) { - if (escaped) { - segment += character; - escaped = false; - continue; - } + for (let index = 0; index < source.length; index += 1) { + const character = source[index] ?? ''; + const next = source[index + 1] ?? ''; if (character === '\\' && quote !== "'") { - segment += character; - escaped = true; + if (next === '\n') { + index += 1; + continue; + } + segment += character + next; + wordStarted = true; + index += 1; continue; } if (quote !== '') { @@ -165,6 +168,11 @@ function shellCommandSegments(source: string): string[] { if (character === "'" || character === '"') { segment += character; quote = character; + wordStarted = true; + continue; + } + if (character === '#' && !wordStarted && segment.trim() !== '') { + while (index + 1 < source.length && source[index + 1] !== '\n') index += 1; continue; } if (character === '\n' || character === ';' || character === '&' || character === '|') { @@ -172,6 +180,8 @@ function shellCommandSegments(source: string): string[] { continue; } segment += character; + if (/\s/.test(character)) wordStarted = false; + else wordStarted = true; } flush(); return segments; @@ -216,17 +226,102 @@ function shellCommandTokens(source: string): string[] { return tokens; } +function envSplitStringTokens(source: string): string[] { + const tokens: string[] = []; + let token = ''; + let quote = ''; + let tokenStarted = false; + + const flush = (): void => { + if (tokenStarted) tokens.push(token); + token = ''; + tokenStarted = false; + }; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index] ?? ''; + if (character === "'" || character === '"') { + if (quote === '') { + quote = character; + tokenStarted = true; + continue; + } + if (quote === character) { + quote = ''; + continue; + } + } + if (character === '\\') { + const escaped = source[index + 1]; + if (escaped === undefined) { + token += '\\'; + tokenStarted = true; + continue; + } + if (quote === "'" && escaped !== "'" && escaped !== '\\') { + token += `\\${escaped}`; + tokenStarted = true; + index += 1; + continue; + } + if (escaped === 'c' && quote === '') break; + if (escaped === '_' && quote !== '"') { + flush(); + index += 1; + continue; + } + const escapes: Readonly> = { + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '#': '#', + $: '$', + _: ' ', + '"': '"', + "'": "'", + '\\': '\\', + }; + const value = escapes[escaped]; + if (value !== undefined) { + if (/\s/.test(value) && quote === '') flush(); + else { + token += value; + tokenStarted = true; + } + index += 1; + continue; + } + token += `\\${escaped}`; + tokenStarted = true; + index += 1; + continue; + } + if (character === '#' && quote === '' && !tokenStarted) break; + if (/\s/.test(character) && quote === '') { + flush(); + continue; + } + token += character; + tokenStarted = true; + } + flush(); + return tokens; +} + function normalizedCommandTokens(segment: string): string[] { const tokens = shellCommandTokens(segment.trim().replace(/^(?:[$#>]\s*)/, '')); let index = 0; while (index < tokens.length) { + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? '')) index += 1; const wrapper = commandBasename(tokens[index] ?? ''); if (wrapper === 'env') { index += 1; while (index < tokens.length) { const token = tokens[index] ?? ''; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + if (!token.startsWith('-') && /^[^=]+=/.test(token)) { index += 1; continue; } @@ -234,6 +329,7 @@ function normalizedCommandTokens(segment: string): string[] { index += 1; break; } + if (token === '--help' || token === '--version') return []; if (/^(?:--unset|--chdir|--argv0)$/.test(token)) { index += 2; continue; @@ -243,13 +339,12 @@ function normalizedCommandTokens(segment: string): string[] { continue; } if (token === '--split-string') { - const splitTokens = shellCommandTokens(tokens[index + 1] ?? ''); - tokens.splice(index, 2, ...splitTokens); + tokens.splice(index, 2, ...envSplitStringTokens(tokens[index + 1] ?? '')); continue; } const longSplitString = token.match(/^--split-string=(.*)$/)?.[1]; if (longSplitString !== undefined) { - tokens.splice(index, 1, ...shellCommandTokens(longSplitString)); + tokens.splice(index, 1, ...envSplitStringTokens(longSplitString)); continue; } if (/^-[^-]/.test(token)) { @@ -265,7 +360,7 @@ function normalizedCommandTokens(segment: string): string[] { if (option === 'S') { const attached = options.slice(optionIndex + 1); const operand = attached === '' ? (tokens[index + 1] ?? '') : attached; - tokens.splice(index, attached === '' ? 2 : 1, ...shellCommandTokens(operand)); + tokens.splice(index, attached === '' ? 2 : 1, ...envSplitStringTokens(operand)); consumed = true; break; } @@ -299,7 +394,7 @@ function normalizedCommandTokens(segment: string): string[] { return tokens.slice(index); } -function containsPrivilegedCommand(source: string): boolean { +function containsPrivilegedCommand(source: string, depth = 0): boolean { const privilegedCommands = new Set([ 'sudo', 'doas', @@ -340,38 +435,77 @@ function containsPrivilegedCommand(source: string): boolean { const tokens = normalizedCommandTokens(segment); const command = commandBasename(tokens[0] ?? ''); if (privilegedCommands.has(command)) return true; + if ((command === 'sh' || command === 'bash') && depth < 3) { + let index = 1; + while (index < tokens.length) { + const token = tokens[index] ?? ''; + if (token === '--') { + index += 1; + continue; + } + if (token === '-c' || token === '--command') { + return containsPrivilegedCommand(tokens[index + 1] ?? '', depth + 1); + } + if (token === '-o' || token === '+o') { + index += 2; + continue; + } + if (/^-[^-]*c/.test(token)) { + return containsPrivilegedCommand(tokens[index + 1] ?? '', depth + 1); + } + if (token.startsWith('-') || token.startsWith('+')) { + index += 1; + continue; + } + break; + } + return false; + } if (command !== 'su') return false; let index = 1; + let user: string | undefined; + let rootGroup = false; while (index < tokens.length) { const token = tokens[index] ?? ''; if (token === '--') { - index += 1; + user ??= tokens[index + 1]; break; } + const longOperand = token.match( + /^--(command|session-command|group|supp-group|shell|whitelist-environment)=(.*)$/, + ); + if (longOperand !== null) { + if ( + (longOperand[1] === 'group' || longOperand[1] === 'supp-group') && + longOperand[2] === 'root' + ) { + rootGroup = true; + } + index += 1; + continue; + } if ( /^(?:--command|--session-command|--group|--supp-group|--shell|--whitelist-environment)$/.test( token, ) ) { + if ((token === '--group' || token === '--supp-group') && tokens[index + 1] === 'root') { + rootGroup = true; + } index += 2; continue; } - if ( - /^(?:--command=|--session-command=|--group=|--supp-group=|--shell=|--whitelist-environment=)/.test( - token, - ) - ) { - index += 1; - continue; - } if (/^-[^-]/.test(token)) { const options = token.slice(1); let consumed = false; for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) { const option = options[optionIndex] ?? ''; if ('cgGsw'.includes(option)) { - index += options.slice(optionIndex + 1) === '' ? 2 : 1; + const attached = options.slice(optionIndex + 1); + const operand = attached === '' ? tokens[index + 1] : attached; + if ((option === 'g' || option === 'G') && operand === 'root') rootGroup = true; + index += attached === '' ? 2 : 1; consumed = true; break; } @@ -383,10 +517,10 @@ function containsPrivilegedCommand(source: string): boolean { index += 1; continue; } - break; + user ??= token; + index += 1; } - const user = tokens[index]; - return user === undefined || user === 'root'; + return rootGroup || user === undefined || user === 'root'; }); } @@ -509,6 +643,30 @@ const UNSAFE_EXAMPLE_FIXTURES = [ expected: 'privileged-command', source: "env --split-string='-- /usr/bin/apt-get update'", }, + { expected: 'privileged-command', source: "env -S '\\_apt-get --version'" }, + { expected: 'privileged-command', source: "env -S '\\tapt-get --version'" }, + { + expected: 'privileged-command', + source: String.raw`env -S 'env -S "\_apt-get --version"'`, + }, + { expected: 'privileged-command', source: 'LC_ALL=C apt-get --version' }, + { expected: 'privileged-command', source: "env 'x-y=1' apt-get --version" }, + { expected: 'privileged-command', source: 'env 1X=value apt-get --version' }, + { expected: 'privileged-command', source: "sh -c 'apt-get --version'" }, + { expected: 'privileged-command', source: "/bin/bash -lc 'apt-get --version'" }, + { + expected: 'privileged-command', + source: "bash --noprofile -o pipefail -c 'apt-get --version'", + }, + { expected: 'privileged-command', source: 'su operator --group root' }, + { expected: 'privileged-command', source: 'su --group=root operator' }, + { expected: 'privileged-command', source: 'su operator -g root' }, + { expected: 'privileged-command', source: 'su -groot operator' }, + { expected: 'privileged-command', source: 'su operator --supp-group root' }, + { expected: 'privileged-command', source: 'su --supp-group=root operator' }, + { expected: 'privileged-command', source: 'su operator -G root' }, + { expected: 'privileged-command', source: 'su -lGroot operator' }, + { expected: 'privileged-command', source: 'apt\\\n-get --version' }, { expected: 'privileged-command', source: 'reboot' }, { expected: 'privileged-command', source: 'su --command=/bin/sh' }, { expected: 'privileged-command', source: 'su -lc /usr/bin/id' }, @@ -582,6 +740,33 @@ describe('documentation validation regressions', (): void => { ); }); + it.each([ + 'printf safe # ; apt-get --version', + 'env --help apt-get', + 'env --version sudo', + "env -S '\\\\_printf safe'", + "env -S 'printf\\_safe'", + "env -S 'printf \\#safe'", + "env -S 'printf safe \\c apt-get --version'", + 'LC_ALL=C printf safe', + "env 'x-y=1' printf safe", + 'env 1X=value printf safe', + "sh -c 'printf safe'", + "/bin/bash -lc 'command -v apt-get'", + "printf '%s\\n' 'sh -c apt-get --version'", + 'su operator --group operators', + 'su --group=operators operator', + 'su operator -g operators', + 'su -goperators operator', + 'su operator --supp-group operators', + 'su --supp-group=operators operator', + 'su operator -G operators', + 'su -lGoperators operator', + "printf '%s\\n' 'apt\\\\\\n-get --version'", + ])('keeps new benign command-parser controls safe', (source): void => { + expect(exampleSafetyViolationKinds(source)).not.toContain('privileged-command'); + }); + it.each([ "env --split-string='-i -- /usr/bin/printf safe'", "env --split-string='FOO=x /usr/bin/printf safe'",