diff --git a/CHANGELOG.md b/CHANGELOG.md index 1595bb5a..388e63f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 … }"`, `& { & { & { & { … } } } }` — +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 … }"`、 +`& { & { & { & { … } } } }`——会被静默忽略:既没有解析出目标,也没有 +unresolved 标记,门禁完全不命中。现在嵌套体递归扫描至三层,更深的按 +unresolved 失败关闭,包装链再也藏不住写入。 + ### O170 — shell-exec env assignments and tmux/screen spawn forms are blocked `BASH_ENV= bash -c ':'` sources the file, `ENV=` does the same for POSIX diff --git a/src/safety/pwsh-write-targets.ts b/src/safety/pwsh-write-targets.ts index 38eea6e8..3de9d99a 100644 --- a/src/safety/pwsh-write-targets.ts +++ b/src/safety/pwsh-write-targets.ts @@ -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[] = []; @@ -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); } return { resolved, unresolved }; diff --git a/test/o148-pwsh-deep-nesting.spec.mjs b/test/o148-pwsh-deep-nesting.spec.mjs new file mode 100644 index 00000000..c3e2bf55 --- /dev/null +++ b/test/o148-pwsh-deep-nesting.spec.mjs @@ -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 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 } resolves the target', () => { + assert.equal(hasCfg(scan(`& { Set-Content '${CFG}' x }`)), true); +}); + +test('depth 2: & { & { Set-Content } } 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 }" 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); +});