fix: address PowerShell review bypasses

This commit is contained in:
haelyra
2026-09-05 16:12:58 -04:00
parent d9f6091ee8
commit 3ad828db47
5 changed files with 77 additions and 5 deletions
+7
View File
@@ -133,6 +133,13 @@ function summarizeCommand(command) {
};
}
if (trimmed.startsWith("'") || trimmed.startsWith('"')) {
return {
commandName: null,
commandFingerprint: fingerprintCommand(trimmed),
};
}
const firstToken = trimmed.split(/\s+/)[0] || '';
// Static method invocations can attach their arguments to the first token,
// for example `[IO.File]::Delete('private-path')`. Keep the operation name
+41 -3
View File
@@ -28,6 +28,7 @@ const RULE_IDS = Object.freeze({
const DELETE_COMMANDS = new Set([
'remove-item',
'remove-itemproperty',
'rp',
'ri',
'rm',
'rmdir',
@@ -507,6 +508,33 @@ function staticStringResult(body) {
return quote === "'" ? content.replace(/''/g, "'") : content.replace(/`(.)/gs, '$1');
}
function leadingStaticStringResult(source) {
const input = String(source || '');
let index = 0;
while (/\s/.test(input[index] || '')) index += 1;
const quote = input[index];
if (quote !== "'" && quote !== '"') return null;
index += 1;
let value = '';
while (index < input.length) {
const char = input[index];
if (quote === "'" && char === "'" && input[index + 1] === "'") {
value += "'";
index += 2;
continue;
}
if (quote === '"' && char === '`' && index + 1 < input.length) {
value += input[index + 1];
index += 2;
continue;
}
if (char === quote) return value;
value += char;
index += 1;
}
return null;
}
function staticScalarResult(body, depth = 0) {
if (depth > MAX_SCAN_DEPTH) return null;
const value = String(body || '').trim();
@@ -1083,8 +1111,19 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
}
if (isCommandFlag(token)) {
const payload = tokens.slice(index + 1).join(' ');
let payload = tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
const payloadReference = tokens.quotedTokens?.[index + 1] === true
? null
: variableReference(payload);
if (payloadReference) {
const staticValue = scanState?.staticScalars.get(payloadReference);
if (staticValue === undefined) {
findings.add(RULE_IDS.DYNAMIC_EXECUTION);
return;
}
payload = staticValue;
}
if (pipelinePayload || (payload && payload !== '-')) {
addNestedScan(
pipelinePayload || payload,
@@ -1558,8 +1597,7 @@ function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, stat
const pattern = /\$executioncontext\.invokecommand\.invokescript\s*\(/gi;
while (pattern.exec(unquoted) !== null) {
const argumentSource = source.slice(pattern.lastIndex);
const literal = argumentSource.match(/^\s*(?:'(?:''|[^'])*'|"(?:`[\s\S]|[^"])*")/);
const payload = literal ? staticStringResult(literal[0].trim()) : null;
const payload = leadingStaticStringResult(argumentSource);
if (payload === null) {
findings.add(RULE_IDS.DYNAMIC_EXECUTION);
} else {
+2
View File
@@ -2964,8 +2964,10 @@ function runTests() {
test('denies direct and nested destructive PowerShell commands', () => {
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"',
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
+16 -2
View File
@@ -325,7 +325,7 @@ async function runTests() {
})) passed += 1; else failed += 1;
if (await test('PowerShell governance normalizes tool casing and redacts assignment prefixes', async () => {
const command = "$password='governance-secret-sentinel'; Remove-Item -Force C:/tmp/demo";
const command = "$label='governance-private-marker'; Remove-Item -Force C:/tmp/demo";
for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) {
const events = analyzeForGovernanceEvents({
tool_name: toolName,
@@ -337,10 +337,24 @@ async function runTests() {
assert.ok(approvalEvent, `${toolName} should raise approval_requested`);
assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell');
assert.strictEqual(approvalEvent.payload.commandName, null);
assert.ok(!JSON.stringify(events).includes('governance-secret-sentinel'));
assert.ok(!JSON.stringify(events).includes('governance-private-marker'));
}
})) passed += 1; else failed += 1;
if (await test('PowerShell governance redacts quoted expression prefixes', async () => {
const command = "'quoted-private-marker' ; Remove-Item -Force C:/tmp/demo";
const events = analyzeForGovernanceEvents({
tool_name: 'PowerShell',
tool_input: { command },
}, {
hookPhase: 'pre',
});
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
assert.ok(approvalEvent, 'quoted prefix should still raise approval_requested');
assert.strictEqual(approvalEvent.payload.commandName, null);
assert.ok(!JSON.stringify(events).includes('quoted-private-marker'));
})) passed += 1; else failed += 1;
if (await test('PowerShell elevation events are captured without raw command leakage', async () => {
const commands = [
'Start-Process -Verb RunAs cmd -ArgumentList elevation-command-sentinel',
@@ -98,6 +98,7 @@ test('classifies recursive Remove-Item aliases', () => {
for (const alias of ['ri', 'rm', 'rmdir', 'rd', 'del', 'erase']) {
expectRules(`${alias} -Recurse C:/tmp/demo`, [RULES.REMOVE_RECURSE]);
}
expectRules('rp -Force HKCU:/Software/Demo -Name setting', [RULES.REMOVE_FORCE]);
expectRules('Remove-ItemProperty -Force HKCU:/Software/Demo -Name setting', [
RULES.REMOVE_FORCE,
]);
@@ -455,10 +456,13 @@ test('classifies static execution primitives', () => {
"$cmd = 'Remove-Item -Force C:/tmp/demo'; & ([scriptblock]::Create($cmd))",
"$name = 'Remove-Item'; & $name -Force C:/tmp/demo",
"$args = '-Command \"Remove-Item -Force C:/tmp/demo\"'; Start-Process pwsh -ArgumentList $args",
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
expectRules('pwsh -Command $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
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,
@@ -492,6 +496,13 @@ test('classifies static execution primitives', () => {
);
});
test('scans malformed InvokeScript string arguments in bounded time', () => {
const command = `$ExecutionContext.InvokeCommand.InvokeScript("${'`!'.repeat(10000)}`;
const startedAt = Date.now();
expectRules(command, [RULES.DYNAMIC_EXECUTION]);
assert.ok(Date.now() - startedAt < 1000, 'malformed string scan should remain bounded');
});
test('classifies command names composed from static subexpression output', () => {
expectRules('Remove-$(Write-Output Item) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('Clear-$(echo Disk) -Number 2', [RULES.CLEAR_DISK]);