fix(shell-scan): handle PowerShell line endings

This commit is contained in:
Kit Langton
2026-08-12 23:48:00 -04:00
parent bd2b7f4661
commit 690c777c56
5 changed files with 17 additions and 5 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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"}
+6 -3
View File
@@ -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) {
@@ -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")
})
+7
View File
@@ -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",