test(fleet): harden static command parsing
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:
@@ -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;
|
||||
continue;
|
||||
}
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index] ?? '';
|
||||
const next = source[index + 1] ?? '';
|
||||
if (character === '\\' && quote !== "'") {
|
||||
segment += character;
|
||||
escaped = true;
|
||||
if (next === '\n') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
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,38 +435,77 @@ function containsPrivilegedCommand(source: string): boolean {
|
||||
const tokens = normalizedCommandTokens(segment);
|
||||
const command = commandBasename(tokens[0] ?? '');
|
||||
if (privilegedCommands.has(command)) return true;
|
||||
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 === '--') {
|
||||
index += 1;
|
||||
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,
|
||||
)
|
||||
) {
|
||||
if ((token === '--group' || token === '--supp-group') && tokens[index + 1] === 'root') {
|
||||
rootGroup = true;
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/^(?:--command=|--session-command=|--group=|--supp-group=|--shell=|--whitelist-environment=)/.test(
|
||||
token,
|
||||
)
|
||||
) {
|
||||
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;
|
||||
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'",
|
||||
|
||||
Reference in New Issue
Block a user