mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
fix: scan inline PowerShell command parameters
This commit is contained in:
@@ -201,7 +201,7 @@ for static variables embedded in nested double-quoted command payloads.
|
||||
|
||||
- Focused classifier and hook suites: 531 passed, 0 failed.
|
||||
- Full repository suite: 4,217 passed, 0 failed.
|
||||
- Coverage gate: passed at 89.23% statements, 81.29% branches, 94.56%
|
||||
- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.56%
|
||||
functions, and 89.23% lines.
|
||||
- Supply-chain IOC scan: passed for all 224 inspected files.
|
||||
- ESLint, Markdown lint, hook validation, personal-path validation, and
|
||||
|
||||
@@ -520,7 +520,7 @@ function decodeDoubleQuotedString(content) {
|
||||
const escaped = input[index + 1];
|
||||
index += 1;
|
||||
if (escaped === '\r' && input[index + 1] === '\n') index += 1;
|
||||
else if (escaped !== '\n') value += escaped;
|
||||
if (escaped !== '\r' && escaped !== '\n') value += escaped;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -535,7 +535,7 @@ function expandStaticDoubleQuotedString(content, state, findings) {
|
||||
const escaped = input[index + 1];
|
||||
index += 1;
|
||||
if (escaped === '\r' && input[index + 1] === '\n') index += 1;
|
||||
else if (escaped !== '\n') value += escaped;
|
||||
if (escaped !== '\r' && escaped !== '\n') value += escaped;
|
||||
continue;
|
||||
}
|
||||
if (char !== '$') {
|
||||
@@ -596,7 +596,7 @@ function leadingStaticStringResult(source) {
|
||||
const escaped = input[index + 1];
|
||||
index += 2;
|
||||
if (escaped === '\r' && input[index] === '\n') index += 1;
|
||||
else if (escaped !== '\n') value += escaped;
|
||||
if (escaped !== '\r' && escaped !== '\n') value += escaped;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) return value;
|
||||
@@ -924,11 +924,14 @@ function parseStatements(input) {
|
||||
let segmentQuotedTokens = [];
|
||||
let segmentQuoteKinds = [];
|
||||
let segmentTokenSources = [];
|
||||
let segmentInlineValueQuoteKinds = [];
|
||||
let word = '';
|
||||
let wordSource = '';
|
||||
let wordHasQuotedContent = false;
|
||||
let wordHasUnquotedContent = false;
|
||||
let wordQuoteKind = null;
|
||||
let wordInlineValueQuoteKind = null;
|
||||
let wordInlineValueQuoteClosed = false;
|
||||
let quote = null;
|
||||
let parenDepth = 0;
|
||||
let callOperatorPending = false;
|
||||
@@ -941,12 +944,19 @@ function parseStatements(input) {
|
||||
wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null
|
||||
);
|
||||
segmentTokenSources.push(wordSource);
|
||||
segmentInlineValueQuoteKinds.push(
|
||||
wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed'
|
||||
? wordInlineValueQuoteKind
|
||||
: null
|
||||
);
|
||||
}
|
||||
word = '';
|
||||
wordSource = '';
|
||||
wordHasQuotedContent = false;
|
||||
wordHasUnquotedContent = false;
|
||||
wordQuoteKind = null;
|
||||
wordInlineValueQuoteKind = null;
|
||||
wordInlineValueQuoteClosed = false;
|
||||
};
|
||||
const flushSegment = () => {
|
||||
flushWord();
|
||||
@@ -956,6 +966,7 @@ function parseStatements(input) {
|
||||
quotedTokens: { value: segmentQuotedTokens },
|
||||
quoteKinds: { value: segmentQuoteKinds },
|
||||
tokenSources: { value: segmentTokenSources },
|
||||
inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds },
|
||||
});
|
||||
statement.push(segment);
|
||||
callOperatorPending = false;
|
||||
@@ -964,6 +975,7 @@ function parseStatements(input) {
|
||||
segmentQuotedTokens = [];
|
||||
segmentQuoteKinds = [];
|
||||
segmentTokenSources = [];
|
||||
segmentInlineValueQuoteKinds = [];
|
||||
};
|
||||
const flushStatement = () => {
|
||||
flushSegment();
|
||||
@@ -981,6 +993,7 @@ function parseStatements(input) {
|
||||
index += 1;
|
||||
} else if (char === "'") {
|
||||
quote = null;
|
||||
if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true;
|
||||
} else {
|
||||
word += char;
|
||||
wordSource += char;
|
||||
@@ -1004,6 +1017,7 @@ function parseStatements(input) {
|
||||
index += 1;
|
||||
}
|
||||
} else {
|
||||
if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
|
||||
word += escaped;
|
||||
if (quote) wordHasQuotedContent = true;
|
||||
else wordHasUnquotedContent = true;
|
||||
@@ -1014,6 +1028,7 @@ function parseStatements(input) {
|
||||
if (quote === '"') {
|
||||
if (char === '"') {
|
||||
quote = null;
|
||||
if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true;
|
||||
} else {
|
||||
word += char;
|
||||
wordSource += char;
|
||||
@@ -1023,6 +1038,11 @@ function parseStatements(input) {
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') {
|
||||
if (wordInlineValueQuoteClosed) {
|
||||
wordInlineValueQuoteKind = 'mixed';
|
||||
} else if (wordInlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word)) {
|
||||
wordInlineValueQuoteKind = char;
|
||||
}
|
||||
quote = char;
|
||||
wordHasQuotedContent = true;
|
||||
wordQuoteKind = wordQuoteKind === null || wordQuoteKind === char ? char : 'mixed';
|
||||
@@ -1030,6 +1050,7 @@ function parseStatements(input) {
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
|
||||
parenDepth += 1;
|
||||
word += char;
|
||||
wordSource += char;
|
||||
@@ -1037,6 +1058,7 @@ function parseStatements(input) {
|
||||
continue;
|
||||
}
|
||||
if (char === ')' && parenDepth > 0) {
|
||||
if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
|
||||
parenDepth -= 1;
|
||||
word += char;
|
||||
wordSource += char;
|
||||
@@ -1062,6 +1084,7 @@ function parseStatements(input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
|
||||
word += char;
|
||||
wordSource += char;
|
||||
wordHasUnquotedContent = true;
|
||||
@@ -1207,17 +1230,50 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
|
||||
const token = tokens[index];
|
||||
|
||||
if (isEncodedCommandFlag(token)) {
|
||||
const decoded = decodeUtf16LeBase64(tokens[index + 1]);
|
||||
const inlinePayload = parameterValue(token);
|
||||
let encodedPayload = inlinePayload || tokens[index + 1];
|
||||
const payloadIndex = index + 1;
|
||||
const quoteKind = tokens.quoteKinds?.[payloadIndex];
|
||||
const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index];
|
||||
if ((inlinePayload && inlineQuoteKind !== "'") ||
|
||||
(!inlinePayload && encodedPayload && quoteKind !== "'")) {
|
||||
const source = inlinePayload
|
||||
? parameterValue(tokens.tokenSources?.[index] || token)
|
||||
: tokens.tokenSources?.[payloadIndex] ?? encodedPayload;
|
||||
const expanded = expandStaticDoubleQuotedString(
|
||||
source || encodedPayload,
|
||||
scanState,
|
||||
findings
|
||||
);
|
||||
if (expanded === null) return;
|
||||
encodedPayload = expanded;
|
||||
}
|
||||
const decoded = decodeUtf16LeBase64(encodedPayload);
|
||||
if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCommandFlag(token)) {
|
||||
let payload = tokens.slice(index + 1).join(' ');
|
||||
const inlinePayload = parameterValue(token);
|
||||
let payload = inlinePayload
|
||||
? [inlinePayload, ...tokens.slice(index + 1)].join(' ')
|
||||
: tokens.slice(index + 1).join(' ');
|
||||
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
|
||||
const payloadIndex = index + 1;
|
||||
const hasOnePayloadToken = tokens.length === payloadIndex + 1;
|
||||
if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] === '"') {
|
||||
const hasOnePayloadToken = !inlinePayload && tokens.length === payloadIndex + 1;
|
||||
const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index];
|
||||
if (inlinePayload && inlineQuoteKind !== "'") {
|
||||
const inlineSource = parameterValue(tokens.tokenSources?.[index] || token);
|
||||
const expanded = expandStaticDoubleQuotedString(
|
||||
inlineSource || inlinePayload,
|
||||
scanState,
|
||||
findings
|
||||
);
|
||||
if (expanded === null) return;
|
||||
payload = [expanded, ...tokens.slice(index + 1)].join(' ');
|
||||
} else if (inlinePayload) {
|
||||
payload = [inlinePayload, ...tokens.slice(index + 1)].join(' ');
|
||||
} else if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] !== "'") {
|
||||
const expanded = expandStaticDoubleQuotedString(
|
||||
tokens.tokenSources?.[payloadIndex] ?? payload,
|
||||
scanState,
|
||||
|
||||
@@ -2962,11 +2962,17 @@ function runTests() {
|
||||
|
||||
if (
|
||||
test('denies direct and nested destructive PowerShell commands', () => {
|
||||
const encodedPayload = Buffer.from(
|
||||
'Remove-Item -Force C:/tmp/demo',
|
||||
'utf16le'
|
||||
).toString('base64');
|
||||
const commands = [
|
||||
'Remove-Item -Recurse C:/tmp/demo',
|
||||
'rp -Force HKCU:/Software/Demo -Name setting',
|
||||
'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
|
||||
'pwsh -Command "Remove-Item -Force C:/tmp/demo"',
|
||||
'pwsh -Command:"Remove-Item -Force C:/tmp/demo"',
|
||||
`pwsh -EncodedCommand:${encodedPayload}`,
|
||||
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
|
||||
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
|
||||
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
|
||||
|
||||
@@ -239,6 +239,10 @@ async function runTests() {
|
||||
command: 'pwsh -Command "Remove-Item -Force C:/private/nested-command-sentinel"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: 'pwsh -Command:"Remove-Item -Force C:/private/inline-command-sentinel"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"",
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
@@ -251,6 +255,10 @@ async function runTests() {
|
||||
command: `pwsh -EncodedCommand ${encodedPayload}`,
|
||||
expectedRules: ['powershell.remove-item.wildcard'],
|
||||
},
|
||||
{
|
||||
command: `pwsh -EncodedCommand:${encodedPayload}`,
|
||||
expectedRules: ['powershell.remove-item.wildcard'],
|
||||
},
|
||||
{
|
||||
command: 'Write-Output "$(Remove-Item -Force C:/private/subexpression-command-sentinel)"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
@@ -302,7 +310,7 @@ async function runTests() {
|
||||
'Should not store raw command text'
|
||||
);
|
||||
assert.ok(
|
||||
!JSON.stringify(approvalEvent).includes(command),
|
||||
!JSON.stringify(approvalEvent).includes(JSON.stringify(command).slice(1, -1)),
|
||||
'Serialized governance evidence should not leak the raw command'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,6 +228,8 @@ test('classifies powershell and pwsh command payloads recursively', () => {
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('pwsh -cwa "Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]);
|
||||
expectRules('pwsh -Command:"Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]);
|
||||
expectRules('pwsh -Command:Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules(
|
||||
"Start-Process pwsh -ArgumentList '-NoProfile -Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
[RULES.REMOVE_FORCE]
|
||||
@@ -277,6 +279,13 @@ test('classifies UTF-16LE EncodedCommand payloads', () => {
|
||||
).toString('base64');
|
||||
|
||||
expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_WILDCARD]);
|
||||
expectRules(`pwsh -EncodedCommand:${payload}`, [RULES.REMOVE_WILDCARD]);
|
||||
expectRules(`$payload='${payload}'; pwsh -EncodedCommand:$payload`, [
|
||||
RULES.REMOVE_WILDCARD,
|
||||
]);
|
||||
expectRules('pwsh -EncodedCommand $runtimePayload', [RULES.DYNAMIC_EXECUTION]);
|
||||
expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:\`$payload`);
|
||||
expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:'$payload'`);
|
||||
});
|
||||
|
||||
test('ignores an invalid EncodedCommand payload without throwing', () => {
|
||||
@@ -460,6 +469,7 @@ test('classifies static execution primitives', () => {
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $($payload)\"",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:$payload",
|
||||
"$payload = \"Remove-Item `\n-Force C:/tmp/demo\"; pwsh -Command $payload",
|
||||
]) {
|
||||
expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
@@ -474,6 +484,8 @@ test('classifies static execution primitives', () => {
|
||||
]);
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command '$payload'");
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output `$payload\"");
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:`$payload");
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:'$payload'");
|
||||
expectRules('Start-Process pwsh -ArgumentList $runtimeArgs', [RULES.DYNAMIC_EXECUTION]);
|
||||
expectRules("$cmd='Remove-'; $cmd+='Item'; & $cmd -Force C:/tmp/demo", [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
@@ -513,6 +525,10 @@ test('classifies static execution primitives', () => {
|
||||
"$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\nRemove-Item -Force C:/tmp/demo\")",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
"$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\rRemove-Item -Force C:/tmp/demo\")",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
});
|
||||
|
||||
test('scans malformed InvokeScript string arguments in bounded time', () => {
|
||||
|
||||
Reference in New Issue
Block a user