diff --git a/packages/mosaic/src/fleet/fleet-documentation.spec.ts b/packages/mosaic/src/fleet/fleet-documentation.spec.ts index bab73f2..08a61e2 100644 --- a/packages/mosaic/src/fleet/fleet-documentation.spec.ts +++ b/packages/mosaic/src/fleet/fleet-documentation.spec.ts @@ -135,12 +135,64 @@ function commandBasename(token: string): string { return token.slice(token.lastIndexOf('/') + 1); } +function shellCommandSegments(source: string): string[] { + const segments: string[] = []; + let segment = ''; + let quote = ''; + let escaped = false; + + const flush = (): void => { + if (segment.trim() !== '') segments.push(segment); + segment = ''; + }; + + for (const character of source) { + if (escaped) { + segment += character; + escaped = false; + continue; + } + if (character === '\\' && quote !== "'") { + segment += character; + escaped = true; + continue; + } + if (quote !== '') { + segment += character; + if (character === quote) quote = ''; + continue; + } + if (character === "'" || character === '"') { + segment += character; + quote = character; + continue; + } + if (character === '\n' || character === ';' || character === '&' || character === '|') { + flush(); + continue; + } + segment += character; + } + flush(); + return segments; +} + function shellCommandTokens(source: string): string[] { const tokens: string[] = []; let token = ''; let quote = ''; + let escaped = false; for (const character of source) { + if (escaped) { + token += character; + escaped = false; + continue; + } + if (character === '\\' && quote !== "'") { + escaped = true; + continue; + } if (quote !== '') { if (character === quote) quote = ''; else token += character; @@ -159,6 +211,7 @@ function shellCommandTokens(source: string): string[] { } token += character; } + if (escaped) token += '\\'; if (token !== '') tokens.push(token); return tokens; } @@ -177,22 +230,47 @@ function normalizedCommandTokens(segment: string): string[] { index += 1; continue; } - if (/^(?:-u|--unset|-C|--chdir)$/.test(token)) { + if (token === '--') { + index += 1; + break; + } + if (/^(?:--unset|--chdir)$/.test(token)) { index += 2; continue; } - if (/^(?:-u|--unset=|-C|--chdir=)/.test(token)) { + if (/^(?:--unset=|--chdir=)/.test(token)) { index += 1; continue; } - if (/^(?:-S|--split-string)$/.test(token)) { + if (token === '--split-string') { const splitTokens = shellCommandTokens(tokens[index + 1] ?? ''); tokens.splice(index, 2, ...splitTokens); continue; } - const splitString = token.match(/^(?:-S|--split-string=)(.*)$/)?.[1]; - if (splitString !== undefined) { - tokens.splice(index, 1, ...shellCommandTokens(splitString)); + const longSplitString = token.match(/^--split-string=(.*)$/)?.[1]; + if (longSplitString !== undefined) { + tokens.splice(index, 1, ...shellCommandTokens(longSplitString)); + 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 (option === 'u' || option === 'C') { + index += options.slice(optionIndex + 1) === '' ? 2 : 1; + consumed = true; + break; + } + if (option === 'S') { + const attached = options.slice(optionIndex + 1); + const operand = attached === '' ? (tokens[index + 1] ?? '') : attached; + tokens.splice(index, attached === '' ? 2 : 1, ...shellCommandTokens(operand)); + consumed = true; + break; + } + } + if (!consumed) index += 1; continue; } if (token.startsWith('-')) { @@ -258,7 +336,7 @@ function containsPrivilegedCommand(source: string): boolean { 'nix-env', ]); - return source.split(/[\n;&|]+/).some((segment): boolean => { + return shellCommandSegments(source).some((segment): boolean => { const tokens = normalizedCommandTokens(segment); const command = commandBasename(tokens[0] ?? ''); if (privilegedCommands.has(command)) return true; @@ -272,7 +350,7 @@ function containsPrivilegedCommand(source: string): boolean { break; } if ( - /^(?:-c|--command|--session-command|-g|--group|-G|--supp-group|-s|--shell|-w|--whitelist-environment)$/.test( + /^(?:--command|--session-command|--group|--supp-group|--shell|--whitelist-environment)$/.test( token, ) ) { @@ -287,13 +365,28 @@ function containsPrivilegedCommand(source: string): boolean { 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; + consumed = true; + break; + } + } + if (!consumed) index += 1; + continue; + } if (token.startsWith('-')) { index += 1; continue; } break; } - return (tokens[index] ?? 'root') === 'root'; + const user = tokens[index]; + return user === undefined || user === 'root'; }); } @@ -357,6 +450,14 @@ const UNSAFE_EXAMPLE_FIXTURES = [ expected: 'privileged-command', source: '/usr/bin/env -u FOO /usr/bin/apt-get install example', }, + { + expected: 'privileged-command', + source: 'env -iu FOO /usr/bin/apt-get --version', + }, + { + expected: 'privileged-command', + source: 'env -ivS/usr/bin/apt-get --version', + }, { expected: 'privileged-command', source: 'env --unset FOO /usr/bin/apt-get install example', @@ -382,6 +483,7 @@ const UNSAFE_EXAMPLE_FIXTURES = [ }, { expected: 'privileged-command', source: 'reboot' }, { expected: 'privileged-command', source: 'su --command=/bin/sh' }, + { expected: 'privileged-command', source: 'su -lc /usr/bin/id' }, { expected: 'privileged-command', source: 'su -l root' }, { expected: 'privileged-command', source: 'su -l -s /bin/sh root' }, { expected: 'privileged-command', source: '/usr/bin/su --login root' }, @@ -440,9 +542,24 @@ describe('documentation validation regressions', (): void => { expect(exampleSafetyViolationKinds('command -v apt-get')).not.toContain('privileged-command'); }); + it('keeps quoted command separators as inert argument data', (): void => { + expect( + exampleSafetyViolationKinds("printf '%s\\n' 'safe; /usr/bin/apt-get update'"), + ).not.toContain('privileged-command'); + }); + + it('still detects commands after unquoted command separators', (): void => { + expect(exampleSafetyViolationKinds('printf safe; /usr/bin/apt-get update')).toContain( + 'privileged-command', + ); + }); + it.each([ "env --split-string='-i -- /usr/bin/printf safe'", "env --split-string='FOO=x /usr/bin/printf safe'", + 'env -iu FOO /usr/bin/printf safe', + 'env -ivS/usr/bin/printf safe', + 'su -lc /usr/bin/id operator', 'su -l -s /bin/sh operator', 'su -s /bin/sh -- operator', 'su -c /usr/bin/id operator',