test(fleet): harden static command parsing
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:
Jarvis
2026-07-16 14:32:43 -05:00
parent 42c7980dfb
commit 862dbd5204

View File

@@ -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;
for (let index = 0; index < source.length; index += 1) {
const character = source[index] ?? '';
const next = source[index + 1] ?? '';
if (character === '\\' && quote !== "'") {
if (next === '\n') {
index += 1;
continue;
}
if (character === '\\' && quote !== "'") {
segment += character;
escaped = true;
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<Record<string, string>> = {
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,29 +435,65 @@ function containsPrivilegedCommand(source: string): boolean {
const tokens = normalizedCommandTokens(segment);
const command = commandBasename(tokens[0] ?? '');
if (privilegedCommands.has(command)) return true;
if (command !== 'su') return false;
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 === '--') {
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,
)
) {
index += 2;
continue;
if ((token === '--group' || token === '--supp-group') && tokens[index + 1] === 'root') {
rootGroup = true;
}
if (
/^(?:--command=|--session-command=|--group=|--supp-group=|--shell=|--whitelist-environment=)/.test(
token,
)
) {
index += 1;
index += 2;
continue;
}
if (/^-[^-]/.test(token)) {
@@ -371,7 +502,10 @@ function containsPrivilegedCommand(source: string): boolean {
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'",