diff --git a/packages/shell-scan/research/README.md b/packages/shell-scan/research/README.md index 70ca7169e53..ec5c0d23a7b 100644 --- a/packages/shell-scan/research/README.md +++ b/packages/shell-scan/research/README.md @@ -50,6 +50,6 @@ Add one syntax class only when representative commands show meaningful opacity. | Replace Bash tree-sitter | 1.38 MB grammar, ~69k parity scans/sec | Pure TS, ~149k parity scans/sec | Keep | | Replace PowerShell tree-sitter | 0.98 MB grammar + 0.21 MB shared runtime | Pure TS; no Core parser assets | Keep | | Combined scanner bundle | 2.57 MB parser assets | 10.1 KB minified, 3.6 KB gzip | Keep | -| Security hardening | Initial portable subset | 397 scanner tests, 685 assertions, zero known unsafe shell-grammar corpus cases | Keep | +| Security hardening | Initial portable subset | 400 scanner tests, 689 assertions, zero known unsafe shell-grammar corpus cases | Keep | The TUI's independent tree-sitter grammar remains for syntax highlighting. Core has no tree-sitter runtime dependency. diff --git a/packages/shell-scan/research/results.jsonl b/packages/shell-scan/research/results.jsonl index 1f71289c012..1e67abaa192 100644 --- a/packages/shell-scan/research/results.jsonl +++ b/packages/shell-scan/research/results.jsonl @@ -2,4 +2,4 @@ {"target":"shell-permission-scan","hypothesis":"the same permission-focused scanner design can replace PowerShell tree-sitter","metric":"parser_asset_bytes","before":1188724,"after":0,"decision":"keep","notes":"removed the 983,236-byte PowerShell grammar and 205,488-byte shared runtime from Core; focused tests and CLI asset tests pass"} {"target":"shell-permission-scan","hypothesis":"PowerShell safety hardening remains negligible relative to removed parser assets","metric":"scanner_minified_bytes","before":4203,"after":4846,"decision":"keep","notes":"added quote escaping, redirects, malformed input, dynamic execution, scripts/modules, and location guards; gzip is 1,950 bytes and scanner throughput is 349,777/sec"} {"target":"shell-permission-scan","hypothesis":"removing hot-path slices and repeated Set allocation improves throughput while simplifying classification","metric":"scans_per_second","before":349777,"after":514076,"decision":"keep","notes":"median of five complete 9-run benchmark invocations after simplification; 113 scanner tests remain green"} -{"target":"shell-permission-scan","hypothesis":"mutation closure and grammar hardening remain fast and compact enough for permission analysis","metric":"scans_per_second","before":410594,"after":404948,"decision":"keep","notes":"final 9-run benchmark median; 397 scanner tests and 685 assertions pass; bundle is 10,147 bytes minified and 3,615 bytes gzip"} +{"target":"shell-permission-scan","hypothesis":"mutation closure and grammar hardening remain fast and compact enough for permission analysis","metric":"scans_per_second","before":410594,"after":415493,"decision":"keep","notes":"final 9-run benchmark median; 400 scanner tests and 689 assertions pass; bundle is 10,147 bytes minified and 3,615 bytes gzip"} diff --git a/packages/shell-scan/src/index.ts b/packages/shell-scan/src/index.ts index 3ad329e78f5..4aa04772adf 100644 --- a/packages/shell-scan/src/index.ts +++ b/packages/shell-scan/src/index.ts @@ -519,8 +519,10 @@ export function scanPowerShell(input: string): Result { if (/^#requires\b/i.test(input.slice(index))) return { kind: "opaque", reason: "dynamic-execution" } finishCommand(index) comment = true - const newline = input.indexOf("\n", index) + const endings = [input.indexOf("\n", index), input.indexOf("\r", index)].filter((ending) => ending >= 0) + const newline = endings.length > 0 ? Math.min(...endings) : -1 if (newline === -1) break + comment = false index = newline segment = newline + 1 continue @@ -558,7 +560,7 @@ export function scanPowerShell(input: string): Result { } if (quote) return { kind: "opaque", reason: "unterminated-quote" } - if (!comment || input.includes("\n")) finishCommand(input.length) + if (!comment) finishCommand(input.length) if (redirectTarget || invalid || dangling) return { kind: "opaque", reason: "invalid-structure" } if ( dynamic || @@ -605,7 +607,8 @@ function shellCommandName(word: string | undefined) { } function knownPowerShellDirectory(word: string) { - return /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.test(word) + const variable = /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.exec(word) + return Boolean(variable) && !word.slice(variable?.[0].length).includes("$") } function powerShellRedirect(input: string, index: number) { diff --git a/packages/shell-scan/test/adversarial.test.ts b/packages/shell-scan/test/adversarial.test.ts index d731f770bcd..ba5a5b149b9 100644 --- a/packages/shell-scan/test/adversarial.test.ts +++ b/packages/shell-scan/test/adversarial.test.ts @@ -149,6 +149,8 @@ describe("ShellScan adversarial corpus", () => { "pushd Alias:; si harmless Remove-Item; harmless victim", "Invoke-`\nExpression 'Remove-Item victim'", "<# ignored #> Remove-Item victim", + "Set-Location $HOME/$target; Get-ChildItem", + "Push-Location $env:TEMP/$target; Get-ChildItem", ])("fails closed for secondary PowerShell execution: %s", (input) => { expect(ShellScan.scanPowerShell(input).kind).toBe("opaque") }) diff --git a/packages/shell-scan/test/scan.test.ts b/packages/shell-scan/test/scan.test.ts index 4732b5a908e..5833d00058b 100644 --- a/packages/shell-scan/test/scan.test.ts +++ b/packages/shell-scan/test/scan.test.ts @@ -173,6 +173,13 @@ describe("ShellScan PowerShell", () => { expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"]) }) + test("ends comments at carriage returns", () => { + const result = ShellScan.scanPowerShell("# comment\rRemove-Item victim") + expect(result.kind).toBe("scanned") + if (result.kind === "opaque") return + expect(result.commands.map((command) => command.words[0])).toEqual(["Remove-Item"]) + }) + test("scans static commands and pipelines", () => { expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toEqual({ kind: "scanned",