opencode/packages/core/test/shell-scan/powershell-safety.test.ts

470 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, test } from "bun:test"
import { ShellScan } from "../../src/shell/scan.js"
describe("PowerShell scanner safety", () => {
test("backticks in single quotes cannot hide subsequent executions", () => {
const result = ShellScan.scanPowerShell("Write-Output '`'; Remove-Item victim; Write-Output '`'")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words)).toEqual([
["Write-Output", "`"],
["Remove-Item", "victim"],
["Write-Output", "`"],
])
})
test.each([" ", "\t", "\v", "\f", "\u00a0", "\u2000", "\u2028", "\u2029", "\n", "\r", "\r\n"])(
"recognizes comments after escaped whitespace at a token boundary %j",
(space) => {
expect(ShellScan.scanPowerShell(`Write-Output \`${space}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["Write-Output"] }, { words: ["Remove-Item", "victim"] }],
})
for (const redirect of ["2>&1", "6>&1", "*>&1"]) {
expect(
ShellScan.scanPowerShell(`% { Write-Output ${redirect}\`${space}#} '\nRemove-Item victim\n} #'`),
).toMatchObject({
kind: "scanned",
commands: [
{ words: ["%", expect.any(String)] },
{ words: ["Write-Output"] },
{ words: ["Remove-Item", "victim"] },
],
})
}
},
)
test.each([" ", "\t", "\v", "\f", "\u00a0", "\u2000", "\u2028", "\u2029"])(
"keeps backtick whitespace inside strings literal: %j",
(space) => {
const result = ShellScan.scanPowerShell(`% { Write-Output '\`${space}#literal' "\`${space}#literal" }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands[1]?.words).toEqual(["Write-Output", `\`${space}#literal`, `${space}#literal`])
},
)
test.each(["x2>&1", "x6>&1"])("keeps embedded greater-than text distinct from redirects: %s", (token) => {
expect(ShellScan.scanPowerShell(`Write-Output ${token}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `Write-Output ${token.slice(0, 3)}`, words: ["Write-Output", token.slice(0, 3)] },
{ words: ["Remove-Item", "victim"] },
],
})
})
test.each([
"1",
",1",
"+1",
"-1",
".1",
"0x1",
"0b1",
"1L",
"1kb",
"1.0",
"1+1",
"1..2",
"-not 1",
"'x' -eq 1",
'"x" -eq 1',
"{} -eq 1",
])("recognizes expression-mode comments without inventing command heads: %s", (expression) => {
expect(ShellScan.scanPowerShell(`${expression}#'\nRemove-Item victim\n#'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["Remove-Item", "victim"] }],
})
expect(ShellScan.scanPowerShell(`% { ${expression}#} '\nRemove-Item victim\n} #'`)).toMatchObject({
kind: "scanned",
commands: [{ words: ["%", expect.any(String)] }, { words: ["Remove-Item", "victim"] }],
})
})
test.each(["\n", "\r", "\r\n"])("ends nested block comments at %j", (newline) => {
const result = ShellScan.scanPowerShell(`ForEach-Object { # } ignored${newline}Remove-Item victim }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"])
})
test.each([
'ForEach-Object { Write-Output "it\'s } literal"; Remove-Item victim }',
'ForEach-Object { Write-Output "a\'b{c}"; Remove-Item victim }',
"ForEach-Object { Write-Output '`'; Remove-Item victim }",
'ForEach-Object { Write-Output "a`\"}b"; Remove-Item victim }',
'ForEach-Object { Write-Output "a""}b"; Remove-Item victim }',
])("keeps block delimiters inside strings: %s", (input) => {
const result = ShellScan.scanPowerShell(input)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"ForEach-Object",
"Write-Output",
"Remove-Item",
])
})
test("preserves doubled double quotes in argument values", () => {
const result = ShellScan.scanPowerShell('Write-Output "a""b"')
expect(result).toMatchObject({
kind: "scanned",
commands: [{ resource: 'Write-Output "a""b"', words: ["Write-Output", 'a"b'] }],
})
})
test.each(["'safe'", '"safe"', "{ Get-Item x }"])(
"recognizes comments after complete literal tokens: %s",
(argument) => {
const result = ShellScan.scanPowerShell(`Write-Output ${argument}# '\nRemove-Item victim\n# '`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.some((command) => command.words[0] === "Remove-Item")).toBe(true)
expect(result.commands[0]?.resource).toBe(`Write-Output ${argument}`)
},
)
test("keeps generic-token quotes distinct from standalone strings", () => {
expect(ShellScan.scanPowerShell("Write-Output pre'safe'#literal; Get-Item x")).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Write-Output pre'safe'#literal", words: ["Write-Output", "presafe#literal"] },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
expect(ShellScan.scanPowerShell("Write-Output 'safe'tail")).toMatchObject({
kind: "scanned",
commands: [{ resource: "Write-Output 'safe'tail", words: ["Write-Output", "safe", "tail"] }],
})
})
test("block matching agrees with tokenization after a merging redirect", () => {
const result = ShellScan.scanPowerShell("% { Get-Item x 2>&1# } '\nRemove-Item victim\n} # '")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["%", "Get-Item", "Remove-Item"])
})
test.each([
'cmd.exe \u0085--% "ignored\nRemove-Item victim\n# "',
"Write-Output \u2018a'; Remove-Item victim; Write-Output 'b\u2019",
'Write-Output \u201ca"; Remove-Item victim; Write-Output "b\u201d',
])("refuses unsupported lexical modes: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
test.each(["\n", "\r", "\r\n"])("ends stop-parsing at %j even adjacent to completed tokens", (newline) => {
for (const argument of ["'x'", '"x"', "{}"]) {
const result = ShellScan.scanPowerShell(`Write-Output ${argument}--% '${newline}Remove-Item victim${newline}# '`)
expect(result).toMatchObject({
kind: "scanned",
commands: [{ words: ["Write-Output", expect.any(String), "--%", "'"] }, { words: ["Remove-Item", "victim"] }],
})
const block = ShellScan.scanPowerShell(
`% { Write-Output ${argument}--%} '${newline}Remove-Item victim${newline}} # '`,
)
expect(block.kind).toBe("scanned")
if (block.kind === "scanned") expect(block.commands.at(-1)?.words).toEqual(["Remove-Item", "victim"])
}
})
test.each([
"& '' victim",
"Write-Output ok > > out",
"Write-Output ok > 2>&1",
"Write-Output ok 2>&",
"Write-Output ok 2>&2",
"Write-Output ok 1>&1",
"Write-Output ok >&1",
"Write-Output ok 7> out",
"Write-Output ok 2>>&1",
"Write-Output ok > # missing target\nRemove-Item victim",
"Write-Output ok | # missing pipeline\n",
"| Remove-Item victim",
"&& Remove-Item victim",
"& & Remove-Item victim",
"&",
".",
"Write-Output ok; &",
"Write-Output ok; .",
"Set-Location \u2013StackName old",
"Set-Location \u2014StackName old",
"Set-Location \u2015StackName old",
])("reports malformed or unsupported lexical syntax: %s", (input) => {
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
})
test.each(["Set-Location", "SL", "cd", "chdir", "Push-Location", "pushd", "Microsoft.PowerShell.Management\\sl"])(
"preserves commands with directory variables for Core policy through %s",
(head) => {
expect(ShellScan.scanPowerShell(`${head} $target; Get-Item x`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `${head} $target`, words: [head, "$target"] },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
expect(ShellScan.scanPowerShell(`${head} $HOME/project; Get-Item x`).kind).toBe("scanned")
},
)
test.each([
"Pop-Location",
"popd",
"Microsoft.PowerShell.Management\\Pop-Location",
"Set-Location -",
"Set-Location +",
"Set-Location -StackName old",
"Set-Location -st old",
"Set-Location -Path:C:relative",
"Set-Location C:relative",
"Set-Location Registry::HKEY_CURRENT_USER",
"Set-Location $HOME/$target",
])("preserves directory command syntax without deciding directory policy: %s", (input) => {
expect(ShellScan.scanPowerShell(`${input}; Get-Item x`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: input, words: input.split(" ") },
{ resource: "Get-Item x", words: ["Get-Item", "x"] },
],
})
})
test.each(["%", "?", "foreach", "where", "iex"])("keeps command aliases visible: %s", (head) => {
const result = ShellScan.scanPowerShell(`Get-Item x | ${head} { Remove-Item victim }`)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-Item", head, "Remove-Item"])
})
test.each(["Get-*", "./g?t", "./[gr]it", ".\\script.ps1", "..\\scripts\\run.ps1", "\\\\host\\share\\run.ps1"])(
"retains command names and paths without resolving them: %s",
(head) => {
expect(ShellScan.scanPowerShell(`& ${head} victim`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `& ${head} victim`, words: [head, "victim"], rawWords: [head, "victim"] }],
})
},
)
test("recursively extracts commands from nested script blocks", () => {
const result = ShellScan.scanPowerShell("Get-Item x | % { Get-Item y | ? { Remove-Item victim } }")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual([
"Get-Item",
"%",
"Get-Item",
"?",
"Remove-Item",
])
})
test("keeps hashes within words out of block-comment detection", () => {
const result = ShellScan.scanPowerShell("% { Write-Output a#b }; Remove-Item victim")
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toEqual(["%", "Write-Output", "Remove-Item"])
})
test.each([";", "&", "\n", "\r", "\r\n"])("never invents a command after a trailing %j", (separator) => {
expect(ShellScan.scanPowerShell(`Get-Item x${separator}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: "Get-Item x", words: ["Get-Item", "x"] }],
})
})
test.each(["'victim'", '"victim"', "`victim", "{ Get-Item victim }"])(
"does not clear a dangling pipeline with a new empty statement: %s",
(tail) => {
expect(ShellScan.scanPowerShell(`Get-Item x | ; Write-Output ${tail}`).kind).toBe("opaque")
},
)
test("scans valid redirects without consuming command arguments", () => {
for (const redirect of [">", ">>", "1>", "2>>", "3>", "4>", "5>", "6>", "*>", "*>>"]) {
expect(ShellScan.scanPowerShell(`Get-Item x ${redirect} out.txt | Write-Output done`)).toMatchObject({
kind: "scanned",
commands: [
{ resource: `Get-Item x ${redirect} out.txt`, words: ["Get-Item", "x"] },
{ resource: "Write-Output done", words: ["Write-Output", "done"] },
],
})
}
for (const redirect of ["2>&1", "3>&1", "4>&1", "5>&1", "6>&1", "*>&1"]) {
expect(ShellScan.scanPowerShell(`Get-Item x ${redirect}`)).toMatchObject({
kind: "scanned",
commands: [{ resource: `Get-Item x ${redirect}`, words: ["Get-Item", "x"] }],
})
}
})
test("bounds script block nesting and input size", () => {
expect(ShellScan.scanPowerShell("% { ".repeat(33) + "Get-Item x" + " }".repeat(33)).kind).toBe("opaque")
expect(ShellScan.scanPowerShell(`Write-Output ${"x".repeat(64 * 1024)}`).kind).toBe("opaque")
})
test("scans compound subexpressions with expression-mode comments inside expandable strings", () => {
const result = ShellScan.scanPowerShell(
'$x = "$(switch ${x}#)\n{ default { Invoke-ProbeA; Set-Location /outside } })"',
)
expect(result).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Invoke-ProbeA", words: ["Invoke-ProbeA"] },
{ resource: "Set-Location /outside", words: ["Set-Location", "/outside"] },
],
})
})
test.each([
"${x}<# comment #>; Invoke-ProbeA; Set-Location /outside",
"$(1)<# comment #>; Invoke-ProbeA; Set-Location /outside",
"@(1)<# comment #>; Invoke-ProbeA; Set-Location /outside",
"@{a=1}<# comment #>; Invoke-ProbeA; Set-Location /outside",
"[int]<# comment #>$x = 1; Invoke-ProbeA; Set-Location /outside",
"$x<# comment #>; Invoke-ProbeA; Set-Location /outside",
"1<# comment #>; Invoke-ProbeA; Set-Location /outside",
])("recognizes block comments immediately after expression tokens: %s", (source) => {
expect(ShellScan.scanPowerShell(source)).toMatchObject({
kind: "scanned",
commands: [
{ resource: "Invoke-ProbeA", words: ["Invoke-ProbeA"] },
{ resource: "Set-Location /outside", words: ["Set-Location", "/outside"] },
],
})
})
test("rejects mid-token here-string openers and preserves subsequent commands", () => {
expect(ShellScan.scanPowerShell("$x@'\n'; Invoke-ProbeA; Set-Location /outside; '\n'@").kind).toBe("opaque")
expect(
ShellScan.scanPowerShell("Write-Output $x@'\n'; Invoke-ProbeA; Set-Location /outside; #\n'@'"),
).toMatchObject({
kind: "scanned",
commands: [
{ words: ["Write-Output", "$x@\n"] },
{ resource: "Invoke-ProbeA", words: ["Invoke-ProbeA"] },
{ resource: "Set-Location /outside", words: ["Set-Location", "/outside"] },
],
})
})
test.each([
"Write-Output [a; Invoke-ProbeA; Set-Location /outside; Write-Output ]",
'Write-Output "${x"\'}"#"\nInvoke-ProbeA; Set-Location /outside\n# "\'',
"Write-Output ${a`}'}; Invoke-ProbeA; Set-Location /outside; #'",
".'Write-Output'# '\nInvoke-ProbeA; Set-Location /outside\n# '",
"$x > $null#'\n'; Invoke-ProbeA; Set-Location /outside; #'",
"$x>$null#'\n'; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output --% arg && Invoke-ProbeA; Set-Location /outside",
"Write-Output > --% out.txt; Invoke-ProbeA; Set-Location /outside",
"Write-Output `t#'\n'; Invoke-ProbeA; Set-Location /outside; #'",
"[CmdletBinding()] param($x) Invoke-ProbeA; Set-Location /outside",
"function f#a { Invoke-ProbeA; Set-Location /outside }; f#a",
'"$("a""; Invoke-ProbeA; Set-Location /outside; "b"")"',
'$x = "$("a""; Invoke-ProbeA; Set-Location /outside; "b"")"',
"${a`u{0041}'}; Invoke-ProbeA; Set-Location /outside; #'",
'"${a`u{0041}" # }; $(Invoke-ProbeA); $(Set-Location /outside)"',
'@"\n${a`u{0041}\n"@; \' # } $(Invoke-ProbeA); $(Set-Location /outside)\n"@#\'',
"$probe[-$(Invoke-ProbeA)]; $probe[1+$(Set-Location /outside)]",
"function else {}\nelse\nInvoke-ProbeA\nSet-Location /outside\n{}",
"function catch {}\ncatch\nInvoke-ProbeA\nSet-Location /outside\n{}",
"switch ('x') {\n a#'\n ' {} default { Invoke-ProbeA; Set-Location /outside } # '\n}",
"data -SupportedCommand a<#'#> ' {}; Invoke-ProbeA; Set-Location /outside # ' {}",
"if ($false) { switch -File $global:probe[0#'\n] { default {} } }; Invoke-ProbeA; Set-Location /outside # ' {} }",
"if ($false) { switch -File $.a[a<#'#>'] { default {} } }; Invoke-ProbeA; Set-Location /outside # '] {} }",
"Write-Output 'a'b[a<#'#>']; Invoke-ProbeA; Set-Location /outside; # ']",
"try { throw 1 } catch [Exception<#'#>] { Invoke-ProbeA; Set-Location /outside } # ' {}",
"Write-Output --`%('| Invoke-ProbeA; Set-Location /outside #')",
"if ($false) { Set-Location --`%('\n} if ($true) { Invoke-ProbeA; Set-Location /outside # ')\n}",
"if ($false) { $.a<#'#>' }; Invoke-ProbeA; Set-Location /outside; #' }",
"-$n#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"--$n<#'#>; Invoke-ProbeA; Set-Location /outside; #'",
'Invoke-ProbeA "$( "a`"; Invoke-ProbeB; Set-Location /outside; `"b" )"',
'Invoke-ProbeA "$( `"\'"; Invoke-ProbeB; Set-Location /outside; "\'`" )"',
'Invoke-ProbeA "$( "a`""""; Invoke-ProbeB; Set-Location /outside; "c`"""" )"',
'Invoke-ProbeA "$( ""$( """"\'""""; Invoke-ProbeB; Set-Location /outside; """"\'"""" )"" )"',
'Invoke-ProbeA "$( Invoke-ProbeA @"\n""@; Invoke-ProbeB; Set-Location /outside; \'\n"@#\'\n)"',
'Invoke-ProbeA "$( \'(\' )"#" )"; Invoke-ProbeB; Set-Location /outside',
'Invoke-ProbeA "$( <# ( #> )"#" )"; Invoke-ProbeB; Set-Location /outside',
'Invoke-ProbeA "$( # (\n)"#" )"; Invoke-ProbeB; Set-Location /outside',
'Invoke-ProbeA "$( ${(} )"#" )"; Invoke-ProbeB; Set-Location /outside',
'Invoke-ProbeA "$( "(" )"#" )"; Invoke-ProbeB; Set-Location /outside',
"@\"\n$( '(' )\n\"@ ' )\n\"@\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output arg`\r\nInvoke-ProbeA\r\nSet-Location /outside",
"Write-Output arg`\r\n#'\r\nInvoke-ProbeA; Set-Location /outside\r\n#'",
'@"\n`\r\n"@; Invoke-ProbeA; Set-Location /outside; \'\r\n"@#\'',
"switch ('x') {\n $a#'\n ' {} default { Invoke-ProbeA; Set-Location /outside } # '\n}",
"switch ('x') {\n 1#'\n ' {} default { Invoke-ProbeA; Set-Location /outside } # '\n}",
"switch ('x') {\n ${a}b#'\n ' {} default { Invoke-ProbeA; Set-Location /outside } # '\n}",
"switch ('x') {\n $a<#'#>' {} default { Invoke-ProbeA; Set-Location /outside } # '\n}",
"if ($false) { switch -File @{} { a {} --% {} } }; Invoke-ProbeA; Set-Location /outside <#\n} } #>",
"- 1#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"- $n#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"-- $n#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"-[int]$n#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"--[int]$n#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"$:a = Invoke-ProbeA; $:b = Set-Location /outside",
"$α = Invoke-ProbeA; $β = Set-Location /outside",
"foreach ($:a in Set-Location /outside) { Invoke-ProbeA }",
"foreach ($α in Set-Location /outside) { Invoke-ProbeA }",
"$:a#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"$α#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"$:a = @(1); Write-Output $:a[0]#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"$α = @(1); Write-Output $α[0]#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output $?[a<#'#>']; Invoke-ProbeA; Set-Location /outside; # ']",
"Write-Output $?a[a<#'#>']; Invoke-ProbeA; Set-Location /outside; # ']",
"Write-Output $?.a[a<#'#>']; Invoke-ProbeA; Set-Location /outside; # ']",
"Write-Output ${a}::b[a<#'#>']; Invoke-ProbeA; Set-Location /outside; # ']",
"Write-Output $a..b[a<#'#>']; Invoke-ProbeA; Set-Location /outside; # ']",
"function f ([a#]$x) {}; Invoke-ProbeA; Set-Location /outside <#\n]) {} #>",
"if ($false) { [a#]$x }; Invoke-ProbeA; Set-Location /outside <#\n] } #>",
"if ($false) { $x.Foo[a#]() }; Invoke-ProbeA; Set-Location /outside <#\n] } #>",
"try { } catch [a#] { Invoke-ProbeA; Set-Location /outside } <#\n] {} #>",
"Write-Output a,#'\nb; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output a,<#'#>b; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output a,@'\n'\n'@; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output a,@{k=1#'\n}; Invoke-ProbeA; Set-Location /outside; #'}",
"Write-Output a,$probe[0]#'\nInvoke-ProbeA; Set-Location /outside\n#'",
'Invoke-ProbeA "$( "a``""; Invoke-ProbeB; Set-Location /outside; `"c``"" )"',
'Invoke-ProbeA "$( "a``""; Invoke-ProbeB; Set-Location /outside; ""c``"" )"',
'Invoke-ProbeA "$( `"a``""; Invoke-ProbeB; Set-Location /outside; `"c``"" )"',
'Invoke-ProbeA "$( Write-Output ``"a; Invoke-ProbeB; Set-Location /outside; Write-Output ``" )"',
'Invoke-ProbeA "$( Invoke-ProbeA --% ""`" && Set-Location /outside\n)"',
"Write-Output a, --% ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a,\n--% ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a, --`% ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a,<##>--% ; Invoke-ProbeA; Set-Location /outside",
"Write-Output -Param: --% ; Invoke-ProbeA; Set-Location /outside",
"Write-Output -Param:<##>--% ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a,`\r\n1# ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a,`\r\n$x# ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a,`\n\n1# ; Invoke-ProbeA; Set-Location /outside",
"Write-Output a,` #'\nb; Invoke-ProbeA; Set-Location /outside\n#'",
"Write-Output a,` <#'#>b; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output a,` @'\n'\n'@; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output a,` @{k=1#'\n}; Invoke-ProbeA; Set-Location /outside; #'}",
"Write-Output x,'a'[0]#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output x,\"a\"[0]#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output x,'a'#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output x,'a'<#'#>; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output $probe.Length#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output $probe.Length<#'#>; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output $probe.<#'#>Length; Invoke-ProbeA; Set-Location /outside; #'",
"Write-Output $probe.#'\nLength; Invoke-ProbeA; Set-Location /outside\n#'",
"Write-Output (1).ToString#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output (1)?.ToString#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output 'a'.Length#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output ${probe}.Length#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output x,$probe.Length#'\nInvoke-ProbeA; Set-Location /outside\n#'",
"Write-Output x,${probe}.Length#'\nInvoke-ProbeA; Set-Location /outside\n#'",
])("does not hide commands across lexical mode boundaries: %s", (source) => {
const result = ShellScan.scanPowerShell(source)
expect(result.kind).toBe("scanned")
if (result.kind === "opaque") return
expect(result.commands.map((command) => command.words[0])).toContain("Invoke-ProbeA")
expect(result.commands.map((command) => command.words)).toContainEqual(["Set-Location", "/outside"])
})
})