fix: reject late PowerShell scalar resolution

This commit is contained in:
haelyra
2026-09-07 16:23:30 -04:00
parent db88758cbd
commit bf0ac4e4b3
4 changed files with 37 additions and 2 deletions
+15 -1
View File
@@ -1164,6 +1164,13 @@ function createScanState() {
function collectStaticScalarAssignments(input, state) {
const variable = String.raw`(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)`;
const firstReferences = new Map();
const referencePattern = new RegExp(variable, 'g');
let reference;
while ((reference = referencePattern.exec(input)) !== null) {
const name = reference[1].toLowerCase();
if (!firstReferences.has(name)) firstReferences.set(name, reference.index);
}
const assignmentCounts = new Map();
const assignmentPattern = new RegExp(`${variable}\\s*(?:\\+=|-=|\\*=|\\/=|%=|=)`, 'g');
let assignmentMatch;
@@ -1177,11 +1184,18 @@ function collectStaticScalarAssignments(input, state) {
);
let match;
while ((match = pattern.exec(input)) !== null) {
const name = match[1].toLowerCase();
// The scan pre-collects immutable scalars for nested executable bodies.
// A value assigned after an earlier reference cannot explain that use.
// Keep it unresolved so dynamic execution remains gated. Counting even
// quoted references is deliberately conservative, with a linear scan.
const assignmentIndex = match.index + match[0].indexOf(match[1]);
if (firstReferences.get(name) !== assignmentIndex) continue;
if (match[3] !== undefined && /(^|[^`])\$/.test(match[3])) continue;
const value = match[2] !== undefined
? match[2].replace(/''/g, "'")
: decodeDoubleQuotedString(match[3]);
state.staticScalars.set(match[1].toLowerCase(), value);
state.staticScalars.set(name, value);
}
for (const [name, count] of assignmentCounts) {
if (count !== 1) state.staticScalars.delete(name);
+2 -1
View File
@@ -2991,7 +2991,8 @@ function runTests() {
'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"',
'@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@',
'& Remove-Item -Force C:/tmp/demo',
'Invoke-Expression $runtimeValue'
'Invoke-Expression $runtimeValue',
'pwsh -Command "$payload"; $payload = "Write-Output ok"'
];
for (const command of commands) {
+4
View File
@@ -251,6 +251,10 @@ async function runTests() {
command: 'pwsh -Command "Write-Output ready; $runtimePayload"',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: 'pwsh -Command "$payload"; $payload = "Write-Output ok"',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: 'pwsh -Command $runtimePayload -Force C:/private/runtime-command-sentinel',
expectedRules: ['powershell.dynamic-execution'],
@@ -190,6 +190,22 @@ test('classifies pipeline recursion evidence upstream of Remove-Item', () => {
console.log('\nNested shell payloads:');
test('does not resolve earlier invocations from later scalar assignments', () => {
for (const invocation of [
'pwsh -Command "$payload"',
'pwsh -Command:$payload',
'pwsh -EncodedCommand:$payload',
'Invoke-Expression $payload',
'& $payload',
]) {
expectRules(`${invocation}; $payload = 'Write-Output ok'`, [RULES.DYNAMIC_EXECUTION]);
}
expectRules('pwsh -Command "$payload"; $payload = "Remove-Item -Force C:/tmp/demo"', [
RULES.DYNAMIC_EXECUTION,
]);
expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"');
});
test('classifies powershell and pwsh command payloads recursively', () => {
expectRules(
'powershell -Command "Remove-Item -Recurse C:/tmp/demo"',