mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:31:29 -04:00
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import { ShellScan } from "../../src/shell/scan.js"
|
|
|
|
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
|
|
|
|
const fixtures = [
|
|
...[
|
|
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
|
"$result = (Invoke-ProbeA); Invoke-ProbeB",
|
|
"[string]$result = Invoke-ProbeA; Invoke-ProbeB",
|
|
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
|
|
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
|
|
"foreach ($item in Invoke-ProbeA) { Invoke-ProbeB }",
|
|
"for ($i=0; $i -lt 2; $i++) { Invoke-ProbeB }",
|
|
"while (Invoke-ProbeA) { Invoke-ProbeB; break }",
|
|
"do { Invoke-ProbeB } until ($true)",
|
|
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
|
|
"function Get-Probe($x = (Invoke-ProbeA)) { Invoke-ProbeB }",
|
|
"try { Invoke-ProbeA } catch { Invoke-ProbeB } finally { Invoke-ProbeC }",
|
|
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
|
|
"Invoke-ProbeA @(Invoke-ProbeB; Invoke-ProbeC)",
|
|
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
|
|
"Invoke-ProbeA <# <# ignored } #> #> literal; Invoke-ProbeB",
|
|
'& "Inv`oke-ProbeA"; Invoke-ProbeB',
|
|
'Invoke-ProbeBlock { & "Invoke-ProbeA" literal#value; Invoke-ProbeB }',
|
|
'Invoke-ProbeBlock { . "Invoke-ProbeA" literal#value; Invoke-ProbeB }',
|
|
"Invoke-ProbeBlock { ${probe}# ignored\nInvoke-ProbeB }",
|
|
"Invoke-ProbeBlock { $result = Invoke-ProbeA literal#value; Invoke-ProbeB }",
|
|
"Invoke-ProbeA $probe[$(Invoke-ProbeB)]",
|
|
'Invoke-ProbeA "tab`tnewline`n`u{0041}"; Invoke-ProbeB',
|
|
"Invoke-ProbeA |\n\n# comment\nForEach-Object { Invoke-ProbeB }",
|
|
],
|
|
...[
|
|
"1",
|
|
"+1",
|
|
"-1",
|
|
".1",
|
|
"0x1",
|
|
"0b1",
|
|
"1L",
|
|
"1kb",
|
|
"1.0",
|
|
"1+1",
|
|
"1..2",
|
|
"-not 1",
|
|
"-bnot 1",
|
|
"!1",
|
|
"!!1",
|
|
",1",
|
|
"-join 1",
|
|
"\u2013not 1",
|
|
"\u2014not 1",
|
|
"\u2015not 1",
|
|
"\u2013join 1",
|
|
"\u2014join 1",
|
|
"\u2015join 1",
|
|
"'x' -eq 1",
|
|
'"x" -eq 1',
|
|
"{} -eq 1",
|
|
].flatMap((expression) => [
|
|
`${expression}#'\nInvoke-ProbeB\n#'`,
|
|
`Invoke-ProbeBlock { ${expression}#} '\nInvoke-ProbeB\n} #'`,
|
|
]),
|
|
...[
|
|
"$null",
|
|
"$probe",
|
|
"$HOME",
|
|
"${probe}",
|
|
"$env:PATH",
|
|
"$true",
|
|
"$false",
|
|
"1",
|
|
"1.0",
|
|
"1kb",
|
|
"0x1",
|
|
"-1",
|
|
"x2>&1",
|
|
"x6>&1",
|
|
">$null",
|
|
"> $null",
|
|
"'x'>$null",
|
|
].flatMap((prefix) => [
|
|
`Invoke-ProbeA ${prefix}#'\nInvoke-ProbeB\n#'`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA ${prefix}#} '\nInvoke-ProbeB\n} #'`,
|
|
]),
|
|
...[" ", "\t", "\v", "\f", "\u0085", "\u00a0", "\u2000", "\u2028", "\u2029", "\ufeff"].flatMap((space) => [
|
|
`Invoke-ProbeA \`${space}#'\nInvoke-ProbeB\n#'`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA 2>&1\`${space}#} '\nInvoke-ProbeB\n} #'`,
|
|
]),
|
|
...Array.from({ length: 2048 }, (_, index) => {
|
|
const tokens = [
|
|
"x",
|
|
" ",
|
|
"\t",
|
|
"\r",
|
|
"\n",
|
|
";",
|
|
"|",
|
|
"'",
|
|
'"',
|
|
"''",
|
|
'""',
|
|
"#",
|
|
"--%",
|
|
"`",
|
|
"{",
|
|
"}",
|
|
"2>&1",
|
|
"Invoke-ProbeC",
|
|
]
|
|
let seed = index + 1
|
|
const body = Array.from({ length: 8 }, () => {
|
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
|
|
return tokens[(seed >>> 16) % tokens.length]
|
|
}).join("")
|
|
return index % 2
|
|
? `Invoke-ProbeA ${body}\nInvoke-ProbeB\n# '`
|
|
: `Invoke-ProbeBlock { Invoke-ProbeA ${body}\nInvoke-ProbeB\n} # '`
|
|
}),
|
|
...["&", "."].flatMap((operator) =>
|
|
["'Invoke-ProbeA'", '"Invoke-ProbeA"'].flatMap((head) =>
|
|
["argument", "'argument'", '"argument"', "#ignored", "--% '"].map(
|
|
(tail) => `${operator} ${head}${tail}\nInvoke-ProbeB\n# '`,
|
|
),
|
|
),
|
|
),
|
|
...["'", '"', "\u2018", "\u2019", "\u201c", "\u201d"].flatMap((open) =>
|
|
["'", '"', "\u2018", "\u2019", "\u201c", "\u201d"].flatMap((close) =>
|
|
["", "literal", "`", "'", '"', "''", '""', "{", "}", "#", "--%", "$(Invoke-ProbeB)"].flatMap((content) => [
|
|
`Invoke-ProbeA ${open}${content}${close}; Invoke-ProbeB; Invoke-ProbeC '${content}'`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA ${open}${content}${close}; Invoke-ProbeB }; Invoke-ProbeC '${content}'`,
|
|
]),
|
|
),
|
|
),
|
|
...["", "literal", "'quoted'", '"quoted"', "{}"].flatMap((prefix) =>
|
|
["#ignored", "<#ignored#>", "--% 'ignored", '--% "ignored'].flatMap((tail) =>
|
|
[";", "|", "\r", "\n", "\r\n"].map(
|
|
(separator) => `Invoke-ProbeA ${prefix}${tail}${separator}Invoke-ProbeB${separator}Invoke-ProbeC`,
|
|
),
|
|
),
|
|
),
|
|
...["''", "'x'", '"x"', "{}", "2>&1", "6>&1", "*>&1", "plain", "plain'quoted'"].flatMap((prefix) =>
|
|
["#", "--%", "--% ", "<# #>#"].flatMap((suffix) =>
|
|
["\r", "\n", "\r\n"].flatMap((newline) => [
|
|
`Invoke-ProbeA ${prefix}${suffix}'${newline}Invoke-ProbeB${newline}# '`,
|
|
`Invoke-ProbeA ${prefix}${suffix}\"${newline}Invoke-ProbeB${newline}# \"`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA ${prefix}${suffix}} '${newline}Invoke-ProbeB${newline}} # '`,
|
|
]),
|
|
),
|
|
),
|
|
...[
|
|
...Array.from({ length: 33 }, (_, index) => String.fromCharCode(index)),
|
|
"\u0085",
|
|
"\u00a0",
|
|
"\u1680",
|
|
"\u2000",
|
|
"\u2028",
|
|
"\u2029",
|
|
"\u202f",
|
|
"\u205f",
|
|
"\u3000",
|
|
"\ufeff",
|
|
].flatMap((space) => [
|
|
`Invoke-ProbeA${space}argument; Invoke-ProbeB`,
|
|
`Invoke-ProbeA # ignored${space}Invoke-ProbeB`,
|
|
`Invoke-ProbeA${space}Invoke-ProbeB`,
|
|
`&${space}'Invoke-ProbeA'; Invoke-ProbeB`,
|
|
]),
|
|
...["Invoke-ProbeA", "& Invoke-ProbeA", "& 'Invoke-ProbeA'", '. "Invoke-ProbeA"'].flatMap((head) =>
|
|
[
|
|
"plain",
|
|
"'single ; | & # { }'",
|
|
'"double ; | & # { }"',
|
|
"'single''quote'",
|
|
'"double""quote"',
|
|
"'`'",
|
|
'"a`"}b"',
|
|
'"it\'s } literal"',
|
|
"left`#right",
|
|
"left`;right",
|
|
"left`|right",
|
|
"left`&right",
|
|
'"$(Invoke-ProbeB)"',
|
|
'"$probe"',
|
|
'"${probe}"',
|
|
"\u2018smart single\u2019",
|
|
"\u201csmart double\u201d",
|
|
].flatMap((argument) =>
|
|
["; ", "\n", "\r", "\r\n", " | ", " && ", " || "].map(
|
|
(separator) => `${head} ${argument}${separator}Invoke-ProbeC`,
|
|
),
|
|
),
|
|
),
|
|
...["\n", "\r", "\r\n"].flatMap((newline) => [
|
|
`Invoke-ProbeA # '\" } ; ignored${newline}Invoke-ProbeB`,
|
|
`Invoke-ProbeBlock { # } ignored${newline}Invoke-ProbeB }`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA # } ignored${newline}Invoke-ProbeB }`,
|
|
`Invoke-ProbeA --% \"ignored${newline}Invoke-ProbeB${newline}\"`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA --% \"ignored${newline}Invoke-ProbeB${newline}\" }`,
|
|
`Invoke-ProbeA \`${newline}argument; Invoke-ProbeB`,
|
|
`Invoke-ProbeA @'${newline}literal ; }${newline}'@; Invoke-ProbeB`,
|
|
`Invoke-ProbeA @\"${newline}$(Invoke-ProbeB)${newline}\"@; Invoke-ProbeC`,
|
|
]),
|
|
...["&", ".", "Invoke-ProbeBlock", "Invoke-ProbeA | ForEach-Object"].flatMap((head) =>
|
|
[
|
|
"Invoke-ProbeB; Invoke-ProbeC",
|
|
"Invoke-ProbeB '`'; Invoke-ProbeC",
|
|
'Invoke-ProbeB "it\'s } literal"; Invoke-ProbeC',
|
|
'Invoke-ProbeB "a`\"}b"; Invoke-ProbeC',
|
|
'Invoke-ProbeB "a\"\"}b"; Invoke-ProbeC',
|
|
"Invoke-ProbeBlock { Invoke-ProbeB }; Invoke-ProbeC",
|
|
"<# <# } #> #> Invoke-ProbeB",
|
|
'Invoke-ProbeB "$(Invoke-ProbeC)"',
|
|
'Invoke-ProbeB "$(Invoke-ProbeA \"}\"); Invoke-ProbeC"',
|
|
].map((body) => `${head} { ${body} }; Invoke-ProbeA`),
|
|
),
|
|
...["> $null", ">> $null", "2>&1", "3>&1", "4>&1", "5>&1", "6>&1", "*>&1", "*> $null"].flatMap((redirect) => [
|
|
`Invoke-ProbeA ${redirect}; Invoke-ProbeB`,
|
|
`Invoke-ProbeBlock { Invoke-ProbeA ${redirect}; Invoke-ProbeB }`,
|
|
]),
|
|
"Invoke-ProbeA --% 'ignored|Invoke-ProbeB|Invoke-ProbeC '",
|
|
"Invoke-ProbeA --% ; Invoke-ProbeB",
|
|
"Invoke-ProbeA --% $(Invoke-ProbeB)",
|
|
"Invoke-ProbeA \u2018a'; Invoke-ProbeB; Invoke-ProbeC 'b\u2019",
|
|
'Invoke-ProbeA \u201ca"; Invoke-ProbeB; Invoke-ProbeC "b\u201d',
|
|
'Invoke-ProbeA "$(Invoke-ProbeB \"quoted\")"; Invoke-ProbeC',
|
|
"& $probe; Invoke-ProbeC",
|
|
"& ('Invoke-' + 'ProbeB'); Invoke-ProbeC",
|
|
"& { Invoke-ProbeB }; Invoke-ProbeC",
|
|
"$(Invoke-ProbeB); Invoke-ProbeC",
|
|
"@(Invoke-ProbeB); Invoke-ProbeC",
|
|
"Invoke-ProbeA <# ignored #>; Invoke-ProbeB",
|
|
"Invoke-ProbeA#literal; Invoke-ProbeB",
|
|
"Invoke-ProbeA ''#literal; Invoke-ProbeB",
|
|
'Invoke-ProbeA ""#literal; Invoke-ProbeB',
|
|
"Invoke-ProbeA { Invoke-ProbeB }#literal; Invoke-ProbeC",
|
|
"Inv'oke'-ProbeA; Invoke-ProbeB",
|
|
'Inv"oke"-ProbeA; Invoke-ProbeB',
|
|
"Invoke-ProbeA,Invoke-ProbeB; Invoke-ProbeC",
|
|
'Invoke-ProbeA --%"literal|Invoke-ProbeB|Invoke-ProbeC"',
|
|
'Invoke-ProbeA x--% "literal|Invoke-ProbeB|Invoke-ProbeC"',
|
|
"Invoke-ProbeA '--%' \"literal|Invoke-ProbeB|Invoke-ProbeC\"",
|
|
'Invoke-ProbeA "--%" "literal|Invoke-ProbeB|Invoke-ProbeC"',
|
|
'Invoke-ProbeA --`% "literal|Invoke-ProbeB|Invoke-ProbeC"',
|
|
'Invoke-ProbeA `-`-`% "literal|Invoke-ProbeB|Invoke-ProbeC"',
|
|
"Invoke-ProbeA ''--% \"literal|Invoke-ProbeB|Invoke-ProbeC\"",
|
|
"Invoke-ProbeA 2>&1; &{Invoke-ProbeB}; Invoke-ProbeC",
|
|
"Invoke-ProbeA 2>&1|Invoke-ProbeB",
|
|
"Invoke-ProbeA 'x'#'\nInvoke-ProbeB\nInvoke-ProbeC ''#'",
|
|
'Invoke-ProbeA "x"#"\nInvoke-ProbeB\nInvoke-ProbeC ""#"',
|
|
"Invoke-ProbeA {}#'\nInvoke-ProbeB\nInvoke-ProbeC ''#'",
|
|
"Invoke-ProbeBlock { Invoke-ProbeA 'x'#'\nInvoke-ProbeB\nInvoke-ProbeC ''#' }",
|
|
"Invoke-ProbeA 'x'# ' \nInvoke-ProbeB\n# '",
|
|
"Invoke-ProbeA { Invoke-ProbeB }# ' \nInvoke-ProbeC\n# '",
|
|
"Invoke-ProbeA { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
|
|
"Invoke-ProbeBlock { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
|
|
"& { Invoke-ProbeB 2>&1# } ' \nInvoke-ProbeC\n} # '",
|
|
"Invoke-ProbeA & Invoke-ProbeB",
|
|
"Invoke-ProbeA 'safe`'; Invoke-ProbeB; '`'",
|
|
]
|
|
|
|
// Runtime execution is restricted to probes and $null/stream redirections; other cases are parser-only.
|
|
const oracle = String.raw`
|
|
$ErrorActionPreference = 'Stop'
|
|
$probe = 'Invoke-ProbeB'
|
|
$script:seen = [System.Collections.Generic.List[string]]::new()
|
|
function Invoke-ProbeA { [void]$script:seen.Add('Invoke-ProbeA'); 1 }
|
|
function Invoke-ProbeB { [void]$script:seen.Add('Invoke-ProbeB'); 1 }
|
|
function Invoke-ProbeC { [void]$script:seen.Add('Invoke-ProbeC'); 1 }
|
|
function Invoke-ProbeBlock {
|
|
[void]$script:seen.Add('Invoke-ProbeBlock')
|
|
foreach ($argument in $args) {
|
|
if ($argument -is [scriptblock]) { & $argument }
|
|
}
|
|
}
|
|
$results = foreach ($source in (ConvertFrom-Json -InputObject ([Console]::In.ReadToEnd()))) {
|
|
$tokens = $null
|
|
$errors = $null
|
|
$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref]$tokens, [ref]$errors)
|
|
$nodes = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true))
|
|
$commands = @($nodes | ForEach-Object {
|
|
@{
|
|
name = $_.GetCommandName()
|
|
text = $_.Extent.Text
|
|
start = $_.Extent.StartOffset
|
|
end = $_.Extent.EndOffset
|
|
}
|
|
})
|
|
$script:seen.Clear()
|
|
$runtimeError = $null
|
|
$unsafe = @($nodes | Where-Object {
|
|
$name = $_.GetCommandName()
|
|
($name -and $name -notin @('Invoke-ProbeA', 'Invoke-ProbeB', 'Invoke-ProbeC', 'Invoke-ProbeBlock', 'ForEach-Object')) -or
|
|
(!$name -and $_.CommandElements[0] -isnot [System.Management.Automation.Language.ScriptBlockExpressionAst])
|
|
})
|
|
$files = @($ast.FindAll({ param($node)
|
|
$node -is [System.Management.Automation.Language.FileRedirectionAst] -and $node.Location.Extent.Text -ne '$null'
|
|
}, $true))
|
|
$background = @($ast.FindAll({ param($node)
|
|
$node -is [System.Management.Automation.Language.PipelineAst] -and $node.Background
|
|
}, $true))
|
|
if ($errors.Count -eq 0 -and $unsafe.Count -eq 0 -and $files.Count -eq 0 -and $background.Count -eq 0) {
|
|
try { & ([scriptblock]::Create($source)) | Out-Null }
|
|
catch { $runtimeError = $_.Exception.Message }
|
|
}
|
|
@{
|
|
source = $source
|
|
commands = $commands
|
|
errors = @($errors | ForEach-Object { $_.ErrorId })
|
|
executed = @($script:seen.ToArray())
|
|
runtimeError = $runtimeError
|
|
}
|
|
}
|
|
ConvertTo-Json -InputObject @($results) -Depth 10 -Compress
|
|
`
|
|
|
|
// Run with SHELL_SCAN_PWSH=/path/to/pwsh bun run test test/shell-scan/powershell-runtime.test.ts.
|
|
test.skipIf(!pwsh)(
|
|
"successful PowerShell scans cover real parser boundaries and executed probe calls",
|
|
async () => {
|
|
const process = Bun.spawn([pwsh!, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", oracle], {
|
|
stdin: new Blob([JSON.stringify(fixtures)]),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
timeout: 45_000,
|
|
})
|
|
const [output, error, code] = await Promise.all([
|
|
new Response(process.stdout).text(),
|
|
new Response(process.stderr).text(),
|
|
process.exited,
|
|
])
|
|
expect({ code, error }).toEqual({ code: 0, error: "" })
|
|
const results: Array<{
|
|
source: string
|
|
commands: Array<{ name: string | null; text: string; start: number; end: number }>
|
|
errors: string[]
|
|
executed: string[]
|
|
runtimeError: string | null
|
|
}> = JSON.parse(output)
|
|
expect(results).toHaveLength(fixtures.length)
|
|
const failures: string[] = []
|
|
let scanned = 0
|
|
let executed = 0
|
|
for (const result of results) {
|
|
const scan = ShellScan.scanPowerShell(result.source)
|
|
if (scan.kind === "opaque" || result.errors.length > 0) continue
|
|
scanned++
|
|
executed += result.executed.length
|
|
const missing = result.commands.filter(
|
|
(command) =>
|
|
command.name !== null &&
|
|
!scan.commands.some(
|
|
(candidate) =>
|
|
candidate.words[0]?.toLowerCase() === command.name?.toLowerCase() &&
|
|
candidate.resource === command.text.trim(),
|
|
),
|
|
)
|
|
const unobserved = result.executed.filter(
|
|
(name) => !scan.commands.some((command) => command.words[0]?.toLowerCase() === name.toLowerCase()),
|
|
)
|
|
if (missing.length || unobserved.length)
|
|
failures.push(
|
|
JSON.stringify({
|
|
source: result.source,
|
|
missing: missing.map((command) => command.text),
|
|
unobserved,
|
|
scanned: scan.commands.map((command) => command.words[0]),
|
|
}),
|
|
)
|
|
}
|
|
expect(scanned).toBeGreaterThan(100)
|
|
expect(executed).toBeGreaterThan(100)
|
|
expect(failures).toEqual([])
|
|
},
|
|
60_000,
|
|
)
|