Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

## Unreleased

### O148 — PowerShell nesting deeper than one level is analyzed (else fail-closed)

The pwsh write-target scanner analyzed only one level of nested execution
(`& { … }`, `iex "…"`, `pwsh -c "…"`). A write hidden two or more wrappers
deep — `iex "& { Set-Content <config> … }"`, `& { & { & { & { … } } } }` —
was silently ignored: no resolved target, no unresolved marker, no gate hit.
Nested bodies are now scanned recursively up to three levels and anything
deeper is reported as unresolved (fail-closed), so a wrapper chain can no
longer hide a write from the prompt/refusal path.

pwsh 写目标扫描器以前只分析一层嵌套(`& { … }`、`iex "…"`、`pwsh -c "…"`)。
藏在两层及以上包装里的写入——`iex "& { Set-Content <config> … }"`、
`& { & { & { & { … } } } }`——会被静默忽略:既没有解析出目标,也没有
unresolved 标记,门禁完全不命中。现在嵌套体递归扫描至三层,更深的按
unresolved 失败关闭,包装链再也藏不住写入。

### O170 — shell-exec env assignments and tmux/screen spawn forms are blocked

`BASH_ENV=<file> bash -c ':'` sources the file, `ENV=` does the same for POSIX
Expand Down
32 changes: 19 additions & 13 deletions src/safety/pwsh-write-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,9 @@ function hasEncodedCommand(command: string): boolean {
return /(?:^|[\s;|&])-EncodedCommand\b/i.test(command);
}

/** O148: nesting levels analyzed recursively; deeper is unresolved (fail-closed). */
const MAX_NEST_DEPTH = 3;

function scanCommand(command: string, ctx: Ctx, depth: number): PwshWriteTargets {
const resolved: string[] = [];
const unresolved: string[] = [];
Expand Down Expand Up @@ -735,20 +738,23 @@ function scanCommand(command: string, ctx: Ctx, depth: number): PwshWriteTargets
}
}

// One-level nested execution: apply the SAME rules (abbreviations, parens,
// same-line assignments) to the inner string. Deeper nesting → O148.
if (depth < 1) {
for (const inner of extractNestedCommands(command)) {
if (inner === '__iex_opaque__') {
unresolved.push('Invoke-Expression');
continue;
}
// Inner assigned-vars are computed from the inner string itself, so
// same-line assignments inside the nested body are respected.
const nested = scanCommand(inner, ctx, depth + 1);
resolved.push(...nested.resolved);
unresolved.push(...nested.unresolved);
// O148: nested execution is analyzed recursively up to this depth; anything
// deeper is reported as unresolved (fail-closed) instead of being silently
// ignored — `& { & { … } }` used to hide a write from the gate entirely.
for (const inner of extractNestedCommands(command)) {
if (inner === '__iex_opaque__') {
unresolved.push('Invoke-Expression');
continue;
}
if (depth >= MAX_NEST_DEPTH) {
unresolved.push('PowerShell nesting deeper than the scan limit');
continue;
}
// Inner assigned-vars are computed from the inner string itself, so
// same-line assignments inside the nested body are respected.
const nested = scanCommand(inner, ctx, depth + 1);
resolved.push(...nested.resolved);
unresolved.push(...nested.unresolved);
Comment on lines +755 to +757

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound nested-command traversal as well as recursion depth

For deeply nested scriptblocks, extractNestedCommands() returns every descendant block, not just immediate children, so each recursive call scans descendants again and the depth cap does not bound the total work or accumulated results. After building, scanning '& { '.repeat(80) + 'Write-Host hi' + ' }'.repeat(80) took about eight seconds and produced 1,581,580 unresolved entries; 100 wrappers (only 613 characters) threw RangeError: Maximum call stack size exceeded at the spread into unresolved. Since findShellProtectedWrites() invokes this scanner synchronously, these inputs stall or abort protected-path checking instead of returning the intended fail-closed marker. Traverse only immediate nested bodies or enforce a shared work/result budget that stops scanning once exhausted.

Useful? React with 👍 / 👎.

}

return { resolved, unresolved };
Expand Down
76 changes: 76 additions & 0 deletions test/o148-pwsh-deep-nesting.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* O148: PowerShell nested execution deeper than one level was silently
* ignored by the write-target scanner — `& { & { Set-Content <config> x } }`
* resolved nothing (no hit, no unresolved marker), so a write hidden two or
* more wrappers deep escaped the gate. The scanner now recurses to a bounded
* depth and reports anything deeper as unresolved (fail-closed: the caller
* prompts / refuses instead of silently allowing).
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { findPwshWriteTargets } from '../dist/safety/pwsh-write-targets.js';
import { findShellProtectedWrites } from '../dist/safety/protected-paths.js';

const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o148-'));
const profile = path.join(root, 'profile');
const cfg = path.join(profile, 'AppData', 'Roaming', 'moss');
const ws = path.join(root, 'ws');
for (const d of [cfg, ws]) fs.mkdirSync(d, { recursive: true });
test.after(() => fs.rmSync(root, { recursive: true, force: true }));

const CFG = path.join(cfg, 'config.json').replace(/\\/g, '/');
const fwd = (s) => s.replace(/\\/g, '/');
const env = { USERPROFILE: profile, HOME: profile, APPDATA: path.dirname(cfg), PATH: '/usr/bin' };
const ctx = { cwd: ws, workspaceRoot: ws, configDirs: [cfg], env, home: profile };
const scan = (cmd) => findPwshWriteTargets(cmd, ctx);
const hasCfg = (r) => r.resolved.some((p) => fwd(p) === fwd(CFG));

test('depth 1 (baseline): & { Set-Content <config> } resolves the target', () => {
assert.equal(hasCfg(scan(`& { Set-Content '${CFG}' x }`)), true);
});

test('depth 2: & { & { Set-Content <config> } } resolves the target', () => {
const r = scan(`& { & { Set-Content '${CFG}' x } }`);
assert.equal(hasCfg(r), true, JSON.stringify(r));
});

test('depth 3: three wrappers still resolve the target', () => {
const r = scan(`& { & { & { Set-Content '${CFG}' x } } }`);
assert.equal(hasCfg(r), true, JSON.stringify(r));
});

test('depth 2 through iex: iex "& { Set-Content <config> }" resolves', () => {
const r = scan(`iex "& { Set-Content '${CFG}' x }"`);
assert.equal(hasCfg(r), true, JSON.stringify(r));
});

test('deeper than the cap fails closed instead of being ignored', () => {
const deep = `& { & { & { & { Set-Content '${CFG}' x } } } }`;
const r = scan(deep);
assert.ok(r.unresolved.length > 0, `unresolved marker expected; ${JSON.stringify(r)}`);
// End to end: deep nesting must produce a gate hit — either the resolved
// target itself or the fail-closed unresolved marker, never silence.
const hits = findShellProtectedWrites(deep, ctx);
assert.ok(hits.length > 0, 'deep nesting must produce a protected-path hit');
assert.equal(
hits.some((h) => h.category === 'unresolved' || h.category === 'moss-config'),
true,
JSON.stringify(hits)
);
});

test('a deeply nested BENIGN body still fails closed (stricter only)', () => {
const deep = `& { & { & { & { Write-Host hi } } } }`;
assert.ok(scan(deep).unresolved.length > 0);
});

test('no false hits for ordinary one-level reads', () => {
const r = scan(`& { Get-Content '${CFG}' }`);
assert.equal(hasCfg(r), false);
assert.equal(r.unresolved.length, 0);
});
Loading