test(fleet): harden documentation command scanner
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user