diff --git a/packages/mosaic/src/fleet/fleet-documentation.spec.ts b/packages/mosaic/src/fleet/fleet-documentation.spec.ts index bc9fb8a..fdddc26 100644 --- a/packages/mosaic/src/fleet/fleet-documentation.spec.ts +++ b/packages/mosaic/src/fleet/fleet-documentation.spec.ts @@ -135,12 +135,36 @@ function commandBasename(token: string): string { return token.slice(token.lastIndexOf('/') + 1); } +function shellCommandTokens(source: string): string[] { + const tokens: string[] = []; + let token = ''; + let quote = ''; + + for (const character of source) { + if (quote !== '') { + if (character === quote) quote = ''; + else token += character; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (/\s/.test(character)) { + if (token !== '') { + tokens.push(token); + token = ''; + } + continue; + } + token += character; + } + if (token !== '') tokens.push(token); + return tokens; +} + function normalizedCommandTokens(segment: string): string[] { - const tokens = segment - .trim() - .replace(/^(?:[$#>]\s*)/, '') - .split(/\s+/) - .filter((token): boolean => token !== ''); + const tokens = shellCommandTokens(segment.trim().replace(/^(?:[$#>]\s*)/, '')); let index = 0; while (index < tokens.length) { @@ -162,7 +186,13 @@ function normalizedCommandTokens(segment: string): string[] { continue; } if (/^(?:-S|--split-string)$/.test(token)) { - index += 1; + const splitTokens = shellCommandTokens(tokens[index + 1] ?? ''); + tokens.splice(index, 2, ...splitTokens); + break; + } + const splitString = token.match(/^(?:-S|--split-string)=(.*)$/)?.[1]; + if (splitString !== undefined) { + tokens.splice(index, 1, ...shellCommandTokens(splitString)); break; } if (token.startsWith('-')) { @@ -175,7 +205,14 @@ function normalizedCommandTokens(segment: string): string[] { } if (wrapper === 'command') { index += 1; - while ((tokens[index] ?? '').startsWith('-')) index += 1; + let availabilityQuery = false; + while ((tokens[index] ?? '').startsWith('-')) { + const option = tokens[index] ?? ''; + index += 1; + if (option === '--') break; + if (/^-[^-]*[vV]/.test(option)) availabilityQuery = true; + } + if (availabilityQuery) return []; continue; } break; @@ -226,10 +263,28 @@ function containsPrivilegedCommand(source: string): boolean { const command = commandBasename(tokens[0] ?? ''); if (privilegedCommands.has(command)) return true; const argument = tokens[1] ?? ''; - return ( + if ( command === 'su' && (/^(?:root|-|--command|-c)$/.test(argument) || argument.startsWith('--command=')) - ); + ) { + return true; + } + if (command !== 'su') return false; + + let index = 1; + while (index < tokens.length) { + const token = tokens[index] ?? ''; + if (token === '-l' || token === '--login') { + return (tokens[index + 1] ?? '') === 'root'; + } + if (token === '--') return (tokens[index + 1] ?? '') === 'root'; + if (token.startsWith('-')) { + index += 1; + continue; + } + return token === 'root'; + } + return false; }); } @@ -298,8 +353,15 @@ const UNSAFE_EXAMPLE_FIXTURES = [ source: 'env --unset FOO /usr/bin/apt-get install example', }, { expected: 'privileged-command', source: 'env -S apt-get install example' }, + { expected: 'privileged-command', source: "env -S 'apt-get update'" }, + { + expected: 'privileged-command', + source: "env --split-string='apt-get update'", + }, { expected: 'privileged-command', source: 'reboot' }, { expected: 'privileged-command', source: 'su --command=/bin/sh' }, + { expected: 'privileged-command', source: 'su -l root' }, + { expected: 'privileged-command', source: '/usr/bin/su --login root' }, { expected: 'privileged-command', source: 'su root' }, { expected: 'privileged-command', source: '/usr/sbin/chroot /srv/example' }, { expected: 'credential-format', source: ['ghp_', 'a'.repeat(36)].join('') }, @@ -351,6 +413,10 @@ describe('documentation validation regressions', (): void => { ); }); + it('allows command availability queries without classifying the queried command', (): void => { + expect(exampleSafetyViolationKinds('command -v apt-get')).not.toContain('privileged-command'); + }); + it.each(UNSAFE_EXAMPLE_FIXTURES)( 'classifies unsafe example fixture as $expected', ({ expected, source }): void => {