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);
|
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[] {
|
function shellCommandTokens(source: string): string[] {
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
let token = '';
|
let token = '';
|
||||||
let quote = '';
|
let quote = '';
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
for (const character of source) {
|
for (const character of source) {
|
||||||
|
if (escaped) {
|
||||||
|
token += character;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '\\' && quote !== "'") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (quote !== '') {
|
if (quote !== '') {
|
||||||
if (character === quote) quote = '';
|
if (character === quote) quote = '';
|
||||||
else token += character;
|
else token += character;
|
||||||
@@ -159,6 +211,7 @@ function shellCommandTokens(source: string): string[] {
|
|||||||
}
|
}
|
||||||
token += character;
|
token += character;
|
||||||
}
|
}
|
||||||
|
if (escaped) token += '\\';
|
||||||
if (token !== '') tokens.push(token);
|
if (token !== '') tokens.push(token);
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
@@ -177,22 +230,47 @@ function normalizedCommandTokens(segment: string): string[] {
|
|||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^(?:-u|--unset|-C|--chdir)$/.test(token)) {
|
if (token === '--') {
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (/^(?:--unset|--chdir)$/.test(token)) {
|
||||||
index += 2;
|
index += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^(?:-u|--unset=|-C|--chdir=)/.test(token)) {
|
if (/^(?:--unset=|--chdir=)/.test(token)) {
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^(?:-S|--split-string)$/.test(token)) {
|
if (token === '--split-string') {
|
||||||
const splitTokens = shellCommandTokens(tokens[index + 1] ?? '');
|
const splitTokens = shellCommandTokens(tokens[index + 1] ?? '');
|
||||||
tokens.splice(index, 2, ...splitTokens);
|
tokens.splice(index, 2, ...splitTokens);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const splitString = token.match(/^(?:-S|--split-string=)(.*)$/)?.[1];
|
const longSplitString = token.match(/^--split-string=(.*)$/)?.[1];
|
||||||
if (splitString !== undefined) {
|
if (longSplitString !== undefined) {
|
||||||
tokens.splice(index, 1, ...shellCommandTokens(splitString));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (token.startsWith('-')) {
|
if (token.startsWith('-')) {
|
||||||
@@ -258,7 +336,7 @@ function containsPrivilegedCommand(source: string): boolean {
|
|||||||
'nix-env',
|
'nix-env',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return source.split(/[\n;&|]+/).some((segment): boolean => {
|
return shellCommandSegments(source).some((segment): boolean => {
|
||||||
const tokens = normalizedCommandTokens(segment);
|
const tokens = normalizedCommandTokens(segment);
|
||||||
const command = commandBasename(tokens[0] ?? '');
|
const command = commandBasename(tokens[0] ?? '');
|
||||||
if (privilegedCommands.has(command)) return true;
|
if (privilegedCommands.has(command)) return true;
|
||||||
@@ -272,7 +350,7 @@ function containsPrivilegedCommand(source: string): boolean {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (
|
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,
|
token,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -287,13 +365,28 @@ function containsPrivilegedCommand(source: string): boolean {
|
|||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
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('-')) {
|
if (token.startsWith('-')) {
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
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',
|
expected: 'privileged-command',
|
||||||
source: '/usr/bin/env -u FOO /usr/bin/apt-get install example',
|
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',
|
expected: 'privileged-command',
|
||||||
source: 'env --unset FOO /usr/bin/apt-get install example',
|
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: 'reboot' },
|
||||||
{ expected: 'privileged-command', source: 'su --command=/bin/sh' },
|
{ 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 root' },
|
||||||
{ expected: 'privileged-command', source: 'su -l -s /bin/sh root' },
|
{ expected: 'privileged-command', source: 'su -l -s /bin/sh root' },
|
||||||
{ expected: 'privileged-command', source: '/usr/bin/su --login 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');
|
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([
|
it.each([
|
||||||
"env --split-string='-i -- /usr/bin/printf safe'",
|
"env --split-string='-i -- /usr/bin/printf safe'",
|
||||||
"env --split-string='FOO=x /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 -l -s /bin/sh operator',
|
||||||
'su -s /bin/sh -- operator',
|
'su -s /bin/sh -- operator',
|
||||||
'su -c /usr/bin/id operator',
|
'su -c /usr/bin/id operator',
|
||||||
|
|||||||
Reference in New Issue
Block a user