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[] = [];
|
const segments: string[] = [];
|
||||||
let segment = '';
|
let segment = '';
|
||||||
let quote = '';
|
let quote = '';
|
||||||
let escaped = false;
|
let wordStarted = false;
|
||||||
|
|
||||||
const flush = (): void => {
|
const flush = (): void => {
|
||||||
if (segment.trim() !== '') segments.push(segment);
|
if (segment.trim() !== '') segments.push(segment);
|
||||||
segment = '';
|
segment = '';
|
||||||
|
wordStarted = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const character of source) {
|
for (let index = 0; index < source.length; index += 1) {
|
||||||
if (escaped) {
|
const character = source[index] ?? '';
|
||||||
segment += character;
|
const next = source[index + 1] ?? '';
|
||||||
escaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === '\\' && quote !== "'") {
|
if (character === '\\' && quote !== "'") {
|
||||||
segment += character;
|
if (next === '\n') {
|
||||||
escaped = true;
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
segment += character + next;
|
||||||
|
wordStarted = true;
|
||||||
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (quote !== '') {
|
if (quote !== '') {
|
||||||
@@ -165,6 +168,11 @@ function shellCommandSegments(source: string): string[] {
|
|||||||
if (character === "'" || character === '"') {
|
if (character === "'" || character === '"') {
|
||||||
segment += character;
|
segment += character;
|
||||||
quote = character;
|
quote = character;
|
||||||
|
wordStarted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '#' && !wordStarted && segment.trim() !== '') {
|
||||||
|
while (index + 1 < source.length && source[index + 1] !== '\n') index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (character === '\n' || character === ';' || character === '&' || character === '|') {
|
if (character === '\n' || character === ';' || character === '&' || character === '|') {
|
||||||
@@ -172,6 +180,8 @@ function shellCommandSegments(source: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
segment += character;
|
segment += character;
|
||||||
|
if (/\s/.test(character)) wordStarted = false;
|
||||||
|
else wordStarted = true;
|
||||||
}
|
}
|
||||||
flush();
|
flush();
|
||||||
return segments;
|
return segments;
|
||||||
@@ -216,17 +226,102 @@ function shellCommandTokens(source: string): string[] {
|
|||||||
return tokens;
|
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[] {
|
function normalizedCommandTokens(segment: string): string[] {
|
||||||
const tokens = shellCommandTokens(segment.trim().replace(/^(?:[$#>]\s*)/, ''));
|
const tokens = shellCommandTokens(segment.trim().replace(/^(?:[$#>]\s*)/, ''));
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
while (index < tokens.length) {
|
while (index < tokens.length) {
|
||||||
|
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? '')) index += 1;
|
||||||
const wrapper = commandBasename(tokens[index] ?? '');
|
const wrapper = commandBasename(tokens[index] ?? '');
|
||||||
if (wrapper === 'env') {
|
if (wrapper === 'env') {
|
||||||
index += 1;
|
index += 1;
|
||||||
while (index < tokens.length) {
|
while (index < tokens.length) {
|
||||||
const token = tokens[index] ?? '';
|
const token = tokens[index] ?? '';
|
||||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
if (!token.startsWith('-') && /^[^=]+=/.test(token)) {
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -234,6 +329,7 @@ function normalizedCommandTokens(segment: string): string[] {
|
|||||||
index += 1;
|
index += 1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (token === '--help' || token === '--version') return [];
|
||||||
if (/^(?:--unset|--chdir|--argv0)$/.test(token)) {
|
if (/^(?:--unset|--chdir|--argv0)$/.test(token)) {
|
||||||
index += 2;
|
index += 2;
|
||||||
continue;
|
continue;
|
||||||
@@ -243,13 +339,12 @@ function normalizedCommandTokens(segment: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (token === '--split-string') {
|
if (token === '--split-string') {
|
||||||
const splitTokens = shellCommandTokens(tokens[index + 1] ?? '');
|
tokens.splice(index, 2, ...envSplitStringTokens(tokens[index + 1] ?? ''));
|
||||||
tokens.splice(index, 2, ...splitTokens);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const longSplitString = token.match(/^--split-string=(.*)$/)?.[1];
|
const longSplitString = token.match(/^--split-string=(.*)$/)?.[1];
|
||||||
if (longSplitString !== undefined) {
|
if (longSplitString !== undefined) {
|
||||||
tokens.splice(index, 1, ...shellCommandTokens(longSplitString));
|
tokens.splice(index, 1, ...envSplitStringTokens(longSplitString));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^-[^-]/.test(token)) {
|
if (/^-[^-]/.test(token)) {
|
||||||
@@ -265,7 +360,7 @@ function normalizedCommandTokens(segment: string): string[] {
|
|||||||
if (option === 'S') {
|
if (option === 'S') {
|
||||||
const attached = options.slice(optionIndex + 1);
|
const attached = options.slice(optionIndex + 1);
|
||||||
const operand = attached === '' ? (tokens[index + 1] ?? '') : attached;
|
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;
|
consumed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -299,7 +394,7 @@ function normalizedCommandTokens(segment: string): string[] {
|
|||||||
return tokens.slice(index);
|
return tokens.slice(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsPrivilegedCommand(source: string): boolean {
|
function containsPrivilegedCommand(source: string, depth = 0): boolean {
|
||||||
const privilegedCommands = new Set([
|
const privilegedCommands = new Set([
|
||||||
'sudo',
|
'sudo',
|
||||||
'doas',
|
'doas',
|
||||||
@@ -340,38 +435,77 @@ function containsPrivilegedCommand(source: string): 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;
|
||||||
|
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;
|
if (command !== 'su') return false;
|
||||||
|
|
||||||
let index = 1;
|
let index = 1;
|
||||||
|
let user: string | undefined;
|
||||||
|
let rootGroup = false;
|
||||||
while (index < tokens.length) {
|
while (index < tokens.length) {
|
||||||
const token = tokens[index] ?? '';
|
const token = tokens[index] ?? '';
|
||||||
if (token === '--') {
|
if (token === '--') {
|
||||||
index += 1;
|
user ??= tokens[index + 1];
|
||||||
break;
|
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 (
|
if (
|
||||||
/^(?:--command|--session-command|--group|--supp-group|--shell|--whitelist-environment)$/.test(
|
/^(?:--command|--session-command|--group|--supp-group|--shell|--whitelist-environment)$/.test(
|
||||||
token,
|
token,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
if ((token === '--group' || token === '--supp-group') && tokens[index + 1] === 'root') {
|
||||||
|
rootGroup = true;
|
||||||
|
}
|
||||||
index += 2;
|
index += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
/^(?:--command=|--session-command=|--group=|--supp-group=|--shell=|--whitelist-environment=)/.test(
|
|
||||||
token,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/^-[^-]/.test(token)) {
|
if (/^-[^-]/.test(token)) {
|
||||||
const options = token.slice(1);
|
const options = token.slice(1);
|
||||||
let consumed = false;
|
let consumed = false;
|
||||||
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
|
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
|
||||||
const option = options[optionIndex] ?? '';
|
const option = options[optionIndex] ?? '';
|
||||||
if ('cgGsw'.includes(option)) {
|
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;
|
consumed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -383,10 +517,10 @@ function containsPrivilegedCommand(source: string): boolean {
|
|||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
user ??= token;
|
||||||
|
index += 1;
|
||||||
}
|
}
|
||||||
const user = tokens[index];
|
return rootGroup || user === undefined || user === 'root';
|
||||||
return user === undefined || user === 'root';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +643,30 @@ const UNSAFE_EXAMPLE_FIXTURES = [
|
|||||||
expected: 'privileged-command',
|
expected: 'privileged-command',
|
||||||
source: "env --split-string='-- /usr/bin/apt-get update'",
|
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: '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 -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([
|
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'",
|
||||||
|
|||||||
Reference in New Issue
Block a user