diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 5e47e41b6..1592f6c12 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.34.0" + "version": "0.35.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 414f8284d..8054bf0a5 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.35.0] + +### Changed + +- lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` (`ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses), and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3, the harness's `bash -c`, the `env` of the shebang, and bash itself, and from 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. +- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command is provably a READ-ONLY CMDLET PIPELINE. That second half is an ALLOWLIST, not a list of known executors: with every quoted string replaced by an opaque placeholder, the command is refused outright when it carries ANY EXPANDABLE STRING anywhere (a double-quoted `"…"` span, or a `@"…"@` here-string), on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`, and is then walked token by token so that every token standing at a command position (the start of input, or after `|`, `;`, `{`, `}`, `(`, `=` or a newline) is an allowlisted interrogator: any `Get-*` verb, `gps`/`ps`/`gcim`/`gwmi`/`gci`/`ls`/`dir`/`gi`/`gc`/`cat`/`type`/`gsv`/`gcm`/`gmo`/`gv`/`gl`/`pwd`, `Where-Object`/`?`, `Select-Object`, `ForEach-Object`/`%`, `Sort-Object`, `Measure-Object`, `Group-Object`, the `Format-*` and `Out-*` set, `Write-Output`/`Write-Host`/`echo`, `Select-String`/`sls`, the path helpers, `Compare-Object`/`diff`, the `ConvertTo-*`/`ConvertFrom-*` pair, and the `if`/`else`/`elseif`/`in`/`return` keywords. `$variable` chains, `-parameter` tokens, string placeholders, numbers and ARGUMENTS (`Get-CimInstance Win32_Process`, `Select-Object ProcessId`) are inert; any other command word refuses. The expandable-string disqualifier is there because such a string is EVALUATED where it is written: `"$( … )"` runs its subexpression to build the value, so the operand itself is a command position, and the very placeholder that keeps the scan honest about message text is what hides it. A fresh security review reproduced the whole executor family through that one hole, under `-eq`, `-ceq`, `-like`, `-in`, inside a second `Where-Object`, behind `Get-Content`, and interpolated beside `${env:ComSpec}`: `"$(cmd /c git push --force)"`, `"$(bash -c 'git push --force')"`, `"$(powershell -c 'git reset --hard')"`, `"$(Start-Process git -ArgumentList push,--force)"`, `"$(& 'git' push -f)"`, `"$(Start-Job { git push -f })"`, `"$(Invoke-Item git.exe)"`, `"$(New-Object …)"`, `"$(node -e 'x')"`, `"$(schtasks /create … /tr 'git push -f')"` and a bare `"$(git push --force)"`. The refusal is on the `"` itself, so it does not depend on recognizing any of them. A `@"…"@` here-string is blanked out of the command at intake, taking its git token with it, so that form is refused at the fail-closed sink instead, where the blanking happened; a verbatim `@'…'@` body carries no command position and is untouched. VERBATIM `'…'` operands, which is every shape this bullet exempts, are unaffected. Inverting the test is what makes it sound: a blocklist of executors is structurally under-inclusive, and an unrecognized command word now costs an over-block instead of a bypass. Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, a command past the scan's length ceiling, and every `… -eq 'git' … | % { $_.Name }` form, that is `&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic process call create`, `schtasks /tr`, `Invoke-Item`/`ii`, `Start-Job`, `New-Object`, `New-Service`/`nsv`, `New-ScheduledTaskAction`, `iwmi -Class Win32_Process -Name Create`, `Set-Alias zz $_.Name; zz push --force`, `[Diagnostics.Process]::Start($_.Name, …)`, `[scriptblock]::Create($_.Name).Invoke()` and `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`, none of them named by the rule and all of them refused by it. Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted: no command in it carries a git literal in operand position, and none carries a double-quoted string together with a compared git literal) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes. The designed corpus of exempt, counterexample and executor shapes, 47 commands, flips exactly 5, every one BLOCKED→ALLOWED with the sink message gone, allows 6 on both roots and leaves the other 36 blocked byte-for-byte. Extended with the 18 reviewed expandable shapes, 65 commands, it flips 6: those same 5, plus the `@"…"@` here-string operand ALLOWED→BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte. + ## [0.34.0] ### Changed diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 344274b92..5e25b2a81 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1124,6 +1124,28 @@ pin_sink_trigger "classify: git -c section.key=cmd does not enter launcher sink" pin_sink_trigger "classify: \$out=pwsh \$script still enters launcher sink" \ '$out=pwsh $script' "launcher" +# --- A `_to` helper assigns the CALLER's variable, never its own local -------- +# `printf -v` walks bash's dynamic scope outward, so a helper whose own locals +# share a name with the destination the caller passed assigns that local and +# leaves the caller's variable untouched: a silent wrong answer rather than an +# error. `out` is this library's dominant accumulator name, so it is the +# destination a future caller is most likely to pass. The reference call names a +# variable no helper declares, so the two results have to agree. +ps_shadow_probe() { + local out="shadowed" + "$1" out "$2" + printf '%s' "$out" +} +# shellcheck disable=SC2016 +ps_shadow_input='x ${a`}b} ("y") w; q' +for ps_shadow_fn in ps::blank_quoted_spans_to ps::fold_escaped_brace_closers_to \ + ps::call_site_operand_region_to ps::blank_bracket_interiors_to; do + "$ps_shadow_fn" ps_shadow_ref "$ps_shadow_input" + # shellcheck disable=SC2154 # assigned indirectly, by the helper's `printf -v` + assert_eq "$ps_shadow_fn assigns the caller's out, not its own local" \ + "$ps_shadow_ref" "$(ps_shadow_probe "$ps_shadow_fn" "$ps_shadow_input")" +done + # --- #2662: fail-closed headlines must not assert a git command is present ----- # The sink is possibly-git (iex / computed call / computed launcher can fire with # no git token). Assert the softened headline on both the no-git-token path and a @@ -1196,6 +1218,231 @@ run_pwsh "PS #2667: sink allow + reset-hard allow opens iex;reset compound" \ "Invoke-Expression 'Write-Host harmless'; git reset --hard" 0 \ CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ALLOW=ps-unparsable-dynamic-invocation,reset-hard +# --- a quoted git literal in COMPARISON-OPERAND position is data -------------- +# ps::might_invoke_git exempts a quoted literal whose nearest preceding token is a +# comparison operator, but only in a command that carries no way to execute a +# computed value. Read-only pipelines that merely NAME git now pass the sink; a +# command that could turn the compared string back into a command word does not. +run_pwsh "PS cmp: -eq 'git' in a script block is data (allowed)" \ + "Get-Process | Where-Object { \$_.Name -eq 'git' }" 0 +run_pwsh "PS cmp: -in @('git.exe','bash.exe') is data (allowed)" \ + "Get-CimInstance Win32_Process | Where-Object { \$_.Name -in @('git.exe','bash.exe') } | Select-Object ProcessId" 0 +run_pwsh "PS cmp: case-prefixed -ceq 'git' is data (allowed)" \ + "Get-Process | Where-Object { \$_.Name -ceq 'git' } | Select-Object Id" 0 +run_pwsh "PS cmp: -notin list element is data (allowed)" \ + "Get-Process | ? { \$_.Name -notin @('git','node') }" 0 +run_pwsh "PS cmp: -like 'git*' is data (allowed)" \ + "Get-Process | ? { \$_.Name -like 'git*' }" 0 +run_pwsh "PS cmp: -match \"^git\\.exe\$\" is data (allowed)" \ + "Get-Process | ? { \$_.Name -match \"^git\\.exe\$\" } | Select-Object Id" 0 +# Rows the class already allowed — the exemption must not disturb them. +run_pwsh "PS cmp: no git token at all (allowed)" \ + "Get-ChildItem | Where-Object { \$_.Length -gt 0 }" 0 +run_pwsh "PS cmp: Get-Process git as an argument (allowed)" \ + "Get-Process git | Select-Object Id" 0 +run_pwsh "PS cmp: GitHub path component is not a git command (allowed)" \ + "Get-ChildItem C:\\code\\proj | Where-Object { \$_.PSIsContainer }" 0 + +# Counterexamples: every one keeps the quote-intact probe and stays blocked. +run_pwsh "PS cmp: bare git in call position beside a comparison (blocked)" \ + "git status; Get-Process | Where-Object { \$_.Name -eq 'node' }" 2 +run_pwsh "PS cmp: Start-Process 'git' is a call target, not an operand (blocked)" \ + "Start-Process 'git' reset --hard; Get-Process | Where-Object { \$_.Name -eq 'x' }" 2 +run_pwsh "PS cmp: saps 'git' is a call target, not an operand (blocked)" \ + "saps 'git' -ArgumentList 'push -f' | % { \$_ }" 2 +run_pwsh "PS cmp: cmd /c 'git push --force' is a nested shell (blocked)" \ + "cmd /c 'git push --force' ; Get-Process | ? { \$_.Name -eq 'node' }" 2 +run_pwsh "PS cmp: computed call of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { & \$_.Name push -f }" 2 +run_pwsh "PS cmp: dot-source of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { . \$_.Name push -f }" 2 +run_pwsh "PS cmp: iex of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { iex \$_.Name }" 2 +run_pwsh "PS cmp: assignment is not a comparison operator (blocked)" \ + "\$n = 'git'; & \$n push -f | % { \$_ }" 2 +run_pwsh "PS cmp: pipeline input is not an operand (blocked)" \ + "'git' | % { & \$_ push -f }" 2 +run_pwsh "PS cmp: cmd /c of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name push -f }" 2 +run_pwsh "PS cmp: bash -c of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { bash -c \$_.Name }" 2 +run_pwsh "PS cmp: quoted launcher calling the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { & 'bash' -c \$_.Name }" 2 +run_pwsh "PS cmp: path-shaped call of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { & .\\\$_.Name push -f }" 2 +run_pwsh "PS cmp: Invoke-Command around the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Command -ScriptBlock { & \$_.Name } }" 2 +# Executors an enumeration cannot converge on. Each of these reaches a program +# without a call operator, an evaluator or a shell word, and each is refused by +# the read-only-cmdlet allowlist rather than by being named — a .NET static +# member, the automatic InvokeCommand API, a run-time alias, a script block +# compiled from the value, WMI/CIM process creation, a service binary path, a +# scheduled-task action, and any launcher that happens to be on PATH. +run_pwsh "PS cmp: [Diagnostics.Process]::Start of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { [Diagnostics.Process]::Start(\$_.Name,'push --force') }" 2 +run_pwsh "PS cmp: InvokeCommand.InvokeScript of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { \$ExecutionContext.InvokeCommand.InvokeScript(\$_.Name) }" 2 +run_pwsh "PS cmp: an alias minted from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Set-Alias zz \$_.Name }; zz push --force" 2 +run_pwsh "PS cmp: a script block compiled from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { \$sb=[scriptblock]::Create(\$_.Name); \$sb.Invoke() }" 2 +run_pwsh "PS cmp: iwmi Win32_Process Create of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { iwmi -Class Win32_Process -Name Create -ArgumentList \$_.Name }" 2 +run_pwsh "PS cmp: a service binary path from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { nsv -Name z -BinaryPathName \$_.Name }" 2 +run_pwsh "PS cmp: schtasks /tr of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { schtasks /create /tn z /sc once /st 00:00 /tr \$_.Name }" 2 +run_pwsh "PS cmp: wmic process call create of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { wmic process call create \$_.Name }" 2 +run_pwsh "PS cmp: a scheduled-task action from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { New-ScheduledTaskAction -Execute \$_.Name }" 2 +run_pwsh "PS cmp: npx of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { npx \$_.Name }" 2 +run_pwsh "PS cmp: dotnet of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { dotnet \$_.Name }" 2 +run_pwsh "PS cmp: cscript of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { cscript \$_.Name }" 2 +run_pwsh "PS cmp: explorer of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { explorer \$_.Name }" 2 +run_pwsh "PS cmp: ssh running the compared value on a remote host (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { ssh host \$_.Name push -f }" 2 +# The same refusal covers the cmdlets that run a program with no call operator, +# no evaluator and no shell word. +run_pwsh "PS cmp: Invoke-Item of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Item \$_.Name }" 2 +run_pwsh "PS cmp: the ii alias of Invoke-Item (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { ii \$_.Name }" 2 +run_pwsh "PS cmp: Start-Job around the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Start-Job { \$_.Name } }" 2 +run_pwsh "PS cmp: New-Object process construction beside a comparison (blocked)" \ + "New-Object System.Diagnostics.Process; Get-Process | ? { \$_.Name -eq 'git' }" 2 +run_pwsh "PS cmp: call of a quoted git literal (blocked)" \ + "& 'git' commit --no-verify | % { \$_ }" 2 +run_pwsh "PS cmp: quoted subcommand after a bare git (blocked)" \ + "git 'commit' | % { \$_ }" 2 +# Right-hand operands only: a literal on the LEFT of the operator is ambiguous +# with a call target (`& 'git' -eq $x` invokes git), so it keeps the probe. +run_pwsh "PS cmp: left-hand literal keeps the quote-intact probe (blocked)" \ + "'git' -in \$names | % { \$_ }" 2 +# The list walk is bounded; a list long enough to exhaust it fails closed. +run_pwsh "PS cmp: over-long operand list fails closed (blocked)" \ + "Get-Process | ? { \$_.Name -in @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','git') }" 2 +# The sink message is the unchanged one — the exemption narrows what reaches the +# sink, it does not soften what the sink says. +cmp_out="$(pwsh_stderr "Get-Process | ? { \$_.Name -eq 'git' } | % { & \$_.Name push -f }")" +assert_contains "PS cmp: blocked counterexample still names could-reach-git" \ + "$cmp_out" "could reach git" + +# Predicate pins — a hook rc of 0 can hide "entered the sink and was waved +# through by something else", so the mechanism itself is asserted. +pin_predicate "ps::might_invoke_git: -eq 'git' is data" \ + ps::might_invoke_git "Get-Process | Where-Object { \$_.Name -eq 'git' }" 1 +pin_predicate "ps::might_invoke_git: -in @('git.exe','bash.exe') is data" \ + ps::might_invoke_git "Get-CimInstance Win32_Process | Where-Object { \$_.Name -in @('git.exe','bash.exe') } | Select-Object ProcessId" 1 +pin_predicate "ps::might_invoke_git: -notin list element is data" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -notin @('git','node') }" 1 +pin_predicate "ps::might_invoke_git: -match \"^git\\.exe\$\" is data" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -match \"^git\\.exe\$\" } | Select-Object Id" 1 +pin_predicate "ps::might_invoke_git: computed call of the compared value still blocks" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -eq 'git' } | % { & \$_.Name push -f }" 0 +pin_predicate "ps::might_invoke_git: bash -c of the compared value still blocks" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -eq 'git' } | % { bash -c \$_.Name }" 0 +pin_predicate "ps::might_invoke_git: call of a quoted git literal still blocks" \ + ps::might_invoke_git "& 'git' commit --no-verify | % { \$_ }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a comparison pipeline of interrogators is read-only" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq 'git' }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: cmd at a command position refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: an unrecognized command word refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { npx \$_.Name }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a type literal refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { [Diagnostics.Process]::Start(\$_.Name) }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a method call refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { \$ExecutionContext.InvokeCommand.InvokeScript(\$_.Name) }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a quoted launcher name is an argument, not a command word" \ + ps::_is_readonly_cmdlet_pipeline "Get-CimInstance Win32_Process | ? { \$_.Name -in @('git.exe','bash.exe') }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a cmdlet argument is not a command word" \ + ps::_is_readonly_cmdlet_pipeline "Get-CimInstance Win32_Process | Select-Object ProcessId,Name" 0 +pin_sink_trigger "classify: the comparison pipeline still enters the special-construct sink" \ + "Get-Process | Where-Object { \$_.Name -eq 'git' }" "special-construct" + +# --- an EXPANDABLE operand is a command position, not data -------------------- +# A double-quoted string is evaluated where it is written, so `"$( … )"` runs a +# program to build the value the comparison then reads. The walk replaces that +# span with an inert placeholder, which is precisely what hid the executor from +# the read-only-pipeline token scan: a security review reproduced the whole +# family through it. Every shape below therefore stays blocked, and the +# disqualifier is the `"` itself, not recognition of the executor inside it. +run_pwsh "PS cmp: expandable operand running cmd /c (blocked)" \ + "Write-Output (\"x\" -eq \"\$(cmd /c git push --force)\")" 2 +run_pwsh "PS cmp: expandable operand in a Where-Object block (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(cmd /c git push --force)\" }" 2 +run_pwsh "PS cmp: expandable operand running bash -c (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(bash -c 'git push --force')\" }" 2 +run_pwsh "PS cmp: expandable operand running powershell -c (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(powershell -c 'git reset --hard')\" }" 2 +run_pwsh "PS cmp: expandable operand running Start-Process (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(Start-Process git -ArgumentList push,--force)\" }" 2 +run_pwsh "PS cmp: expandable operand calling a quoted git literal (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(& 'git' push -f)\" }" 2 +run_pwsh "PS cmp: expandable operand starting a job (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(Start-Job { git push -f })\" }" 2 +run_pwsh "PS cmp: expandable operand invoking git.exe (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(Invoke-Item git.exe)\" }" 2 +run_pwsh "PS cmp: expandable operand constructing an object beside a git literal (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(New-Object System.Diagnostics.Process)\" -and \$_.Name -eq 'git' }" 2 +run_pwsh "PS cmp: expandable operand running node beside a git literal (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(node -e 'x')\" -and \$_.Name -eq 'git' }" 2 +run_pwsh "PS cmp: expandable operand interpolated into a -like pattern (blocked)" \ + "Get-Process | Where-Object { \$_.Name -like \"*\$(cmd /c git push -f)*\" }" 2 +run_pwsh "PS cmp: expandable operand as a list element under -in (blocked)" \ + "Get-Process | Where-Object { \$_.Name -in @(\"\$(cmd /c git reset --hard)\",'git') }" 2 +run_pwsh "PS cmp: expandable operand behind Get-Content and the ? alias (blocked)" \ + "Get-Content x.txt | ? { \$_ -eq \"\$(cmd /c git clean -fdx)\" }" 2 +run_pwsh "PS cmp: expandable operand in a second Where-Object after an exempt one (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq 'git' } | Where-Object { \$_.Path -eq \"\$(cmd /c git push -f)\" }" 2 +run_pwsh "PS cmp: expandable operand mixing a variable and a subexpression (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\${env:ComSpec} \$(cmd /c git push -f)\" }" 2 +# Holes the executor-list gate left open too: the subexpression names git +# directly, or names an executor no list carried. +run_pwsh "PS cmp: expandable operand running git directly (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(git push --force)\" }" 2 +run_pwsh "PS cmp: expandable operand scheduling a git task (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(schtasks /create /tn x /tr 'git push -f' /sc once /st 00:00)\" }" 2 +# A `@"` here-string is the OTHER expandable form, and it never reaches the +# exemption: its body is blanked at intake, so the git token is gone before the +# probe runs. The refusal therefore sits at the sink, where the blanking happened. +run_pwsh "PS cmp: expandable here-string operand (blocked)" \ + "$(printf '%s\n%s\n%s' "Get-Process | Where-Object { \$_.Name -eq @\"" "\$(cmd /c git push --force)" "\"@ }")" 2 +# A VERBATIM here-string body carries no command position and is unchanged. +run_pwsh "PS cmp: verbatim here-string operand stays data (allowed)" \ + "$(printf '%s\n%s\n%s' "Get-Process | Where-Object { \$_.Name -eq @'" "git" "'@ }")" 0 + +# Predicate pins for the disqualifier itself. A hook rc of 2 can hide "blocked by +# something else entirely", so the gate is asserted directly, including the two +# cases a raw `"` scan and the opaque placeholder kind each get wrong. +pin_predicate "ps::_is_readonly_cmdlet_pipeline: an expandable operand refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq \"git\" }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a verbatim operand still accepts" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq 'git' }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a double quote INSIDE a verbatim string is not an expandable string" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq 'he said \"hi\"' }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: an apostrophe INSIDE an expandable string still refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq \"it's\" }" 1 +pin_predicate "ps::might_invoke_git: an expandable operand is not data" \ + ps::might_invoke_git "Get-Process | Where-Object { \$_.Name -eq \"\$(cmd /c git push --force)\" }" 0 +pin_sink_trigger "classify: the expandable here-string still enters the special-construct sink" \ + "$(printf '%s\n%s\n%s' "Get-Process | Where-Object { \$_.Name -eq @\"" "\$(cmd /c git push --force)" "\"@ }")" "special-construct" + +# RECORDED RESIDUAL, not an endorsement: `-MemberName` dispatch calls a METHOD on +# the filtered object rather than running a program named by the compared value, +# so the read-only allowlist admits it and these stay allowed. Pinned so a later +# narrowing that reaches method dispatch flips a test instead of passing silently. +run_pwsh "PS cmp: ForEach-Object -MemberName Kill stays where it is (allowed)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | ForEach-Object -MemberName Kill" 0 +run_pwsh "PS cmp: ForEach-Object -MemberName with -ArgumentList stays where it is (allowed)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | ForEach-Object -MemberName Start -ArgumentList push,--force" 0 + malformed_rc=0 (cd "$REPO_SHA1" && bash "$HOOK" <<<'not json at all' >/dev/null 2>&1) || malformed_rc=$? assert_exit "malformed JSON payload (blocked)" 2 "$malformed_rc" @@ -1259,7 +1506,7 @@ assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \ run "empty command, no NUL (allowed)" "" 0 # --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter ----- -# ps::blank_quoted_spans used to pair quotes with two independent `sed` +# ps::blank_quoted_spans_to used to pair quotes with two independent `sed` # expressions, neither aware of which style opened first. The single-quote # expression matched from the apostrophe inside one double-quoted string to the # apostrophe inside the next and DELETED everything between them: diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 838fcd0cb..0840a0615 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -876,7 +876,7 @@ run_pwsh "PS: braced call target, single positional (allowed — #2848 verifier) # scanner's `[^}]*` stopped at the injected brace, the whitespace boundary failed, # and the call site vanished: both measuring probes returned false and the gate # fell through ALLOWED. The escape is now consumed BEFORE the deletion -# (ps::fold_escaped_brace_closers), and the target token may carry non-space text +# (ps::fold_escaped_brace_closers_to), and the target token may carry non-space text # glued after its closing brace. Blocked pre-0.28.33; these pin the recovery. # shellcheck disable=SC2016 run_pwsh "PS: escaped closer in a braced call target, positional Path+Value (blocked — #2908 review)" \ @@ -1083,7 +1083,7 @@ run_pwsh "PS: & { git diff } > file (tool producer, allowed)" \ "& { git diff } > out.txt" 0 # --- fd-dup merge must not hide a computed writer's operands (#2927) --------- -# The `&` inside `2>&1` sits at bracket depth ZERO, and ps::call_site_operand_region +# The `&` inside `2>&1` sits at bracket depth ZERO, and ps::call_site_operand_region_to # ends a call's operand region at a depth-zero `;` `|` `&`. So the region of # `& $w 2>&1 f.txt x` was truncated to `" 2>"`, both measuring probes went silent, # and a working `Set-Content ` — verified as a real write under @@ -2150,7 +2150,7 @@ bash "$HOOK" <<<"$(jq -n '{tool_name:"Bash",tool_input:{command:("git status" + assert_exit "NUL in command (blocked)" 2 "$nul_rc" # --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter ----- -# ps::blank_quoted_spans used to pair quotes with two independent `sed` +# ps::blank_quoted_spans_to used to pair quotes with two independent `sed` # expressions, neither aware of which style opened first. The single-quote # expression matched from the apostrophe inside one double-quoted string to the # apostrophe inside the next and DELETED everything between them, so a computed @@ -2179,7 +2179,7 @@ run_pwsh "PS: bare-computed writer with -Value, straddled (blocked — #2965)" \ # Both the backtick and the doubled-quote escape therefore delete NOTHING on # their line. This spelling # reaches write_bypass through `lcq_bt` — the backtick-intact copy built before -# backticks are stripped from `lcq` — so `ps::blank_quoted_spans` sees the +# backticks are stripped from `lcq` — so `ps::blank_quoted_spans_to` sees the # backtick and the backtick-ambiguity branch emits the line verbatim. The # doubled-quote arm is not what catches this pinned case. # shellcheck disable=SC2016 @@ -2203,7 +2203,7 @@ run_pwsh "PS: #2848 bare-computed call target flanked by an apostrophe (allowed "Write-Host \"Kyle's build\"; & \$py \$script (Join-Path \$dir \"\$id.jsonl\")" 0 # --- #2906: quoting an operand is not a free escape from the positional signal -- -# ps::blank_quoted_spans DELETES quoted spans, so the two-positional arm of +# ps::blank_quoted_spans_to DELETES quoted spans, so the two-positional arm of # ps::computed_call_has_positional_write_signal saw `& $w 'f.txt' 'x'` as a # zero-operand call. Quoting is the idiomatic Path+Value spelling, not an # obscure one. The contained fix keeps those operands present-but-opaque for diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index 534095adb..d56763e23 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -584,7 +584,7 @@ assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \ run "empty command, no NUL (allowed)" "" 0 # --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter ----- -# ps::blank_quoted_spans used to pair quotes with two independent `sed` +# ps::blank_quoted_spans_to used to pair quotes with two independent `sed` # expressions, neither aware of which style opened first. The single-quote # expression matched from the apostrophe inside one double-quoted string to the # apostrophe inside the next and DELETED everything between them — including the diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 92c847865..b551a1394 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -47,8 +47,10 @@ # (backtick splits `com`+`mit`, quote-stripping erases `'commit'`), so a # negative match is not evidence of safety (the #740/#903 fail-open class). # Instead ps::might_invoke_git asks the mangle-resistant question "could this -# reach git at all?" — backticks recovered, scan quote-INTACT, plus dynamic -# invocation (iex / call / dot-source) — and blocks unless the answer is no. +# reach git at all?" — backticks recovered, scan quote-INTACT (except a +# literal in comparison-operand position, which is data; see +# ps::might_invoke_git), plus dynamic invocation (iex / call / dot-source) — +# and blocks unless the answer is no. # Otherwise the reduced command is Bash-tokenizer-faithful and handed to the # existing parser. # @@ -83,6 +85,16 @@ PS_HERESTRING_UNBALANCED=0 # it knows which opener was left hanging — naming `'@` for a `@"` body sends the # operator to a terminator PowerShell will not accept. PS_HERESTRING_QUOTE="" +# 1 when a properly-delimited EXPANDABLE here-string (`@"` … `"@`) was blanked out +# of the command. A verbatim `@'` … `'@` body is inert text, but an expandable +# body is evaluated where it is written, so a `$( … )` inside it is a COMMAND +# POSITION that the placeholder hides. Read by ps::classify_git_command, which +# will not accept a git-freedom proof taken over text with that body removed. +PS_HERESTRING_EXPANDABLE=0 +# 1 when the last ps::_walk_quoted_spans_to pass crossed a DOUBLE-quote opener, +# i.e. the walked text carries an expandable string. Written by the walk and read +# by its IMMEDIATE caller; any later walk overwrites it. +PS_QUOTED_SPAN_SAW_EXPANDABLE=0 # Set by ps::classify_git_command when a command routes to the fail-closed sink: # which of the four triggers fired (`herestring-unbalanced`, `special-construct`, # `dynamic-invocation`, `launcher`), empty otherwise. Read by the block messages @@ -95,6 +107,103 @@ PS_SINK_TRIGGER="" # shellcheck disable=SC2034 PS_SAFE_COMMAND="" +# --- fork-free primitives ----------------------------------------------------- +# +# Six guards load this file on the hot path of every PowerShell tool call, and +# on Windows Git Bash a command substitution is one process creation while an +# external command in a pipeline is two more. So nothing here reaches a result +# through `$(…)` or `printf | sed`: a helper whose answer is a string takes the +# caller's variable NAME and assigns it with `printf -v` (the `_to` convention +# lib/hook-utils.sh uses), and the substitutions below are done in bash. +# +# TWO RULES MAKE A `_to` HELPER A DROP-IN FOR THE `$(…)` IT REPLACES. It ends by +# chomping the trailing newline run a capture would have eaten (ps::_chomp_to). +# And its own locals are name-prefixed wherever a caller might pass one of them +# as the out-parameter — `printf -v` would otherwise assign the callee's local +# and leave the caller's variable untouched, which is a silent wrong answer +# rather than an error. + +# ps::_chomp_to +# +# TEXT without the trailing newline run `$(…)` would have removed. +# +# A trailing CRLF goes WHOLE, not just its LF. Measured on the Windows Git Bash +# these guards run under: `$(printf 'a\r\n')` is `a`, `$(printf 'a\r\r\n')` is +# `a` plus one CR, and an INTERIOR CRLF survives (`$(printf 'a\r\nb')` keeps +# both bytes). A helper that dropped only the LF would hand its caller a +# trailing `\r` the captured form never had — and PS_SAFE_COMMAND goes on to a +# Bash tokenizer, where that CR joins the final token. +ps::_chomp_to() { + local __ch_s="$2" + while [[ "$__ch_s" == *$'\n' ]]; do + __ch_s="${__ch_s%$'\n'}" + __ch_s="${__ch_s%$'\r'}" + done + printf -v "$1" '%s' "$__ch_s" +} + +# ps::_split_lines_to +# +# The newline-separated parts of TEXT, in the named array — what +# `while IFS= read -r line; do … done < <(printf '%s\n' "$text")` yielded, minus +# the process substitution. `printf '%s\n'` appends one newline, so the reader +# saw exactly these parts, including the trailing EMPTY part of a TEXT that +# already ended in a newline. +ps::_split_lines_to() { + local -n __sl_out="$1" + local __sl_rest="$2" + __sl_out=() + while [[ "$__sl_rest" == *$'\n'* ]]; do + __sl_out+=("${__sl_rest%%$'\n'*}") + __sl_rest="${__sl_rest#*$'\n'}" + done + __sl_out+=("$__sl_rest") +} + +# ps::_gsub_to +# +# `sed -E "s///g"` over TEXT, in bash. +# +# PER LINE, because that is what sed does: it hands its regex one line at a time +# with the newline already stripped, so in sed `[[:space:]]` can never match a +# newline and `.` never spans one, while bash's engine is handed the whole string +# and both would. Within a line the two agree — both resolve an ambiguous match +# POSIX leftmost-longest — so the per-line match set is identical. +# +# A CRLF LINE ENDING LOSES ITS CR, because this host's sed reads in text mode: +# measured, `printf 'a\r\nb\r\n' | sed …` writes `a\nb\n`, while a CR that ends +# no line survives (`a\rb`, and a final unterminated `a\r`). PowerShell commands +# arrive with Windows line endings, and PS_SAFE_COMMAND goes on to a Bash +# tokenizer, so leaving those CRs in would attach one to a token the captured +# form never had. +# +# The match POSITION comes from `${line%%"$m"*}`, the first LITERAL occurrence of +# the matched text. That is this match's own position: were the same text to +# occur earlier, the regex would have matched THERE instead, since no pattern in +# this file is anchored. An empty match would not advance, so it ends the line +# rather than looping forever; no pattern here can produce one, each requiring at +# least one literal character. +ps::_gsub_to() { + local __gs_re="$3" __gs_repl="$4" __gs_out="" __gs_line __gs_acc __gs_pre __gs_m __gs_i + local -a __gs_lines=() + ps::_split_lines_to __gs_lines "$2" + for ((__gs_i = 0; __gs_i < ${#__gs_lines[@]}; __gs_i++)); do + __gs_line="${__gs_lines[__gs_i]}" + ((__gs_i + 1 < ${#__gs_lines[@]})) && __gs_line="${__gs_line%$'\r'}" + __gs_acc="" + while [[ "$__gs_line" =~ $__gs_re ]]; do + __gs_m="${BASH_REMATCH[0]}" + [[ -n "$__gs_m" ]] || break + __gs_pre="${__gs_line%%"$__gs_m"*}" + __gs_acc+="${__gs_pre}${__gs_repl}" + __gs_line="${__gs_line:${#__gs_pre}+${#__gs_m}}" + done + ((__gs_i)) && __gs_out+=$'\n' + __gs_out+="${__gs_acc}${__gs_line}" + done + ps::_chomp_to "$1" "$__gs_out" +} + # Unicode code points PowerShell's tokenizer treats as TOKEN-SEPARATING # whitespace but bash's `[[:space:]]` does not. Spelled as raw UTF-8 byte # sequences via `$'\xNN'`, which is byte-literal and therefore identical under a @@ -189,10 +298,13 @@ ps::blank_herestrings() { ps::normalize_token_separating_spaces "$1" local cmd="$PS_NORMALIZED" local line out="" pending="" in_hs=0 hs_quote="" first2 rest closer opener_scan + local -a hs_lines=() PS_HERESTRING_UNBALANCED=0 PS_HERESTRING_QUOTE="" + PS_HERESTRING_EXPANDABLE=0 - while IFS= read -r line || [[ -n "$line" ]]; do + ps::_split_lines_to hs_lines "$cmd" + for line in "${hs_lines[@]}"; do if ((in_hs)); then first2="${line:0:2}" closer="${hs_quote}@" # '@ or "@ @@ -214,17 +326,22 @@ ps::blank_herestrings() { # would swallow following code lines into a phantom here-string body). # Distinguish by stripping PAIRED quote spans first: a real opener's quote # is unpaired, so its `@'` survives, while `'@'` / `'foo@'` disappear. - # One `sed` per line, not two: the expressions apply in order, so the - # double-quote strip still sees the single-quote-stripped line. - opener_scan=$(printf '%s' "$line" | sed -E -e "s/'[^']*'//g" -e 's/"([^"\\]|\\.)*"//g') + # The two strips apply IN ORDER, so the double-quote strip still sees the + # single-quote-stripped line. + ps::_gsub_to opener_scan "$line" "'[^']*'" '' + ps::_gsub_to opener_scan "$opener_scan" '"([^"\\]|\\.)*"' '' if [[ "$opener_scan" == *"@'" || "$opener_scan" == *'@"' ]]; then hs_quote="${line: -1}" # ' or " + # `@"` opens an EXPANDABLE body, evaluated where it is written, so the + # placeholder about to replace it stands for text that can contain a + # command position. `@'` opens a verbatim body and stands for inert text. + [[ "$hs_quote" == '"' ]] && PS_HERESTRING_EXPANDABLE=1 pending="${line%??}${PS_HERESTRING_PLACEHOLDER}" in_hs=1 continue fi out+="${line}"$'\n' - done < <(printf '%s\n' "$cmd") # not <<<: a >=64KiB here-string deadlocks (see hardcoded-path-patterns.sh) + done if ((in_hs)); then # Opener with no column-zero closer: ambiguous extent. Blanking to end could @@ -233,16 +350,22 @@ ps::blank_herestrings() { PS_HERESTRING_UNBALANCED=1 PS_HERESTRING_QUOTE="$hs_quote" PS_BLANKED="$cmd" + # Nothing was blanked, so no expandable body is hidden: PS_BLANKED is the raw + # command and every body in it stays in view for the callers' own probes. + PS_HERESTRING_EXPANDABLE=0 return 0 fi PS_BLANKED="${out%$'\n'}" } -# The single left-to-right quoted-span walk behind ps::blank_quoted_spans and -# ps::opaque_quoted_spans. MODE is `blank` (a found span is deleted) or `opaque` -# (a found span becomes a classified placeholder, per the classification the -# opaque wrapper documents). WHERE a span starts and ends, and every ambiguity -# resolution, is identical for both and therefore stated once here: two +# The single left-to-right quoted-span walk behind ps::blank_quoted_spans_to, +# ps::opaque_quoted_spans_to and ps::_blank_comparison_operand_literals_to. MODE is +# `blank` (a found span is deleted), `opaque` (a found span becomes a classified +# placeholder, per the classification the opaque wrapper documents) or +# `cmpoperand` (a found span in comparison-operand position becomes the inert +# bareword `_q_` and every other span is emitted verbatim). WHERE a span starts +# and ends, and every ambiguity resolution, is identical for all three and +# therefore stated once here: two # hand-maintained copies of this pairing walk are exactly the drift this file's # other SSOT notes warn about, and a copy that silently stopped pairing the same # way would fail OPEN in one lane while the other stayed closed. @@ -291,7 +414,7 @@ ps::blank_herestrings() { # and ambiguity here means DELETE NOTHING ON THIS LINE. Neither available answer # is safe on its own, which is why the resolution is to refuse the question: # -# - HONORING the escape (what `ps::_skip_double_quote` does, correctly, in the +# - HONORING the escape (what `ps::_skip_double_quote_to` does, correctly, in the # sink BLANKING path that runs after the entry decision) extends the span # past `` `" `` to the next real quote, so # `Write-Host "a`"; & ('g'+'it') push --force; Write-Host "b"` becomes one @@ -307,72 +430,276 @@ ps::blank_herestrings() { # deleted, which is the deeper reason this branch exists. SINGLE-quoted spans are # exempt: PowerShell gives them no escape at all, so a backtick inside one is an # ordinary character and the pairing is genuinely unambiguous. -ps::_walk_quoted_spans() { - local text="$1" mode="$2" out="" i=0 n j q found c inner - n=${#text} - while ((i < n)); do - q="${text:i:1}" - if [[ "$q" == "'" || "$q" == '"' ]]; then - found=0 - for ((j = i + 1; j < n; j++)); do - c="${text:j:1}" - [[ "$c" == $'\n' ]] && break +ps::_walk_quoted_spans_to() { + local __wq_text="$2" __wq_mode="$3" __wq_out="" __wq_i=0 __wq_n __wq_j __wq_q __wq_found __wq_c __wq_inner + __wq_n=${#__wq_text} + # EXPANDABLE-STRING WITNESS. The walk is the only place that knows which quote + # character OPENED a span, and that is exactly what tells an expandable string + # from a verbatim one: a `"` inside a single-quoted span is never examined as an + # opener, and an apostrophe inside a double-quoted span is never examined as + # one either, so `'he said "hi"'` witnesses nothing and `"it's"` witnesses an + # expandable string. Neither a raw `"` scan nor the opaque placeholder kind can + # say that: `_q_` is shared by `'git'` and by `"git"`. + PS_QUOTED_SPAN_SAW_EXPANDABLE=0 + while ((__wq_i < __wq_n)); do + __wq_q="${__wq_text:__wq_i:1}" + if [[ "$__wq_q" == "'" || "$__wq_q" == '"' ]]; then + [[ "$__wq_q" == '"' ]] && PS_QUOTED_SPAN_SAW_EXPANDABLE=1 + __wq_found=0 + for ((__wq_j = __wq_i + 1; __wq_j < __wq_n; __wq_j++)); do + __wq_c="${__wq_text:__wq_j:1}" + [[ "$__wq_c" == $'\n' ]] && break # Ambiguous escape context in a double-quoted span — stop looking and # fall through to the delete-nothing branch below. - if [[ "$q" == '"' && "$c" == '`' ]]; then - for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + if [[ "$__wq_q" == '"' && "$__wq_c" == '`' ]]; then + for (( ; __wq_j < __wq_n; __wq_j++)); do [[ "${__wq_text:__wq_j:1}" == $'\n' ]] && break; done break fi - if [[ "$c" == "$q" ]]; then + if [[ "$__wq_c" == "$__wq_q" ]]; then # A DOUBLED quote is PowerShell's other escape for a delimiter # (`'it''s'`, `"say ""hi"""`), so this candidate closer may not be one. # Same resolution as the backtick: refuse the question, delete nothing. - if [[ "${text:j+1:1}" == "$q" ]]; then - for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + if [[ "${__wq_text:__wq_j+1:1}" == "$__wq_q" ]]; then + for (( ; __wq_j < __wq_n; __wq_j++)); do [[ "${__wq_text:__wq_j:1}" == $'\n' ]] && break; done break fi - found=1 + __wq_found=1 break fi done - if ((found)); then - if [[ "$mode" == "opaque" ]]; then - inner="${text:i+1:j-i-1}" - if [[ -n "$inner" ]]; then - if [[ "$inner" == -* && "$q" == '"' && "$inner" == *'$'* ]]; then - out+='-_q_' - elif [[ "$q" == '"' && "$inner" == *'$'* ]]; then - out+="\$q" + if ((__wq_found)); then + if [[ "$__wq_mode" == "opaque" ]]; then + __wq_inner="${__wq_text:__wq_i+1:__wq_j-__wq_i-1}" + if [[ -n "$__wq_inner" ]]; then + if [[ "$__wq_inner" == -* && "$__wq_q" == '"' && "$__wq_inner" == *'$'* ]]; then + __wq_out+='-_q_' + elif [[ "$__wq_q" == '"' && "$__wq_inner" == *'$'* ]]; then + __wq_out+="\$q" else - out+='_q_' + __wq_out+='_q_' fi fi + elif [[ "$__wq_mode" == "cmpoperand" ]]; then + # `__wq_out` is both the result and the CONTEXT: every span already walked + # is present in it (as `_q_` when blanked, verbatim when kept), so the + # operand test reads the reduced prefix rather than the raw text — which + # is what lets an earlier list element be skipped as one token. + if ps::_is_comparison_operand_context "$__wq_out"; then + __wq_out+='_q_' + else + __wq_out+="${__wq_text:__wq_i:__wq_j-__wq_i+1}" + fi fi - i=$((j + 1)) + __wq_i=$((__wq_j + 1)) continue fi # Unterminated (or escape-ambiguous) on this line: extent is ambiguous, so - # delete nothing. `j` already sits on the newline (or at the end), so copy + # delete nothing. `__wq_j` already sits on the newline (or at the end), so copy # the rest verbatim in one slice — this also keeps the walk linear. - out+="${text:i:j-i}" - i=$j + __wq_out+="${__wq_text:__wq_i:__wq_j-__wq_i}" + __wq_i=$__wq_j continue fi - out+="$q" - i=$((i + 1)) + __wq_out+="$__wq_q" + __wq_i=$((__wq_i + 1)) done - printf '%s' "$out" + ps::_chomp_to "$1" "$__wq_out" } # Crude, SCAN-ONLY strip of single- and double-quoted spans, so that structural # detection and commit/push shaping ignore characters inside message text. Never # fed to a parser. -ps::blank_quoted_spans() { - ps::_walk_quoted_spans "$1" blank +ps::blank_quoted_spans_to() { + ps::_walk_quoted_spans_to "$1" "$2" blank +} + +# True (0) when PREFIX — the already-walked text standing in front of a quoted +# string literal — puts that literal in COMPARISON-OPERAND position: the nearest +# preceding non-whitespace token is a PowerShell comparison operator, reached +# through an optional opening `(` / `@(` and through `,`-separated earlier +# elements of that same list. The `c`/`i` case prefixes count (`-ceq`, `-ilike`). +# +# RIGHT-HAND OPERANDS ONLY. A literal on the LEFT of the operator is not decidable +# here: `& 'git' -eq $x` is a CALL of git with `-eq` as its first argument, not a +# comparison, and nothing in the prefix distinguishes the two. So `'git' -in $names` +# and `@('git') -contains $_.Name` keep the quote-intact probe. +# +# The operator's left boundary excludes alphanumerics, `_` and `-`, so a cmdlet or +# parameter name that merely ENDS in an operator spelling is not one. +# +# The hop count is BOUNDED. A list long enough to exhaust it falls through to +# "not an operand", which keeps the quote-intact probe — the over-block +# direction, matching the file's invariant. +ps::_is_comparison_operand_context() { + local p="$1" hops + for ((hops = 0; hops < 16; hops++)); do + if [[ "$p" =~ ^(.*[^[:space:]])[[:space:]]*$ ]]; then p="${BASH_REMATCH[1]}"; else p=""; fi + [[ "$p" =~ (^|[^[:alnum:]_-])-[ci]?(eq|ne|in|notin|contains|notcontains|like|notlike|match|notmatch|lt|le|gt|ge)$ ]] && return 0 + case "$p" in + *'(') + p="${p%?}" + if [[ "$p" =~ ^(.*[^[:space:]])[[:space:]]*$ ]]; then p="${BASH_REMATCH[1]}"; else p=""; fi + [[ "$p" == *'@' ]] && p="${p%?}" + ;; + *',') + p="${p%?}" + # Drop the earlier list element whole — back to the nearest list/group + # delimiter or whitespace. An element already walked as a quoted span is + # the bareword `_q_` here, so it needs no special case. + if [[ "$p" =~ ^(.*[,()[:space:]])[^,()[:space:]]*$ ]]; then p="${BASH_REMATCH[1]}"; else p=""; fi + ;; + *) return 1 ;; + esac + done + return 1 } -# Sibling of ps::blank_quoted_spans for ONE consumer: -# ps::computed_call_has_positional_write_signal. Same left-to-right pairing — +# Replace every quoted string literal that ps::_is_comparison_operand_context +# accepts with the inert bareword `_q_`, leaving every other span verbatim. +# The one consumer is ps::might_invoke_git's data-literal exemption; the rule and +# why it cannot reach execution are documented there. +ps::_blank_comparison_operand_literals_to() { + ps::_walk_quoted_spans_to "$1" "$2" cmpoperand +} + +# True (0) when TOK is a read-only cmdlet, alias or keyword — the allowlist +# ps::_is_readonly_cmdlet_pipeline admits at a command position. Every entry +# INTERROGATES: it reports, filters, formats, converts or compares, and none of +# them runs a program named by its input. Any Get-* verb is admitted as a class +# (`Get-Process`, `Get-CimInstance`, `Get-Content`, `Get-Command`), because Get +# is defined as retrieval and a PowerShell command name is verb-qualified. +ps::_is_readonly_cmdlet() { + case "$1" in + gps | ps | gcim | gwmi | gci | ls | dir | gi | gc | cat | type | gsv | gcm | gmo | gv | gl | pwd) return 0 ;; + where-object | where | '?') return 0 ;; + select-object | select) return 0 ;; + foreach-object | foreach | '%') return 0 ;; + sort-object | sort | measure-object | measure | group-object | group) return 0 ;; # portability-ok: cmdlet names in a case pattern, not a sort -V invocation + format-table | ft | format-list | fl | format-wide | fw) return 0 ;; + out-string | out-host | oh | out-null) return 0 ;; + write-output | write | echo | write-host) return 0 ;; + select-string | sls) return 0 ;; + test-path | resolve-path | rvpa | split-path | join-path) return 0 ;; + compare-object | compare | diff) return 0 ;; + convertto-json | convertfrom-json | convertto-csv) return 0 ;; + if | else | elseif | in | return) return 0 ;; + *) ;; + esac + [[ "$1" =~ ^get-[a-z0-9]+$ ]] +} + +# True (0) when the whole command is provably a READ-ONLY CMDLET PIPELINE: every +# token standing at a command position is an allowlisted interrogator, and the +# command carries none of the constructs that turn a value into a command. This +# is ps::might_invoke_git's gate on the comparison-operand exemption. +# +# DENY BY DEFAULT, AND THAT IS THE POINT. Asking instead whether a known executor +# is PRESENT is structurally under-inclusive: PowerShell reaches a program +# through `[Diagnostics.Process]::Start(…)`, +# `$ExecutionContext.InvokeCommand.InvokeScript(…)`, +# `[scriptblock]::Create(…).Invoke()`, an alias minted at run time +# (`Set-Alias zz $_.Name; zz push --force`), WMI/CIM `Win32_Process Create`, a +# scheduled-task action, a service binary path, and every launcher that happens +# to be on PATH (`npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic`, +# `schtasks`) — a set no enumeration converges on. Inverted, an unrecognized +# command word is REFUSED rather than admitted, so a new executor costs an +# over-block instead of a bypass. +# +# AN EXPANDABLE STRING IS A COMMAND POSITION, so its presence anywhere in the +# command refuses. A double-quoted `"…"` span (and the `@"…"@` here-string form) +# is EVALUATED where it is written: `"$( … )"` runs the subexpression to build +# the string, so `-eq "$(cmd /c git push --force)"` executes a program before any +# comparison happens. The walk blanks that span to an inert placeholder, which is +# exactly what makes the executor invisible to a token scan of the blanked text. +# A security review reproduced the whole executor family through it (`cmd /c`, +# `bash -c`, `powershell -c`, `Start-Process`, `& 'git'`, `Start-Job`, +# `Invoke-Item`, `New-Object`, `node -e`, a bare `git`, `schtasks`), under `-eq`, +# `-like`, `-in` and a second `Where-Object`, and inside an interpolated +# `"${env:ComSpec} $( … )"`. A VERBATIM `'…'` string has no such evaluation +# (about_Quoting_Rules), so the single-quoted operands the exemption exists for +# stay exempt. The witness is the walk's own opener, not the raw text and not the +# placeholder kind: `_q_` is shared by `'git'` and `"git"`, and a raw `"` scan +# would misread the `"` inside `'he said "hi"'`. +# +# THE SCAN, in order, over the command with every quoted string replaced by an +# opaque placeholder (so a `[` or an `&` inside message text is data, not a +# construct): +# 0. refuse when the walk that produced the placeholders crossed a DOUBLE-quote +# opener, the expandable-string disqualifier above; +# 1. refuse outright on a construct no token scan can settle — a surviving +# BACKTICK (escape), `<#` (block comment), `--%` (stop-parsing), `::` +# (static member, the `[Type]::Method` executor family), `[` (type +# literal), `&` (call operator), and a `.` immediately before `(` (a method +# call such as `.Invoke(` / `.InvokeScript(`); +# 2. walk the remainder token by token, tracking COMMAND POSITION — the start +# of input, and anything after `|`, `;`, `{`, `}`, `(`, `=` or a newline; +# 3. at a command position the token must be allowlisted. A `$variable` or +# property chain, a `-parameter` or operator, an opaque string placeholder +# and a bare number are inert and skipped; anything else at a command +# position refuses — `zz`, `npx`, `iwmi`, `nsv`, `schtasks`, `wmic`, +# `set-alias`, `new-object`, `iex`, a `.` dot-source, a `.\path.exe`, and +# `git` itself. +# +# ARGUMENTS ARE NOT COMMAND WORDS, so a token away from a command position is +# skipped and `Get-CimInstance Win32_Process`, `Select-Object ProcessId` and +# `Select-Object Id` stay admissible. An argument cannot execute on its own: +# reaching a program through one takes a command word that accepts it, and that +# command word sits at a command position and must be allowlisted. +# +# FAIL CLOSED ON DOUBT. An over-long command is refused rather than scanned. The +# walk is per-character and the exemption is a convenience, so the ceiling costs +# an over-block on a command that keeps the quote-intact probe it had anyway. +ps::_is_readonly_cmdlet_pipeline() { + local lc i n ch tok="" cmdpos=1 + ps::opaque_quoted_spans_to lc "$1" + # Read IMMEDIATELY: any later walk overwrites the witness. + ((PS_QUOTED_SPAN_SAW_EXPANDABLE)) && return 1 + lc="${lc,,}" + case "$lc" in + *'`'* | *'<#'* | *'--%'* | *'::'* | *'['* | *'&'*) return 1 ;; + *) ;; + esac + [[ "$lc" =~ \.[a-z0-9_]*\( ]] && return 1 + ((${#lc} > 4096)) && return 1 + # A trailing newline is a command-position separator, so the last token + # flushes inside the loop instead of in a second copy of the token test. + lc+=$'\n' + n=${#lc} + for ((i = 0; i < n; i++)); do + ch="${lc:i:1}" + case "$ch" in + '|' | ';' | '{' | '}' | '(' | '=' | $'\n' | ' ' | $'\t' | $'\r' | ',' | '@' | ')' | '>' | '<' | '+' | '*') ;; + *) + tok+="$ch" + continue + ;; + esac + if [[ -n "$tok" ]]; then + case "$tok" in + '$'* | -* | '_q_') ;; + *) + if [[ "$tok" =~ ^[0-9]+$ ]]; then + : + elif ((cmdpos)); then + ps::_is_readonly_cmdlet "$tok" || return 1 + fi + ;; + esac + tok="" + cmdpos=0 + fi + case "$ch" in + '|' | ';' | '{' | '}' | '(' | '=' | $'\n') cmdpos=1 ;; + *) ;; + esac + done + return 0 +} + +# Sibling of ps::blank_quoted_spans_to for the two consumers that need a string +# to stay PRESENT while its content stays opaque: +# ps::computed_call_has_positional_write_signal and +# ps::_is_readonly_cmdlet_pipeline. Same left-to-right pairing — # first opener owns its span, ambiguity copies the rest of the line verbatim — # but a FOUND span is replaced by a classified placeholder instead of deleted. # @@ -381,8 +708,10 @@ ps::blank_quoted_spans() { # blanking both operands left the probe with nothing to count. The placeholder # keeps the operand PRESENT so it still counts, while its content stays OPAQUE # so a quoted `>` or `-value` in message text cannot become a different signal. -# That is why this string is handed ONLY to the positional probe: the redirect -# and `-va*` probes still need the deletion semantics. +# That is why this string never reaches the redirect and `-va*` probes, which +# still need the deletion semantics. The read-only-pipeline gate wants the same +# property for the same reason: a string has to stay a TOKEN so the walk can tell +# a command word from an argument, while its content stays out of the scan. # # Classification, interpolating-dash FIRST (about_Quoting_Rules + about_Parsing): # 1. DOUBLE-quoted, starts with `-`, AND contains `$` → `-_q_` @@ -400,11 +729,11 @@ ps::blank_quoted_spans() { # 3. otherwise → `_q_` # Present-but-opaque visible literal. # -# An EMPTY span (`""`, `''`) is still deleted, matching blank_quoted_spans. +# An EMPTY span (`""`, `''`) is still deleted, matching blank_quoted_spans_to. # `& $py $script ""` must stay allowed (#2965); counting the empty string as a # literal would turn that pin into an over-block. -ps::opaque_quoted_spans() { - ps::_walk_quoted_spans "$1" opaque +ps::opaque_quoted_spans_to() { + ps::_walk_quoted_spans_to "$1" "$2" opaque } # Fold a BACKTICK-ESCAPED closing brace to `_`, left to right, BEFORE any caller @@ -432,21 +761,21 @@ ps::opaque_quoted_spans() { # `${my`{writer}` deletes to `${my{writer}`, where `[^}]*` matches the name and # the real closer still terminates it — and removing a `{` other probes count # would be a change outside this finding. -ps::fold_escaped_brace_closers() { - local s="$1" out="" i n ch - n=${#s} - for ((i = 0; i < n; i++)); do - ch="${s:i:1}" - if [[ "$ch" == '`' ]] && ((i + 1 < n)); then - case "${s:i+1:1}" in +ps::fold_escaped_brace_closers_to() { + local __fb_s="$2" __fb_out="" __fb_i __fb_n __fb_ch + __fb_n=${#__fb_s} + for ((__fb_i = 0; __fb_i < __fb_n; __fb_i++)); do + __fb_ch="${__fb_s:__fb_i:1}" + if [[ "$__fb_ch" == '`' ]] && ((__fb_i + 1 < __fb_n)); then + case "${__fb_s:__fb_i+1:1}" in '}') - out+='_' - i=$((i + 1)) + __fb_out+='_' + __fb_i=$((__fb_i + 1)) continue ;; '`') - out+='``' - i=$((i + 1)) + __fb_out+='``' + __fb_i=$((__fb_i + 1)) continue ;; *) @@ -455,9 +784,9 @@ ps::fold_escaped_brace_closers() { ;; esac fi - out+="$ch" + __fb_out+="$__fb_ch" done - printf '%s' "$out" + ps::_chomp_to "$1" "$__fb_out" } # True (0) when the (quote-stripped) text carries a PowerShell construct the Bash @@ -651,28 +980,28 @@ ps::call_target_is_bare_subexpression() { # An unmatched OPENER (a genuinely unbalanced command) leaves the region running # to end of string; the callers stay conservative on what they can still see, and # such a command does not parse in PowerShell to begin with. -ps::call_site_operand_region() { - local s="$1" out="" i ch depth=0 - for ((i = 0; i < ${#s}; i++)); do - ch="${s:i:1}" - case "$ch" in +ps::call_site_operand_region_to() { + local __cs_s="$2" __cs_out="" __cs_i __cs_ch __cs_depth=0 + for ((__cs_i = 0; __cs_i < ${#__cs_s}; __cs_i++)); do + __cs_ch="${__cs_s:__cs_i:1}" + case "$__cs_ch" in '{' | '(') - depth=$((depth + 1)) + __cs_depth=$((__cs_depth + 1)) ;; '}' | ')') - ((depth == 0)) && break - depth=$((depth - 1)) + ((__cs_depth == 0)) && break + __cs_depth=$((__cs_depth - 1)) ;; ';' | '|' | '&') - ((depth == 0)) && break + ((__cs_depth == 0)) && break ;; *) # Ordinary operand text — copied through with no depth effect. ;; esac - out+="$ch" + __cs_out+="$__cs_ch" done - printf '%s' "$out" + ps::_chomp_to "$1" "$__cs_out" } # Blank the INTERIOR of every balanced bracket group in a call's operand region, @@ -681,28 +1010,28 @@ ps::call_site_operand_region() { # `& $ic -ScriptBlock { & $w @p }` belongs to the inner `& $w`, which the call-site # walk reaches on its own iteration, and the interior of `${script:Path}` holds no # operands at all. -ps::blank_bracket_interiors() { - local s="$1" out="" i ch depth=0 - for ((i = 0; i < ${#s}; i++)); do - ch="${s:i:1}" - case "$ch" in +ps::blank_bracket_interiors_to() { + local __bb_s="$2" __bb_out="" __bb_i __bb_ch __bb_depth=0 + for ((__bb_i = 0; __bb_i < ${#__bb_s}; __bb_i++)); do + __bb_ch="${__bb_s:__bb_i:1}" + case "$__bb_ch" in '{' | '(') - out+="$ch" - depth=$((depth + 1)) + __bb_out+="$__bb_ch" + __bb_depth=$((__bb_depth + 1)) continue ;; '}' | ')') - ((depth > 0)) && depth=$((depth - 1)) - out+="$ch" + ((__bb_depth > 0)) && __bb_depth=$((__bb_depth - 1)) + __bb_out+="$__bb_ch" continue ;; *) # Ordinary text — kept at depth 0, blanked inside a group. ;; esac - if ((depth > 0)); then out+=" "; else out+="$ch"; fi + if ((__bb_depth > 0)); then __bb_out+=" "; else __bb_out+="$__bb_ch"; fi done - printf '%s' "$out" + ps::_chomp_to "$1" "$__bb_out" } ps::computed_call_has_positional_write_signal() { @@ -744,10 +1073,11 @@ ps::computed_call_has_positional_write_signal() { # count, and the `}` ending an ENCLOSING script block is not an operand — # while a `}` that closes one of this call's own operands must not truncate # past the operands after it (review of #2848). - rest=$(ps::call_site_operand_region "$rest") + ps::call_site_operand_region_to rest "$rest" # Drop redirect operands (`> f`, `2> err`) — those are covered by the # redirect probe; they are not Path+Value positionals. - rest=$(printf '%s' "$rest" | sed -E 's/[0-9*]*>+[^[:space:]]*//g; s/[0-9*]*<+[^[:space:]]*//g') + ps::_gsub_to rest "$rest" '[0-9*]*>+[^[:space:]]*' '' + ps::_gsub_to rest "$rest" '[0-9*]*<+[^[:space:]]*' '' # shellcheck disable=SC2086 # intentional word-split on PowerShell tokens for tok in $rest; do [[ -z "$tok" ]] && continue @@ -840,8 +1170,8 @@ ps::computed_call_has_splat_operand() { # blanked: a `}` closing one of THIS call's operands must not truncate away a # real splat after it (`& $w ${script:Path} @Body`), while a splat nested # inside a script block belongs to the inner call the walk reaches next. - rest=$(ps::call_site_operand_region "$rest") - rest=$(ps::blank_bracket_interiors "$rest") + ps::call_site_operand_region_to rest "$rest" + ps::blank_bracket_interiors_to rest "$rest" if [[ "$rest" =~ (^|[[:space:]])@[a-z0-9_:]+ ]]; then return 0 fi @@ -890,13 +1220,79 @@ ps::call_target_is_interpolating_string() { # trailing boundary excludes a further `/` or `\` so `git` must be the final path # component, not a directory name. `.git` stays inert because `.` is not a # command-position predecessor. +# +# ONE EXEMPTION, AND IT IS NARROW: a quoted string literal in COMPARISON-OPERAND +# position is DATA, and is blanked to `_q_` before the probe re-runs — but only in +# a command that carries no way to execute a computed value at all. Both halves +# are required, and the second is what makes the first safe. +# (a) the literal's nearest preceding non-whitespace token is a PowerShell +# comparison operator, reached through an optional `(` / `@(` and through +# `,`-separated earlier elements of the same list +# (ps::_is_comparison_operand_context; right-hand operands only); +# (b) the whole command is provably a READ-ONLY CMDLET PIPELINE — every token at +# a command position is an allowlisted interrogator, the command carries no +# EXPANDABLE `"…"` span (whose `$( … )` is itself a command position), and +# no construct turns a value into a command +# (ps::_is_readonly_cmdlet_pipeline), which is an allowlist, not a list of +# known executors. +# The other expandable form, a `@"…"@` here-string, never reaches (b): it is +# blanked out of the command at intake, so ps::classify_git_command refuses it at +# the sink instead, where the blanking happened. +# Under (b) the only thing the matched text can DO with the string is compare it: +# `Get-Process | Where-Object { $_.Name -eq 'git' }` filters objects, and nothing +# in what remains turns the filtered value back into a command word. The +# false-positive class this retires is read-only PowerShell that merely NAMES git +# in a VERBATIM literal (`-eq 'git'`, `-ceq 'git'`, `-in @('git.exe','bash.exe')`, +# `-notin @('git','node')`, `-like 'git*'`), which the script block alone had +# already routed to the sink. +# +# WHAT KEEPS THE QUOTE-INTACT PROBE, and why each one must: +# `& 'git' commit`, `git 'commit'` the literal is a call target or an +# argument, never a comparison operand; +# `'git' -in $names` a LEFT-hand operand, which `& 'git' -eq $x` +# makes undecidable, so it is not exempt; +# `Start-Process 'git' …`, `saps 'git'` likewise, and (b) refuses the command word; +# `cmd /c 'git push --force'` (b) refuses the nested shell; +# and every shape that turns the compared value back into a command word, each +# refused by (b) because its command word is not an interrogator or its +# construct is not a pipeline at all: +# `| % { & $_.Name … }`, `| % { . $_.Name … }`, `| % { iex $_.Name }`, +# `| % { cmd /c $_.Name }`, `| % { bash -c $_.Name }`, `| % { npx $_.Name }`, +# `| % { dotnet/cscript/explorer/ssh … $_.Name }`, +# `| % { Invoke-Item $_.Name }` and its `ii` alias, `Start-Job`, `New-Object`, +# `New-Service`/`nsv`, `Register-ScheduledTask`/`New-ScheduledTaskAction`, +# `iwmi`/`Invoke-CimMethod` Win32_Process Create, `schtasks /tr`, `wmic +# process call create`, `Set-Alias zz $_.Name; zz push --force`, +# `[Diagnostics.Process]::Start($_.Name, …)`, +# `[scriptblock]::Create($_.Name).Invoke()`, +# `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`; +# `$n = 'git'; & $n push -f` `=` is not a comparison operator, and (b) +# refuses the call operator; +# `-eq "$(cmd /c git push --force)"` the operand is EXPANDABLE, so building +# it runs a program, and (b) refuses the +# whole command on the `"` alone. The +# executor inside is never the thing being +# recognized. ps::might_invoke_git() { - local recovered="${1//\`/}" lc + local recovered="${1//\`/}" lc narrowed lc="${recovered,,}" # Predecessor class includes `:` so a drive-relative `& 'C:git.exe'` still # counts (Codex #2592 review) and `=` so `$x=git …` (no space) still counts # (Claude #2592 review), while `.git` stays inert (`.` is not listed). - [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]] && return 0 + if [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]]; then + # A git token is visible. It is exempt only as comparison DATA inside a + # provably read-only cmdlet pipeline — the walks run only here, so a command + # with no git token pays nothing for them. The gate is handed the RAW text, + # not the backtick-recovered copy, because a surviving backtick is itself one + # of the constructs it refuses. + ps::_is_readonly_cmdlet_pipeline "$1" || return 0 + ps::_blank_comparison_operand_literals_to narrowed "$lc" + # The re-probe is the SAME pattern spelled again, not shared through a + # variable — the fail-OPEN reason the sibling predicates record: a pattern + # assembled from a variable that silently stopped matching would wave a git + # command word through. + [[ "$narrowed" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]] && return 0 + fi [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression)([^[:alnum:]_-]|$) ]] && return 0 # Call / dot-source of a COMPUTED target — `& (…)`, `& "$x" …` — which could # resolve to git. A CONSTANT target (`& 'git' …`, `& "C:\Git\cmd\git.exe" …`) @@ -1100,7 +1496,7 @@ ps::might_write_via_python3() { # The quote-BLANKED command: an `open(` or a quoted mention inside the write # payload is removed, so an UNQUOTED `(` (subexpression) or `$` (variable) that # survives here is a genuine computed construct, not payload text. - blanked=$(ps::blank_quoted_spans "$1") + ps::blank_quoted_spans_to blanked "$1" # A launcher (Start-Process/saps/start/pwsh/powershell/cmd) whose PROGRAM name is # COMPUTED cannot be proven non-python. Rather than model parameter ordering / # binding with a regex (which successive rounds defeated — one preceding option, @@ -1117,7 +1513,7 @@ ps::might_write_via_python3() { fi # A call `&` / dot-source `.` of a DOUBLE-QUOTED target that INTERPOLATES a # variable or subexpression (`& "$env:PYTHON_BIN" …`, `& "$(…)" …`) runs a - # COMPUTED program that could resolve to python3. blank_quoted_spans erases the + # COMPUTED program that could resolve to python3. blank_quoted_spans_to erases the # target (so the launcher/token tests miss it) and it is not a launcher, so match # it here on the quote-INTACT text and fail closed. A SINGLE-quoted target does # NOT interpolate in PowerShell (`& '$x'` is the literal name `$x`), so it is not @@ -1181,7 +1577,7 @@ ps::has_dynamic_invocation() { # following quote visible (`& "…"` / `. '…'`). Quote-blanked confirms the # `$name=` itself is not inside a string. [[ "$recovered" =~ (^|[[:space:]\;\{\}\(\|\&])\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*=[[:space:]]*[.\&][[:space:]]*[$q] ]] || return 1 - blanked=$(ps::blank_quoted_spans "$recovered") + ps::blank_quoted_spans_to blanked "$recovered" [[ "$blanked" =~ \$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*=[[:space:]]*[.\&] ]] } @@ -1208,7 +1604,7 @@ ps::has_launcher() { # and a quoted `$out=pwsh` stay data (about_Quoting_Rules). `git -c # section.key=cmd` is git(1) `-c =`, not an assignment, and # does not match. Spelled out literally, never shared through a variable. - blanked=$(ps::blank_quoted_spans "$lc") + ps::blank_quoted_spans_to blanked "$lc" [[ "$blanked" =~ (^|[[:space:]\;\|\&\(])\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*=[[:space:]]*(start-process|saps|start|pwsh|powershell|cmd)(\.exe)?([[:space:]]|$) ]] } @@ -1232,7 +1628,7 @@ ps::classify_git_command() { [[ "$tool" == "PowerShell" ]] || return 0 ps::blank_herestrings "$cmd" - scan=$(ps::blank_quoted_spans "$PS_BLANKED") + ps::blank_quoted_spans_to scan "$PS_BLANKED" # Record WHICH trigger routed the command here. Four distinct shapes reach this # sink and they need different remediation: an operator told to "remove the # unparsable construct" when the trigger was a launcher or a computed call @@ -1253,6 +1649,16 @@ ps::classify_git_command() { # probe) so a quoted or backtick-obfuscated `git` is still seen; an unbalanced # here-string leaves PS_BLANKED as the raw command so a trailing pipeline is # scanned, not swallowed. + # + # A BLANKED EXPANDABLE HERE-STRING SUSPENDS THE PROOF. `@"` … `"@` is + # evaluated where it is written, so `$( … )` in its body is a command + # position: `-eq @"` / `$(cmd /c git push --force)` / `"@` runs git at + # construction time. The body is gone from PS_BLANKED, so a NO from + # ps::might_invoke_git is a statement about text the command does not have, + # not a proof of git-freedom. Refuse by shape instead, exactly as for every + # other construct this sink cannot settle. A verbatim `@'` … `'@` body is + # inert text and is unaffected. + ((PS_HERESTRING_EXPANDABLE)) && return 2 ps::might_invoke_git "$PS_BLANKED" || return 1 if [[ "$sink_scope" == "readonly-ok" ]] && ps::git_command_is_readonly "$PS_BLANKED"; then return 1 @@ -1270,12 +1676,12 @@ ps::classify_git_command() { # regardless of which OS the HOOK runs on, and hook::git_is_bin strips # `.exe` only on its msys/cygwin branch. local reduced="${PS_BLANKED//\\//}" - reduced=$(printf '%s' "$reduced" | sed -E 's/[Gg][Ii][Tt]\.[Ee][Xx][Ee]/git/g') + ps::_gsub_to reduced "$reduced" '[Gg][Ii][Tt]\.[Ee][Xx][Ee]' git # PowerShell `$var=cmd` / `$var+=cmd` begins a new pipeline on the RHS without # requiring whitespace. Bash expands `$var` and leaves `=cmd` as a non-git # word, so strip the assignment prefix so the RHS command word is visible # (Claude review on #2592: `$x=git reset --hard`). - reduced=$(printf '%s' "$reduced" | sed -E 's/\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*(\+=|-=|\*=|\/=|%=|=)[[:space:]]*/ /g') + ps::_gsub_to reduced "$reduced" '\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*(\+=|-=|\*=|/=|%=|=)[[:space:]]*' ' ' # Read by the sourcing guard, not within this library. # shellcheck disable=SC2034 PS_SAFE_COMMAND="$reduced" @@ -1318,81 +1724,81 @@ ps::_at_command_position() { [[ "$prev" == [[:space:]\;\|\&\{\}\(\)] ]] } -# Consume a double-quoted span starting at IDX (points at "); returns end index -# (one past the closer, or past end of string if unbalanced). -ps::_skip_double_quote() { - local cmd="$1" i="$2" n=${#1} c - i=$((i + 1)) - while ((i < n)); do - c="${cmd:i:1}" - if [[ "$c" == '`' ]]; then - i=$((i + 2)) +# Consume a double-quoted span starting at IDX (points at "); assigns the end +# index (one past the closer, or past end of string if unbalanced) to VARNAME. +ps::_skip_double_quote_to() { + local __dq_cmd="$2" __dq_i="$3" __dq_n=${#2} __dq_c + __dq_i=$((__dq_i + 1)) + while ((__dq_i < __dq_n)); do + __dq_c="${__dq_cmd:__dq_i:1}" + if [[ "$__dq_c" == '`' ]]; then + __dq_i=$((__dq_i + 2)) continue fi - if [[ "$c" == '"' ]]; then - echo $((i + 1)) + if [[ "$__dq_c" == '"' ]]; then + printf -v "$1" '%s' "$((__dq_i + 1))" return 0 fi - i=$((i + 1)) + __dq_i=$((__dq_i + 1)) done - echo "$n" + printf -v "$1" '%s' "$__dq_n" } # Consume a single-quoted span starting at IDX (points at '). -ps::_skip_single_quote() { - local cmd="$1" i="$2" n=${#1} c - i=$((i + 1)) - while ((i < n)); do - c="${cmd:i:1}" - if [[ "$c" == "'" ]]; then - echo $((i + 1)) +ps::_skip_single_quote_to() { + local __sq_cmd="$2" __sq_i="$3" __sq_n=${#2} __sq_c + __sq_i=$((__sq_i + 1)) + while ((__sq_i < __sq_n)); do + __sq_c="${__sq_cmd:__sq_i:1}" + if [[ "$__sq_c" == "'" ]]; then + printf -v "$1" '%s' "$((__sq_i + 1))" return 0 fi - i=$((i + 1)) + __sq_i=$((__sq_i + 1)) done - echo "$n" + printf -v "$1" '%s' "$__sq_n" } # Advance IDX to the end of the current statement/pipeline element at depth 0 # (stop before top-level `;`, newline, `|`, `&&`, `||`). Quote- and depth-aware. -ps::_skip_statement_tail() { - local cmd="$1" i="$2" n=${#1} depth=0 c - while ((i < n)); do - c="${cmd:i:1}" - if ((depth == 0)); then - if [[ "$c" == "'" ]]; then - i=$(ps::_skip_single_quote "$cmd" "$i") +ps::_skip_statement_tail_to() { + local __st_cmd="$2" __st_i="$3" __st_n=${#2} __st_depth=0 __st_c + while ((__st_i < __st_n)); do + __st_c="${__st_cmd:__st_i:1}" + if ((__st_depth == 0)); then + if [[ "$__st_c" == "'" ]]; then + ps::_skip_single_quote_to __st_i "$__st_cmd" "$__st_i" continue fi - if [[ "$c" == '"' ]]; then - i=$(ps::_skip_double_quote "$cmd" "$i") + if [[ "$__st_c" == '"' ]]; then + ps::_skip_double_quote_to __st_i "$__st_cmd" "$__st_i" continue fi - if [[ "$c" == ';' || "$c" == $'\n' ]]; then - echo "$i" + if [[ "$__st_c" == ';' || "$__st_c" == $'\n' ]]; then + printf -v "$1" '%s' "$__st_i" return 0 fi - if [[ "$c" == '|' ]]; then + if [[ "$__st_c" == '|' ]]; then # `|` and `||` both end this pipeline element / statement. - echo "$i" + printf -v "$1" '%s' "$__st_i" return 0 fi - if [[ "$c" == '&' && "${cmd:i+1:1}" == '&' ]]; then - echo "$i" + if [[ "$__st_c" == '&' && "${__st_cmd:__st_i+1:1}" == '&' ]]; then + printf -v "$1" '%s' "$__st_i" return 0 fi # Bare `&` is the call operator (or background) — part of this statement. fi - case "$c" in - '{') depth=$((depth + 1)) ;; - '}') ((depth > 0)) && depth=$((depth - 1)) ;; - '(') depth=$((depth + 1)) ;; - ')') ((depth > 0)) && depth=$((depth - 1)) ;; + case "$__st_c" in + '{') __st_depth=$((__st_depth + 1)) ;; + '}') ((__st_depth > 0)) && __st_depth=$((__st_depth - 1)) ;; + '(') __st_depth=$((__st_depth + 1)) ;; + ')') ((__st_depth > 0)) && __st_depth=$((__st_depth - 1)) ;; *) ;; esac - i=$((i + 1)) + __st_i=$((__st_i + 1)) done - echo "$n" + printf -v "$1" '%s' "$__st_n" } # Blank dynamic-invocation or launcher statements in CMD. KIND is `dynamic` or @@ -1402,13 +1808,13 @@ ps::_blank_cmd_statements() { while ((i < n)); do c="${cmd:i:1}" if [[ "$c" == "'" ]]; then - end=$(ps::_skip_single_quote "$cmd" "$i") + ps::_skip_single_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue fi if [[ "$c" == '"' ]]; then - end=$(ps::_skip_double_quote "$cmd" "$i") + ps::_skip_double_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue @@ -1422,7 +1828,7 @@ ps::_blank_cmd_statements() { if [[ "$lc" =~ ^(iex|invoke-expression)([^a-z0-9_-]|$) ]]; then if [[ "$lc" == iex* ]]; then word=3; else word=18; fi # iex / Invoke-Expression — blank through end of this pipeline element. - end=$(ps::_skip_statement_tail "$cmd" $((i + word))) + ps::_skip_statement_tail_to end "$cmd" $((i + word)) i=$end out+=" " matched=1 @@ -1431,7 +1837,7 @@ ps::_blank_cmd_statements() { j=$((i + 1)) while ((j < n)) && [[ "${cmd:j:1}" == [[:space:]] ]]; do j=$((j + 1)); done if ((j < n)) && [[ "${cmd:j:1}" == "'" || "${cmd:j:1}" == '"' ]]; then - end=$(ps::_skip_statement_tail "$cmd" "$i") + ps::_skip_statement_tail_to end "$cmd" "$i" i=$end out+=" " matched=1 @@ -1440,7 +1846,7 @@ ps::_blank_cmd_statements() { ;; launcher) if [[ "$lc" =~ ^(start-process|saps|start|pwsh|powershell|cmd)(\.exe)?([^a-z0-9_-]|$) ]]; then - end=$(ps::_skip_statement_tail "$cmd" "$i") + ps::_skip_statement_tail_to end "$cmd" "$i" i=$end out+=" " matched=1 @@ -1466,13 +1872,13 @@ ps::_blank_special_construct_regions() { while ((i < n)); do c="${cmd:i:1}" if [[ "$c" == "'" ]]; then - end=$(ps::_skip_single_quote "$cmd" "$i") + ps::_skip_single_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue fi if [[ "$c" == '"' ]]; then - end=$(ps::_skip_double_quote "$cmd" "$i") + ps::_skip_double_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue @@ -1484,7 +1890,7 @@ ps::_blank_special_construct_regions() { continue fi if [[ "${cmd:i:3}" == '--%' ]]; then - end=$(ps::_skip_statement_tail "$cmd" "$i") + ps::_skip_statement_tail_to end "$cmd" "$i" i=$end out+=" " continue @@ -1497,11 +1903,11 @@ ps::_blank_special_construct_regions() { while ((i < n && depth > 0)); do c="${cmd:i:1}" if [[ "$c" == "'" ]]; then - i=$(ps::_skip_single_quote "$cmd" "$i") + ps::_skip_single_quote_to i "$cmd" "$i" continue fi if [[ "$c" == '"' ]]; then - i=$(ps::_skip_double_quote "$cmd" "$i") + ps::_skip_double_quote_to i "$cmd" "$i" continue fi if [[ "$c" == '`' ]]; then @@ -1529,9 +1935,11 @@ ps::_blank_special_construct_regions() { # PS_SAFE_COMMAND (prefix before the hanging opener, if any). ps::_blank_unbalanced_herestring_tail() { local cmd="$1" line out="" pending="" in_hs=0 hs_quote="" first2 closer opener_scan + local -a hs_lines=() # Mirror ps::blank_herestrings' opener detection; once an opener has no closer, # drop it and everything after (extent unknown — trailing code may be inside). - while IFS= read -r line || [[ -n "$line" ]]; do + ps::_split_lines_to hs_lines "$cmd" + for line in "${hs_lines[@]}"; do if ((in_hs)); then first2="${line:0:2}" closer="${hs_quote}@" @@ -1543,7 +1951,8 @@ ps::_blank_unbalanced_herestring_tail() { fi continue fi - opener_scan=$(printf '%s' "$line" | sed -E -e "s/'[^']*'//g" -e 's/"([^"\\]|\\.)*"//g') + ps::_gsub_to opener_scan "$line" "'[^']*'" '' + ps::_gsub_to opener_scan "$opener_scan" '"([^"\\]|\\.)*"' '' if [[ "$opener_scan" == *"@'" || "$opener_scan" == *'@"' ]]; then hs_quote="${line: -1}" pending="${line%??}" @@ -1551,7 +1960,7 @@ ps::_blank_unbalanced_herestring_tail() { continue fi out+="${line}"$'\n' - done < <(printf '%s\n' "$cmd") + done if ((in_hs)); then # Hanging opener: keep only the prefix before it; drop the opaque tail. # shellcheck disable=SC2034 @@ -1679,14 +2088,14 @@ ps::write_bypass() { # and vanish from the braced-target scanner entirely (review of #2848). This is # the load-bearing position: the probes cannot do it themselves, because by the # time they are called the backticks are already gone. - lcq=$(ps::fold_escaped_brace_closers "$PS_BLANKED") + ps::fold_escaped_brace_closers_to lcq "$PS_BLANKED" # Keep a backtick-INTACT copy for the quote-blanking below, for the same reason # the brace fold has to run before the deletion: a backtick-escaped QUOTE is an # escape context that the deletion destroys. `"say `"hi"` is one string, but # once the backtick is gone it reads as `"say "` + `hi` + a dangling `"` whose # pairing runs forward to the next literal quote anywhere on the line — which # swallowed `& ('set-'+'content') f.txt x` and returned 0 (review of #2965). - # ps::blank_quoted_spans can only resolve that toward NOT deleting while the + # ps::blank_quoted_spans_to can only resolve that toward NOT deleting while the # backtick still exists, so it must see this copy; the result is stripped # afterwards, which still recovers an obfuscated `Set``-Content` name. lcq_bt="${lcq,,}" @@ -1706,7 +2115,7 @@ ps::write_bypass() { # Both this and the quoted-writer check above accept a statement/block separator # boundary (`;& …`), not only whitespace (review round 6). if ps::call_target_is_bare_computed "$lcq"; then - blanked_gate=$(ps::blank_quoted_spans "$lcq_bt") + ps::blank_quoted_spans_to blanked_gate "$lcq_bt" blanked_gate="${blanked_gate//\`/}" # fd-dup merges (`2>&1`) are plumbing, not file writes — strip them before # ANY probe in this branch runs, so `& $tool 2>&1` does not look like a @@ -1714,7 +2123,7 @@ ps::write_bypass() { # separator by the call-site walk. # # The fd-dup strip MUST run on BOTH texts below before any probe sees them, - # not only on the `>` redirect probe's copy. `ps::call_site_operand_region` + # not only on the `>` redirect probe's copy. `ps::call_site_operand_region_to` # ends a call's operand region at a depth-zero `;` `|` `&`, and the `&` # inside `2>&1` is at depth zero, so handing the MEASURING probes unstripped # text truncates the region of `& $w 2>&1 f.txt x` (a working @@ -1731,17 +2140,17 @@ ps::write_bypass() { # # Redirect / -va* probes run on quote-blanked text so a quoted `>` or # `-value` substring in message text is not a write signal (#2722 review). - blanked_gate=$(printf '%s' "$blanked_gate" | sed -E 's/[0-9*]*>&[0-9]+//g') + ps::_gsub_to blanked_gate "$blanked_gate" '[0-9*]*>&[0-9]+' '' # Quoted operands stay PRESENT-BUT-OPAQUE for the positional probe only. - # blank_quoted_spans DELETES them, so `& $w 'f.txt' 'x'` counted as a + # blank_quoted_spans_to DELETES them, so `& $w 'f.txt' 'x'` counted as a # zero-operand call and quoting was a general evasion of the Path+Value # signal (#2906). A global placeholder would also feed quoted `>` / `-value` # in message text to the redirect and `-va*` probes — measured fail-open # on producer-redirect rows — so this string is derived here and handed # to ps::computed_call_has_positional_write_signal alone. - opaque_gate=$(ps::opaque_quoted_spans "$lcq_bt") + ps::opaque_quoted_spans_to opaque_gate "$lcq_bt" opaque_gate="${opaque_gate//\`/}" - opaque_gate=$(printf '%s' "$opaque_gate" | sed -E 's/[0-9*]*>&[0-9]+//g') + ps::_gsub_to opaque_gate "$opaque_gate" '[0-9*]*>&[0-9]+' '' # Grouping is NOT a write signal (#2848): a blanket # ps::has_special_constructs gate treats ANY grouping anywhere in the # command as one, reporting an ordinary `foreach (…) { & $py run.py $x }` @@ -1783,7 +2192,7 @@ ps::write_bypass() { fi fi - scan=$(ps::blank_quoted_spans "$PS_BLANKED") + ps::blank_quoted_spans_to scan "$PS_BLANKED" # Delete backticks before matching so a name obfuscated by PowerShell's escape # char (`Set``-Content`) resolves to its real form. scan="${scan//\`/}" @@ -1848,9 +2257,11 @@ ps::write_bypass() { # them BEFORE splitting, or the `&` inside `2>&1` cuts a phantom `1 > file` # segment that the numeric-producer test would wrongly block # (`git status 2>&1 > out.txt` is a tool capture, not a content write). - lcs=$(printf '%s' "$lcs" | sed -E 's/[0-9*]*>&[0-9]+//g') + ps::_gsub_to lcs "$lcs" '[0-9*]*>&[0-9]+' '' local norm="${lcs//[|;&]/$'\n'}" - while IFS= read -r seg; do + local -a seg_lines=() + ps::_split_lines_to seg_lines "$norm" + for seg in "${seg_lines[@]}"; do seg="${seg#"${seg%%[![:space:]]*}"}" # ltrim [[ "$seg" == *'>'* ]] || continue # Exclude the `$null` discard (PowerShell's /dev/null). @@ -1889,6 +2300,6 @@ ps::write_bypass() { # Only the SPACED form — an attached digit prefix (`2>err.txt`, `2>&1`) is a # stream redirect whose producer is the preceding tool, not a value. [[ "$head" =~ ^[0-9]+([.][0-9]+)?$ ]] && return 0 - done < <(printf '%s\n' "$norm") # not <<<: a >=64KiB here-string deadlocks (see hardcoded-path-patterns.sh) + done return 1 }