@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/common/bundle-sources.js +20 -1
  2. package/common/default-scan-models.ts +1 -1
  3. package/common/dependency-reviews.d.ts +0 -5
  4. package/common/dependency-reviews.js +0 -17
  5. package/common/managed/agent-tasks.ts +292 -0
  6. package/common/managed/github-agents.ts +92 -0
  7. package/common/managed/github-logins.ts +49 -0
  8. package/common/managed/roles.ts +7 -0
  9. package/common/managed/routes.js +88 -7
  10. package/common/managed/team-app.d.ts +5 -2
  11. package/common/managed/team-app.js +2 -2
  12. package/common/server-info.ts +3 -0
  13. package/common/workspace-app.js +6 -3
  14. package/out/brotli-fallback.js +3 -3
  15. package/out/chunk-2XUEZHIP.js +18 -18
  16. package/out/chunk-2ZDZWLFY.js +1 -0
  17. package/out/chunk-3PS4KWD2.js +10 -0
  18. package/out/chunk-5DHL5NG3.js +2 -0
  19. package/out/chunk-6HNXNIQY.js +52 -0
  20. package/out/chunk-7C7DWL7I.js +1 -0
  21. package/out/chunk-AFLZH7WG.js +1 -0
  22. package/out/chunk-BC2UCLFJ.js +1 -0
  23. package/out/chunk-C4Z6ZBXV.js +6 -6
  24. package/out/chunk-GDPCLSOS.js +2 -0
  25. package/out/chunk-GO7FT4OD.js +1 -0
  26. package/out/chunk-HMAJDVXN.js +1 -0
  27. package/out/chunk-I2MDGSQX.js +3 -0
  28. package/out/chunk-IYPY47MC.js +1 -0
  29. package/out/chunk-IZ7POWI3.js +18 -0
  30. package/out/chunk-JCIIBLKR.js +1 -1
  31. package/out/chunk-NPW52BM2.js +17 -0
  32. package/out/chunk-NRPC74AM.js +1 -1
  33. package/out/chunk-NV7ES4EW.js +1 -0
  34. package/out/chunk-OKRLLE5I.js +17 -0
  35. package/out/chunk-OOJM4CTU.js +1 -1
  36. package/out/chunk-PNHBZGDD.js +1 -1
  37. package/out/chunk-PTGDYLNS.js +36 -0
  38. package/out/chunk-PXK7EU4V.js +1 -0
  39. package/out/chunk-Q6MVYGYV.js +3 -0
  40. package/out/chunk-QB4CBOX2.js +1 -0
  41. package/out/chunk-QRD6GIG5.js +229 -0
  42. package/out/chunk-R3NZFXVR.js +1 -0
  43. package/out/chunk-R5O6FAW4.js +1 -1
  44. package/out/chunk-SXV2T5C4.js +80 -0
  45. package/out/chunk-TUII34K2.js +1 -1
  46. package/out/chunk-VFWMQ3LG.js +2 -0
  47. package/out/chunk-VH4XMZIW.js +2 -0
  48. package/out/chunk-WFYLMF6Z.js +10 -0
  49. package/out/chunk-WI7Z5A2T.js +6 -0
  50. package/out/chunk-WQ74ZBZF.js +7 -0
  51. package/out/chunk-XVBEOGOS.js +3 -0
  52. package/out/client-managed-import.js +3 -3
  53. package/out/client-managed.js +31 -35
  54. package/out/client-sync.js +11 -11
  55. package/out/compare.js +35 -0
  56. package/out/dialogs.js +51 -0
  57. package/out/github-issue-authorized.html +1 -1
  58. package/out/github-issue-authorized.js +1 -1
  59. package/out/graph.js +17 -17
  60. package/out/index.html +30 -34
  61. package/out/prism.js +14 -14
  62. package/out/scan.js +2 -0
  63. package/out/terminal.js +294 -293
  64. package/out/view.css +1 -1
  65. package/out/view.js +16 -107
  66. package/package.json +51 -6
  67. package/risk/actions/findings.js +156 -0
  68. package/risk/actions/resolve.js +219 -0
  69. package/risk/actions/scripts/commands.js +182 -0
  70. package/risk/actions/scripts/grade.js +113 -0
  71. package/risk/actions/scripts/packages.js +123 -0
  72. package/risk/actions/scripts/shell.js +165 -0
  73. package/risk/actions/uses.js +76 -0
  74. package/risk/actions/workflow.js +110 -0
  75. package/risk/files/hosts.js +6 -6
  76. package/risk/files/readability.js +23 -17
  77. package/risk/files/runs-code.js +18 -6
  78. package/risk/files/script.js +7 -3
  79. package/risk/files/stamps.js +8 -7
  80. package/risk/index.d.ts +68 -1
  81. package/risk/index.js +6 -2
  82. package/risk/npm/assess.js +1 -1
  83. package/risk/npm/compare.js +34 -10
  84. package/risk/npm/files.js +413 -2
  85. package/risk/npm/tarball.js +56 -73
  86. package/server-managed/agent-merges.ts +198 -0
  87. package/server-managed/app-agents.ts +30 -0
  88. package/server-managed/avatar-store.ts +1 -0
  89. package/server-managed/commit-coauthors.ts +83 -0
  90. package/server-managed/commit-lockfiles.ts +62 -0
  91. package/server-managed/db-methods.ts +29 -10
  92. package/server-managed/db-neon.ts +30 -2
  93. package/server-managed/db-schema.ts +9 -2
  94. package/server-managed/db.ts +7 -0
  95. package/server-managed/dependabot-access.ts +60 -0
  96. package/server-managed/dependabot-alerts.ts +185 -0
  97. package/server-managed/dependency-review-comments.ts +97 -0
  98. package/server-managed/dependency-reviews.ts +65 -15
  99. package/server-managed/github-accounts.ts +33 -0
  100. package/server-managed/github-actions.ts +71 -0
  101. package/server-managed/github-app.ts +23 -9
  102. package/server-managed/github-errors.ts +2 -0
  103. package/server-managed/github-link-oauth.ts +69 -0
  104. package/server-managed/github-links.ts +204 -0
  105. package/server-managed/github-oauth.ts +5 -2
  106. package/server-managed/github-users.ts +30 -0
  107. package/server-managed/http.ts +1122 -52
  108. package/server-managed/index.ts +6 -2
  109. package/server-managed/locked-packages.ts +37 -16
  110. package/server-managed/lockfile-scope.ts +49 -0
  111. package/server-managed/npm-delta-worker.js +23 -0
  112. package/server-managed/npm-delta.ts +173 -0
  113. package/server-managed/npm-insights.ts +20 -11
  114. package/server-managed/npm-kept.ts +51 -4
  115. package/server-managed/npm-loads.ts +20 -13
  116. package/server-managed/npm-packages.ts +24 -64
  117. package/server-managed/pull-comment-reviews.ts +161 -0
  118. package/server-managed/pull-dependencies.ts +280 -0
  119. package/server-managed/pull-dependency-reviews.ts +318 -0
  120. package/server-managed/pull-reviews.ts +205 -0
  121. package/server-managed/release-commits.ts +84 -0
  122. package/server-managed/repository-actions.ts +209 -0
  123. package/server-managed/repository-agents.ts +220 -0
  124. package/server-managed/repository-browser.ts +54 -13
  125. package/server-managed/repository-changes.ts +284 -0
  126. package/server-managed/repository-code.ts +52 -0
  127. package/server-managed/repository-dependency-reviews.ts +135 -0
  128. package/server-managed/repository-history.ts +445 -0
  129. package/server-managed/repository-issues.ts +181 -0
  130. package/server-managed/repository-pulse.ts +432 -0
  131. package/server-managed/repository-risk-read.ts +165 -0
  132. package/server-managed/repository-risk.ts +113 -105
  133. package/server-managed/reviewed-dependencies.ts +149 -0
  134. package/server-managed/session.ts +9 -5
  135. package/server-managed/sql.ts +1 -1
  136. package/server-managed/storage-stores.ts +5 -0
  137. package/server-managed/team-app.ts +19 -3
  138. package/server-managed/workspace-shares.ts +4 -1
  139. package/server.ts +11 -0
  140. package/out/chunk-2ABLKCOY.js +0 -46
  141. package/out/chunk-2KBF5TZ7.js +0 -2
  142. package/out/chunk-35VE6CDH.js +0 -2
  143. package/out/chunk-3BZ7JERB.js +0 -1
  144. package/out/chunk-4XCDFGDP.js +0 -10
  145. package/out/chunk-5LSXOLUS.js +0 -1
  146. package/out/chunk-5RCM2J63.js +0 -1
  147. package/out/chunk-7B6HB4D7.js +0 -1
  148. package/out/chunk-AJ4GTATZ.js +0 -3
  149. package/out/chunk-CH4UN6WK.js +0 -225
  150. package/out/chunk-CMQA4DM6.js +0 -1
  151. package/out/chunk-CVVJAXVY.js +0 -2
  152. package/out/chunk-DIJJNTRH.js +0 -10
  153. package/out/chunk-EDKLBQQN.js +0 -17
  154. package/out/chunk-F47DXCEV.js +0 -33
  155. package/out/chunk-FK3DZEKL.js +0 -1
  156. package/out/chunk-G6AJWENO.js +0 -1
  157. package/out/chunk-GJ4IUCNS.js +0 -1
  158. package/out/chunk-GNXOKIGP.js +0 -80
  159. package/out/chunk-GZ4MS3TE.js +0 -13
  160. package/out/chunk-GZWM7UQM.js +0 -2
  161. package/out/chunk-IU3CCINR.js +0 -3
  162. package/out/chunk-K3X5EJBQ.js +0 -2
  163. package/out/chunk-KEX2ATEW.js +0 -2
  164. package/out/chunk-KYCNJVB7.js +0 -1
  165. package/out/chunk-LPCTJYNA.js +0 -7
  166. package/out/chunk-O4RJY5TP.js +0 -2
  167. package/out/chunk-OD4LPN3H.js +0 -1
  168. package/out/chunk-QWDWTBM7.js +0 -1
  169. package/out/chunk-QYWKC557.js +0 -1
  170. package/out/chunk-R75XOKUP.js +0 -1
  171. package/out/chunk-SWTLADW5.js +0 -1
  172. package/out/chunk-T7OKH4VW.js +0 -1
  173. package/out/chunk-TZ5KB2Y5.js +0 -10
  174. package/out/chunk-UNTRGE26.js +0 -2
  175. package/out/chunk-UTR2LVME.js +0 -2
  176. package/out/chunk-UYAXVRVL.js +0 -1
  177. package/out/chunk-V3ME6FI5.js +0 -2
  178. package/out/chunk-V5O7M2YT.js +0 -1
  179. package/out/chunk-VIHGO77Y.js +0 -1
  180. package/out/chunk-W4J3MGLB.js +0 -1
  181. package/out/chunk-WGG7KQUH.js +0 -1
  182. package/out/chunk-XYFXXB2G.js +0 -10
  183. package/out/chunk-ZESLQCTF.js +0 -6
@@ -0,0 +1,182 @@
1
+ // What a `run:` script fetches and runs past what a lockfile or a digest pins,
2
+ // command by command (shell.js), each with its line in the script from 0.
3
+ import { NPM_INSTALLS, fetched, npmToolFinds, optionsOf, projectOf, valuesOf } from './packages.js'
4
+ import { pathFrom, scriptCommands, scriptLines } from './shell.js'
5
+ import { SHELLS as SHELL_NAMES } from '../../files/runs-code.js'
6
+ import { readImage } from '../uses.js'
7
+
8
+ // A program's file name, lowercase, past its directory and a Windows extension.
9
+ const programOf = word => (word ?? '').split(/[/\\]/u).at(-1).toLowerCase().replace(/\.(?:cmd|exe|bat|ps1)$|@[\w.-]*$/u, '')
10
+
11
+ const SHELLS = new RegExp(String.raw`^(?:${SHELL_NAMES}|python[\d.]*|node|perl|ruby|php|iex|invoke-expression)$`, 'u')
12
+ const FETCHERS = /^(?:curl|wget|irm|iwr|invoke-restmethod|invoke-webrequest)$/u
13
+ // A script fetched and run on one line: `<(curl …)`, `-c "$(curl …)"`, `iex (irm …)`.
14
+ const FETCHED_AND_RUN = [
15
+ /\b(?:(?:ba|z|da|k)?sh|source|\.)\s+(?:-\S+\s+)*<\(\s*(?:curl|wget)\b/iu,
16
+ /\b(?:(?:ba|z|da|k)?sh|python[\d.]*|node|perl|ruby)\s+(?:-\S+\s+)*-c\s+["']?\$\(\s*(?:curl|wget)\b/iu,
17
+ /\b(?:iex|invoke-expression)\b[^|;]*\b(?:irm|iwr|invoke-restmethod|invoke-webrequest|downloadstring)\b/iu,
18
+ ]
19
+
20
+ // The file curl or wget saves to (`-o f`, `-of`, `--output f`, curl's `-O`,
21
+ // wget's by its URL), or PowerShell's (`-OutFile f`); else null.
22
+ function savedTo(program, argv) {
23
+ for (let at = 1; at < argv.length; at++) {
24
+ const outFile = /^-outfile(?::(.+))?$/iu.exec(argv[at]), word = argv[at]
25
+ if (outFile && !/^(?:curl|wget)$/u.test(program)) return outFile[1] ?? argv[at + 1] ?? null
26
+ const attached = { curl: /^-[a-zA-Z]*o(.+)$/u, wget: /^-[a-zA-Z]*O(.+)$/u }[program]?.exec(word)
27
+ if (attached) return attached[1] === '-' ? null : attached[1]
28
+ if (program === 'curl' && (/^-[a-zA-Z]*o$/u.test(word) || word === '--output')) return argv[at + 1] ?? null
29
+ if (program === 'wget' && (/^-[a-zA-Z]*O$/u.test(word) || word === '--output-document')) return argv[at + 1] === '-' ? null : argv[at + 1] ?? null
30
+ const joined = /^(?:--output|--output-document)=(.+)$/u.exec(word)
31
+ if (joined) return joined[1]
32
+ }
33
+ const url = program === 'wget' || (program === 'curl' && argv.some(word => /^-[a-zA-Z]*O[a-zA-Z]*$/u.test(word) || word === '--remote-name')) ? argv.find(word => /^https?:\/\//u.test(word)) : null
34
+ return url ? URL.parse(url)?.pathname.split('/').at(-1) || null : null
35
+ }
36
+
37
+ // A path from the current directory, its `./` (PowerShell's `.\`, its `\`
38
+ // read as an escape) taken off.
39
+ const local = path => path.replace(/^\.\/?/u, '')
40
+
41
+ // Whether a command runs `file`, as a program or a shell's script.
42
+ function runsFile({ argv }, file) {
43
+ if (local(argv[0]) === local(file) || argv[0] === file) return true
44
+ return (SHELLS.test(programOf(argv[0])) || argv[0] === 'source' || argv[0] === '.') && argv.slice(1).some(word => local(word) === local(file))
45
+ }
46
+
47
+ const pythonPackage = text => {
48
+ if (/^(?:git\+|https?:|file:|\.{0,2}\/)/iu.test(text)) return { name: text, spec: null, exact: /@[\da-f]{40}(?:#|$)/u.test(text), registry: false }
49
+ const m = /^([\w.-]+)(?:\[[^\]]*\])?\s*(.*)$/u.exec(text)
50
+ return m && { name: m[1], spec: m[2] || null, exact: /^===?\s*[\w.!+-]+$/u.test(m[2]), registry: true }
51
+ }
52
+
53
+ // The packages pip installs by name, not a requirements file's or a path.
54
+ const PIP_VALUED = /^(?:-r|--requirement|-c|--constraint|-e|--editable|-i|--index-url|--extra-index-url|-t|--target|--prefix|--root|-f|--find-links)$/u
55
+ const pip = (tool, argv, from) => optionsOf(argv, from, PIP_VALUED).operands.filter(word => !/^\.{1,2}(?:\/|$)/u.test(word))
56
+ .flatMap(text => fetched(tool, pythonPackage(text), { ecosystem: 'pypi' }))
57
+ // A package named `name@version`, as Go's, crates' and Deno's are.
58
+ const atVersion = (text, exact) => {
59
+ const at = text.lastIndexOf('@')
60
+ const spec = at > 0 ? text.slice(at + 1) : null
61
+ return { name: at > 0 ? text.slice(0, at) : text, spec, exact: spec !== null && exact(spec), registry: true }
62
+ }
63
+
64
+ function python(program, argv) {
65
+ if (program === 'pip' || /^pip\d/u.test(program)) return argv[1] === 'install' ? pip(program, argv, 2) : []
66
+ if (/^python[\d.]*$/u.test(program) && argv[1] === '-m' && /^pip\d?$/u.test(argv[2] ?? '') && argv[3] === 'install') return pip(`${program} -m pip`, argv, 4)
67
+ if (program === 'uv' && argv[1] === 'pip' && argv[2] === 'install') return pip('uv pip', argv, 3)
68
+ if (program === 'uvx' || (program === 'uv' && argv[1] === 'tool' && /^(?:run|install)$/u.test(argv[2] ?? ''))) {
69
+ const options = optionsOf(argv, program === 'uvx' ? 1 : 3, /^--(?:from|python|with)$/u), [spec] = [...valuesOf(options, '--from'), ...options.operands]
70
+ return fetched(program === 'uvx' ? 'uvx' : `uv tool ${argv[2]}`, spec && pythonPackage(spec), { ecosystem: 'pypi' })
71
+ }
72
+ if (program === 'pipx' && /^(?:run|install)$/u.test(argv[1] ?? '')) {
73
+ const options = optionsOf(argv, 2, /^--(?:spec|python)$/u), [spec] = [...valuesOf(options, '--spec'), ...options.operands]
74
+ return fetched(`pipx ${argv[1]}`, spec && pythonPackage(spec), { ecosystem: 'pypi' })
75
+ }
76
+ return []
77
+ }
78
+
79
+
80
+ const EXACT_SEMVER = text => /^=?\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(text)
81
+
82
+ // `go install x@v y@v`, `go run x@v`, a version held to Go's checksum database.
83
+ function go(argv) {
84
+ const { operands } = optionsOf(argv, 2, /^-(?:o|modfile|tags|ldflags|gcflags|C|exec)$/u)
85
+ return (argv[1] === 'run' ? operands.slice(0, 1) : operands).filter(target => target.includes('@'))
86
+ .flatMap(target => fetched(`go ${argv[1]}`, atVersion(target, spec => /^v\d+\.\d+\.\d+/u.test(spec)), { ecosystem: 'go' }))
87
+ }
88
+
89
+ // `cargo install`: each crate by its version, or a repository by its commit;
90
+ // `locked` with `--locked`.
91
+ function cargo(argv) {
92
+ if (argv.includes('--path')) return []
93
+ const options = optionsOf(argv, 2, /^--(?:version|git|branch|rev|tag|root|features|registry|index|bin|example|target|profile|jobs)$|^-[jF]$/u)
94
+ const [git = null] = valuesOf(options, '--git'), [version = null] = valuesOf(options, '--version'), [ref = null] = valuesOf(options, '--rev', '--tag', '--branch')
95
+ const specs = git ? [{ name: git, spec: ref, exact: /^[\da-f]{40}$/u.test(valuesOf(options, '--rev')[0] ?? ''), registry: false }] : options.operands.map(crate => atVersion(crate, EXACT_SEMVER))
96
+ return specs.flatMap(spec => fetched(`cargo ${argv[1]}`, { ...spec, spec: spec.spec ?? version, exact: spec.exact || (!git && EXACT_SEMVER(version ?? '')), locked: argv.includes('--locked') }, { ecosystem: 'crates' }))
97
+ }
98
+
99
+ // `gem install x [-v 1.2.3]`.
100
+ function gem(argv) {
101
+ const options = optionsOf(argv, 2, /^(?:-v|--version|-i|--install-dir|-s|--source|-n|--bindir)$/u), [version = null] = valuesOf(options, '-v', '--version')
102
+ return options.operands.flatMap(name => fetched('gem install', { name, spec: version, exact: /^=?\s*\d+(?:\.\d+)+$/u.test(version ?? ''), registry: true }, { ecosystem: 'rubygems' }))
103
+ }
104
+
105
+ // `deno run|install|x` of a URL or a registry's package.
106
+ function deno(argv) {
107
+ const target = argv.slice(2).find(word => /^(?:https?:\/\/|npm:|jsr:)/u.test(word))
108
+ if (!target) return []
109
+ return fetched(`deno ${argv[1]}`, /^(?:npm|jsr):/u.test(target) ? atVersion(target.replace(/^(?:npm|jsr):/u, ''), EXACT_SEMVER)
110
+ : { name: target, spec: null, exact: /@v?\d+\.\d+\.\d+/u.test(target), registry: false }, { ecosystem: 'deno' })
111
+ }
112
+
113
+ function others(program, argv) {
114
+ if (program === 'go' && /^(?:install|run)$/u.test(argv[1] ?? '')) return go(argv)
115
+ if (program === 'cargo' && /^(?:install|binstall)$/u.test(argv[1] ?? '')) return cargo(argv)
116
+ if (program === 'gem' && argv[1] === 'install') return gem(argv)
117
+ return program === 'deno' && /^(?:run|install|x|compile)$/u.test(argv[1] ?? '') ? deno(argv) : []
118
+ }
119
+
120
+ // What a command fetches or runs unpinned, but a script fetched and run.
121
+ function commandFinds(command) {
122
+ const { argv } = command
123
+ const program = programOf(argv[0])
124
+ if (/^(?:docker|podman)$/u.test(program) && /^(?:run|pull|create)$/u.test(argv[1] ?? '')) {
125
+ const image = argv.slice(2).find((word, at, words) => !word.startsWith('-') && !/^(?:-[evpwulhm]|--(?:env|volume|publish|workdir|user|label|hostname|memory|name|network|entrypoint|platform|mount|env-file|cpus|add-host|cap-add|device|dns|gpus|ipc|pid|restart|runtime|pull|security-opt|shm-size|tmpfs|ulimit|volumes-from|log-driver))$/u.test(words[at - 1] ?? ''))
126
+ return image ? [{ kind: 'image', text: image, read: readImage(image) }] : []
127
+ }
128
+ if (program === 'git' && argv[1] === 'clone') return [{ kind: 'clone', url: argv.slice(2).find(word => /^(?:https?:|git@|git:|ssh:)/u.test(word)) ?? null }]
129
+ return [...npmToolFinds(program, argv), ...python(program, argv), ...others(program, argv)]
130
+ }
131
+
132
+ const INSTALLS = { npm: new RegExp(`^(?:ci|clean-install|ic|install-clean|${NPM_INSTALLS})$`, 'u'), pnpm: /^(?:i|install|add)$/u, bun: /^(?:i|install|add)$/u }
133
+ // An install's options that leave the project's node_modules as they are.
134
+ const NOT_LOCAL = /^(?:-g|--location=global|--(?:global|package-lock-only|lockfile-only|dry-run)(?:=true)?)$/u
135
+
136
+ // A script's installs of a project's packages: each one's `order`, the
137
+ // directory it installs into, by its `cd`s and the one it names (`into`, a
138
+ // path from the step's, '' for it), and whether it leaves devDependencies out
139
+ // (`prod`), by an option or NODE_ENV production (the step's `production`).
140
+ export function installsOf(script, production = false) {
141
+ const found = []
142
+ let exported = production
143
+ for (const { argv, dir, env, order } of scriptCommands(script)) {
144
+ const program = programOf(argv[0])
145
+ if (program === 'export') for (const word of argv) if (word.startsWith('NODE_ENV=')) exported = word === 'NODE_ENV=production'
146
+ const options = optionsOf(argv, 1), [command] = options.operands
147
+ if (argv.some(word => NOT_LOCAL.test(word)) || !(program === 'yarn' ? command === undefined || command === 'install' : INSTALLS[program]?.test(command ?? ''))) continue
148
+ // npm installs what `--include` names whatever leaves it out.
149
+ const prod = !valuesOf(options, '--include').includes('dev') && (valuesOf(options, '--omit').includes('dev') || /^prod(?:uction)?$/u.test(valuesOf(options, '--only')[0] ?? '')
150
+ || argv.some(word => /^--prod(?:uction)?$/u.test(word)) || (env.NODE_ENV ?? (exported ? 'production' : '')) === 'production')
151
+ const project = projectOf(argv)
152
+ found.push({ order, into: project === null ? dir : pathFrom(dir, project), prod })
153
+ }
154
+ return found
155
+ }
156
+
157
+ // A script's line, as a finding names what it runs, cut short.
158
+ const excerpt = text => { const trimmed = text.trim().replaceAll(/\s+/gu, ' '); return trimmed.length > 120 ? `${trimmed.slice(0, 119)}…` : trimmed }
159
+
160
+ // Everything a script fetches and runs past a pin, in its order.
161
+ export function scriptFinds(script) {
162
+ const found = []
163
+ const lines = new Map(scriptLines(script).map(({ text, line }) => [line, text]))
164
+ for (const [line, text] of lines) {
165
+ if (FETCHED_AND_RUN.some(pattern => pattern.test(text))) found.push({ kind: 'remote-script', line, command: excerpt(text) })
166
+ }
167
+ const saved = new Map()
168
+ for (const command of scriptCommands(script)) {
169
+ const program = programOf(command.argv[0])
170
+ if (FETCHERS.test(program) && command.pipedTo.some(each => SHELLS.test(programOf(each.argv[0])))) found.push({ kind: 'remote-script', line: command.line, command: excerpt(lines.get(command.line) ?? '') })
171
+ const file = FETCHERS.test(program) ? savedTo(program, command.argv) ?? command.out : null
172
+ if (file) saved.set(file, command.line)
173
+ for (const [name, at] of saved) {
174
+ if (FETCHERS.test(program) || !runsFile(command, name)) continue
175
+ found.push({ kind: 'remote-script', line: command.line, command: excerpt(lines.get(at) ?? '') })
176
+ saved.delete(name)
177
+ }
178
+ for (const each of commandFinds(command)) found.push({ ...each, line: command.line, order: command.order, dir: command.dir })
179
+ }
180
+ const told = new Set()
181
+ return found.filter(({ order: _order, ...each }) => { const key = JSON.stringify(each); return !told.has(key) && told.add(key) }).toSorted((a, b) => a.line - b.line)
182
+ }
@@ -0,0 +1,113 @@
1
+ // What a `run:` script fetches and runs (commands.js), graded (README.md).
2
+ import { HOST_TAGS, npmExfiltration, npmHostTags } from '../../files/hosts.js'
3
+ import { lineOf } from '../../files/script.js'
4
+ import { installsOf, scriptFinds } from './commands.js'
5
+
6
+ // The events a workflow runs on with its secrets for anyone's pull request.
7
+ const PRIVILEGED_TRIGGERS = new Set(['pull_request_target', 'workflow_run', 'issue_comment', 'issues', 'discussion', 'discussion_comment',
8
+ 'pull_request_review', 'pull_request_review_comment', 'fork', 'watch'])
9
+
10
+ // The expressions anyone can write: a title, a body, a branch's name, a
11
+ // commit's message, a fork's description.
12
+ const INDEX = String.raw`(?:\s*\[[^\]]*\]|\.\*)`
13
+ const AUTHOR = String.raw`(?:author|committer)\.(?:email|name)`
14
+ const UNTRUSTED = new RegExp(String.raw`\bgithub\s*\.\s*(?:head_ref|event\s*\.\s*(?:`
15
+ + [String.raw`issue\.(?:title|body)`,
16
+ String.raw`pull_request\.(?:title|body|head\.(?:ref|label|repo\.(?:default_branch|description|homepage)))`,
17
+ String.raw`(?:comment|review|review_comment)\.body`,
18
+ String.raw`pages${INDEX}\.page_name`,
19
+ String.raw`commits${INDEX}\.(?:message|${AUTHOR})`,
20
+ String.raw`head_commit\.(?:message|${AUTHOR})`,
21
+ String.raw`workflow_run\.(?:head_branch|display_title|head_commit\.(?:message|${AUTHOR})|head_repository\.(?:description|homepage|default_branch)|pull_requests${INDEX}\.head\.ref)`,
22
+ String.raw`discussion\.(?:title|body)`].join('|')
23
+ + String.raw`))(?![\w-])`, 'gu')
24
+
25
+ // An expression with its index syntax's properties (`github['event']`) as
26
+ // dereferences, as GitHub reads either.
27
+ export const dotted = text => text.replaceAll(/\[\s*'([\w-]+)'\s*\]/gu, '.$1')
28
+
29
+ // Each expression anyone can write in a script's `text`, a finding with
30
+ // the `index` its `${{` is at: red where the workflow runs with its secrets
31
+ // for anyone (`triggers`), else orange.
32
+ export function scriptInjections(text, triggers) {
33
+ if (typeof text !== 'string' || !text.includes('${{')) return []
34
+ const severity = triggers.some(event => PRIVILEGED_TRIGGERS.has(event)) ? 'critical' : 'high'
35
+ return [...text.matchAll(/\$\{\{([\s\S]*?)\}\}/gu)].flatMap(({ 1: inner, index }) =>
36
+ [...dotted(inner).matchAll(UNTRUSTED)].map(({ 0: expression }) => ({ id: 'script-injection', severity, expression: expression.replaceAll(/\s+/gu, ''), index })))
37
+ }
38
+
39
+ // A ref or a repository naming a pull request's own code, as
40
+ // `actions/checkout` checks it out: its head's commit or branch (a
41
+ // `workflow_run`'s pull requests' too), or the fork's repository, whose
42
+ // default branch it is then.
43
+ const PULL_HEAD = new RegExp(String.raw`\bgithub\.(?:head_ref|event\.(?:number|pull_request\.(?:number|merge_commit_sha|head\.(?:sha|ref|repo\.(?:full_name|owner\.login)))|workflow_run\.(?:head_sha|head_branch|head_commit\.id|pull_requests\[[^\]]*\]\.head\.(?:sha|ref)|head_repository\.(?:full_name|owner\.login))))\b`
44
+ .replaceAll('\\.', String.raw`\s*\.\s*`) + String.raw`|refs\/pull\/`, 'u')
45
+ export const namesPullHead = text => typeof text === 'string' && PULL_HEAD.test(dotted(text))
46
+
47
+ // Programs whose package is named otherwise: `npx tsc` runs typescript's.
48
+ const KNOWN_BINS = new Map([['tsc', 'typescript'], ['tsserver', 'typescript'], ['ng', '@angular/cli'], ['nest', '@nestjs/cli'],
49
+ ['changeset', '@changesets/cli'], ['biome', '@biomejs/biome'], ['svelte-kit', '@sveltejs/kit'], ['api-extractor', '@microsoft/api-extractor'],
50
+ ['commitlint', '@commitlint/cli'], ['attw', '@arethetypeswrong/cli'], ['ncu', 'npm-check-updates'], ['vue-cli-service', '@vue/cli-service'],
51
+ ['firebase', 'firebase-tools'], ['netlify', 'netlify-cli'], ['cdk', 'aws-cdk'], ['sls', 'serverless'], ['playwright', '@playwright/test'],
52
+ ['eas', 'eas-cli'], ['expo', 'expo'], ['vsce', '@vscode/vsce'], ['wdio', '@wdio/cli'], ['lhci', '@lhci/cli'], ['tsx', 'tsx'], ['webpack', 'webpack-cli']])
53
+ const PACKAGING = new Set(['pip', 'setuptools', 'wheel'])
54
+
55
+ // Whether `locked` (a Set) has what npx runs by `name`, or the package giving it.
56
+ export const lockedName = (locked, name) => locked.has(name) || (KNOWN_BINS.has(name) && locked.has(KNOWN_BINS.get(name)))
57
+
58
+ // How severe fetching a package is; null for none.
59
+ function packageSeverity(found, { locked, installed, external }) {
60
+ const runsOwn = found.ecosystem === 'npm' && found.localFirst && found.registry && (found.spec === null || found.exact)
61
+ if (runsOwn && (external ? installed : locked && installed)) return null
62
+ if (found.ecosystem === 'go' && found.exact) return null
63
+ if (found.ecosystem === 'crates' && found.exact && found.locked) return null
64
+ if (found.ecosystem === 'pypi' && PACKAGING.has(found.name.toLowerCase())) return 'notice'
65
+ // A version a variable names may be one, set elsewhere in the workflow.
66
+ return found.exact || found.spec?.includes('$') ? 'moderate' : 'high'
67
+ }
68
+
69
+ // The findings of one find of a script, `locked` what a lockfile says of it.
70
+ function graded(found, context, locked) {
71
+ if (found.kind === 'package') {
72
+ const { tool, name, spec, localFirst } = found, installed = context.installed
73
+ const severity = packageSeverity(found, { locked, installed, external: context.external })
74
+ return severity ? [{ id: 'fetched-package', severity, tool, name, spec, ...localFirst ? { localFirst, locked, installed } : {} }] : []
75
+ }
76
+ if (found.kind === 'unlocked-install') return [{ id: 'unlocked-install', severity: /^(?:yarn|bun)\b/u.test(found.tool) ? 'low' : 'moderate', tool: found.tool }]
77
+ if (found.kind === 'remote-script') return [{ id: 'remote-script', severity: 'high', command: found.command }]
78
+ if (found.kind === 'image') return found.read?.digest ? [] : [imageFinding(found.text, found.read)]
79
+ return found.kind === 'clone' ? [{ id: 'clone', severity: 'low', url: found.url }] : []
80
+ }
81
+
82
+ // An image run by a tag: orange with none or `latest`, else yellow.
83
+ export const imageFinding = (text, read) => ({ id: 'unpinned-image', severity: !read || read.tag === null || read.tag === 'latest' ? 'high' : 'moderate', image: text })
84
+
85
+ // The file's line a script's line is on: a `|` block scalar's own, else `run:`'s.
86
+ const fileLine = (run, line) => run.start === null ? run.line : run.start + line
87
+
88
+ // Whether `dir` is `directory` or above it, as node finds a package there;
89
+ // null for anywhere.
90
+ export const within = (dir, directory) => dir === null || dir === '' || directory === dir || directory.startsWith(`${dir}/`)
91
+
92
+ // Every finding of a `run` script (workflow.js runOf); `context`'s
93
+ // `locked(name, dir)` whether a lockfile there locks it (`dev` where
94
+ // devDependencies alone need it), `installed(dev, dir)` whether an install
95
+ // ran before that installs such a package there, `dir` a path from the
96
+ // step's directory, as a `cd` moves to.
97
+ export function scriptFindings(run, context) {
98
+ const findings = []
99
+ // An install earlier in the script counts as one in a step before.
100
+ const installs = installsOf(run.script, run.production)
101
+ for (const found of scriptFinds(run.script)) {
102
+ const dir = found.dir ?? ''
103
+ const locked = found.kind === 'package' && found.ecosystem === 'npm' && found.localFirst && context.locked(found.exact ? `${found.name}@${found.spec.replace(/^[v=]/u, '')}` : found.name, dir)
104
+ const before = context.installed(locked === 'dev', dir) || installs.some(({ order, into, prod }) => order < found.order && within(into, dir) && !(prod && locked === 'dev'))
105
+ for (const finding of graded(found, { ...context, installed: before }, Boolean(locked))) findings.push({ ...finding, line: fileLine(run, found.line) })
106
+ }
107
+ for (const [tag, services] of npmHostTags(npmExfiltration('run.sh', run.script, true))) {
108
+ findings.push({ id: 'hosts', severity: HOST_TAGS.get(tag), tag, services: [...services.keys()], line: fileLine(run, Math.min(...[...services.values()].flat()) - 1) })
109
+ }
110
+ const line = lineOf(run.script)
111
+ for (const { index, ...finding } of scriptInjections(run.script, context.triggers)) findings.push({ ...finding, line: fileLine(run, line(index) - 1) })
112
+ return findings
113
+ }
@@ -0,0 +1,123 @@
1
+ // What a command of npm's tools fetches and runs past what a lockfile locks
2
+ // (commands.js), npx's and its kin's the project's own where installed
3
+ // (`localFirst`), and an install that resolves anew (`unlocked`).
4
+ import { isExactVersion } from '@preventive/upstream/semver.js'
5
+
6
+ // A package spec's name and spec, `exact` for a version, `registry: false` for
7
+ // a URL, a path, git or a GitHub shorthand.
8
+ export function npmSpec(text) {
9
+ if (/^(?:\.{0,2}\/|~|[a-z][\w+.-]*:|[\w.-]+\/[\w.-]+(?:#.*)?$)/iu.test(text) && !/^@[\w.-]+\//u.test(text)) {
10
+ return { name: text, spec: null, exact: false, registry: false }
11
+ }
12
+ const at = text.indexOf('@', text.startsWith('@') ? 1 : 0)
13
+ const name = at === -1 ? text : text.slice(0, at)
14
+ const spec = at === -1 ? null : text.slice(at + 1)
15
+ if (!/^(?:@[\w.-]+\/)?[\w.-]+$/u.test(name)) return null
16
+ return { name, spec, exact: spec !== null && isExactVersion(spec.replace(/^[v=]/u, '')), registry: true }
17
+ }
18
+
19
+ // A package a tool fetches, as a script's find; none for none.
20
+ export const fetched = (tool, spec, { ecosystem = 'npm', localFirst = false, ...more } = {}) => spec ? [{ kind: 'package', tool, ecosystem, localFirst, ...more, ...spec }] : []
21
+
22
+ // What `npm create x` runs: `create-x`, `@s/create` for `@s`, `@s/create-x`.
23
+ function createPackage(text) {
24
+ const scoped = /^(@[\w.-]+)(?:@(.+))?$/u.exec(text)
25
+ if (scoped) return npmSpec(`${scoped[1]}/create${scoped[2] ? `@${scoped[2]}` : ''}`)
26
+ const spec = npmSpec(text)
27
+ if (!spec?.registry) return spec
28
+ const [scope, name] = spec.name.startsWith('@') ? spec.name.split('/') : [null, spec.name]
29
+ const named = scope ? (name ? `${scope}/create-${name}` : `${scope}/create`) : `create-${name}`
30
+ return { ...spec, name: named }
31
+ }
32
+
33
+ // Options of npm's tools that take a value.
34
+ const VALUED = /^(?:-[pcwC]|--(?:package|call|cache|userconfig|registry|workspace|prefix|filter|dir|global-dir|store-dir|loglevel|cwd|spec|location|include|omit|only))$/u
35
+ const GLOBAL = /^(?:-g|--global|--location=global)$/u
36
+
37
+ // A command's words past `from`: those not options (`operands`), all past
38
+ // `--` too, and each option's values (`values`), of `--x=v`, and `--x v` for
39
+ // one `valued` names.
40
+ export function optionsOf(argv, from, valued = VALUED) {
41
+ const operands = [], values = new Map()
42
+ for (let at = from; at < argv.length; at++) {
43
+ const [, name, joined] = /^(--[\w-]+)=(.*)$/u.exec(argv[at]) ?? [], word = argv[at]
44
+ if (word === '--') return { operands: [...operands, ...argv.slice(at + 1)], values }
45
+ if (name || valued.test(word)) values.set(name ?? word, [...values.get(name ?? word) ?? [], joined ?? argv[++at] ?? ''])
46
+ else if (!word.startsWith('-')) operands.push(word)
47
+ }
48
+ return { operands, values }
49
+ }
50
+ const positional = (argv, from) => optionsOf(argv, from).operands
51
+ // The values of `names`' options, in their order.
52
+ export const valuesOf = ({ values }, ...names) => names.flatMap(name => values.get(name) ?? [])
53
+
54
+ // What an exec (npx, `npm exec`, bunx, dlx) fetches: its `--package`s, else
55
+ // its command's; none with `--no`, `--offline`, or a command line (`-c`).
56
+ function execPackages(tool, argv, from, localFirst) {
57
+ if (argv.some(word => /^(?:--no|--no-install|--offline)$/u.test(word))) return []
58
+ const named = valuesOf(optionsOf(argv, 1), '-p', '--package').filter(Boolean)
59
+ if (named.length === 0 && argv.some(word => word === '-c' || word === '--call')) return []
60
+ return (named.length > 0 ? named : positional(argv, from).slice(0, 1)).flatMap(text => fetched(tool, npmSpec(text), { localFirst }))
61
+ }
62
+
63
+ // The directory an install names for its project (`--prefix`, `-C`, `--dir`,
64
+ // `--cwd`); null for the one it runs in, `.` too.
65
+ export function projectOf(argv) {
66
+ const [dir = null] = valuesOf(optionsOf(argv, 1), '-C', '--prefix', '--dir', '--cwd')
67
+ return dir === null || /^\.\/?$/u.test(dir) ? null : dir
68
+ }
69
+
70
+ const installs = (tool, words) => words.flatMap(text => fetched(tool, npmSpec(text)))
71
+ // npm's install command and its aliases (commands.js installsOf).
72
+ export const NPM_INSTALLS = 'i|in|ins|inst|insta|instal|install|isnt|isnta|isntal|isntall|add|it|install-test'
73
+ const created = (tool, text) => text ? fetched(tool, createPackage(text)) : []
74
+ const unlocked = tool => [{ kind: 'unlocked-install', tool }]
75
+ const isInstalled = word => /^(?:\.{1,2}(?:\/|$)|file:|link:|workspace:)/u.test(word)
76
+
77
+ function npm(argv) {
78
+ const [command = '', ...rest] = positional(argv, 1)
79
+ if (command === 'exec' || command === 'x') return execPackages(`npm ${command}`, argv, argv.indexOf(command) + 1, true)
80
+ if (/^(?:create|init|innit)$/u.test(command)) return created(`npm ${command}`, rest[0])
81
+ if (new RegExp(`^(?:${NPM_INSTALLS})$`, 'u').test(command)) {
82
+ const named = rest.filter(word => !isInstalled(word))
83
+ if (argv.some(word => GLOBAL.test(word))) return installs(`npm ${command} -g`, named)
84
+ return named.length > 0 ? installs(`npm ${command}`, named) : unlocked(`npm ${command}`)
85
+ }
86
+ return /^(?:update|up|upgrade|udpate)$/u.test(command) ? unlocked(`npm ${command}`) : []
87
+ }
88
+
89
+ // pnpm's and bun's, by the word each runs a package with (`exec`), what an
90
+ // install resolving anew is told by (`anew`, null for none), and its updates.
91
+ const pnpmLike = (program, exec, localFirst, anew, updates) => argv => {
92
+ const [command = '', ...rest] = positional(argv, 1)
93
+ if (command === exec) return execPackages(`${program} ${exec}`, argv, argv.indexOf(exec) + 1, localFirst)
94
+ if (command === 'create') return created(`${program} create`, rest[0])
95
+ const global = argv.some(word => GLOBAL.test(word)), installing = command === 'install' || command === 'i'
96
+ if (command === 'add' || (installing && rest.length > 0)) return installs(`${program} ${command}${global ? ' -g' : ''}`, rest.filter(word => !isInstalled(word)))
97
+ if (installing && anew(argv) !== null) return unlocked(`${program} ${command}${anew(argv)}`)
98
+ return updates.test(command) ? unlocked(`${program} ${command}`) : []
99
+ }
100
+ const pnpm = pnpmLike('pnpm', 'dlx', false, argv => argv.includes('--no-frozen-lockfile') ? ' --no-frozen-lockfile' : null, /^(?:update|up|upgrade)$/u)
101
+ const bun = pnpmLike('bun', 'x', true, argv => argv.some(word => word === '--frozen-lockfile' || word === '--ci') ? null : '', /^update$/u)
102
+
103
+ function yarn(argv) {
104
+ const [command = '', ...rest] = positional(argv, 1)
105
+ if (command === 'dlx') return execPackages('yarn dlx', argv, argv.indexOf('dlx') + 1, false)
106
+ if (command === 'create') return created('yarn create', rest[0])
107
+ if (command === 'global' && rest[0] === 'add') return installs('yarn global add', rest.slice(1))
108
+ if (command === 'add') return installs('yarn add', rest.filter(word => !isInstalled(word)))
109
+ const frozen = argv.some(word => /^--(?:frozen-lockfile|immutable)$/u.test(word))
110
+ if ((command === '' || command === 'install') && !frozen) return unlocked(command ? 'yarn install' : 'yarn')
111
+ return /^(?:upgrade|up)$/u.test(command) ? unlocked(`yarn ${command}`) : []
112
+ }
113
+
114
+ // The tools that run a package (whether one installed first), and the rest.
115
+ const EXECS = new Map([['npx', true], ['bunx', true], ['pnpx', false]])
116
+ const TOOLS = new Map([['npm', npm], ['pnpm', pnpm], ['yarn', yarn], ['bun', bun]])
117
+
118
+ // What a command of npm's tools fetches, by its program's name: packages and
119
+ // installs resolving anew.
120
+ export function npmToolFinds(program, argv) {
121
+ if (EXECS.has(program)) return execPackages(program, argv, 1, EXECS.get(program))
122
+ return TOOLS.get(program)?.(argv) ?? []
123
+ }
@@ -0,0 +1,165 @@
1
+ // A `run:` script read into the commands it runs, as a shell would split
2
+ // them, for commands.js. A quick read, not a shell's parse: a command built
3
+ // as the script runs goes unseen.
4
+ import { SHELLS } from '../../files/runs-code.js'
5
+ import { resolved } from '../../npm/files.js'
6
+
7
+ // A shell, by its program, wherever it is, Windows' `.exe` too (risk's
8
+ // runs-code.js SHELLS).
9
+ const SHELL = new RegExp(String.raw`^(?:.*[\/\\])?${SHELLS}(?:\.exe)?$`, 'iu')
10
+
11
+ // A line of a script, its continuations joined (an escaped line's end, or a
12
+ // pipe, `&&` or `||` the next line goes on from), and the line it starts
13
+ // on, counting from 0.
14
+ export function scriptLines(script) {
15
+ const lines = script.split('\n')
16
+ const joined = []
17
+ for (let at = 0; at < lines.length; at++) {
18
+ const start = at
19
+ let text = lines[at].replace(/\r$/u, '')
20
+ for (;;) {
21
+ const escaped = /(?:^|[^\\])(?:\\\\)*[\\`^]$/u.test(text), piped = /(?:\||&&)\s*$/u.test(text) && !/(?:^|\s)#/u.test(text)
22
+ if (!(escaped || piped) || at + 1 === lines.length) break
23
+ text = `${escaped ? text.slice(0, -1) : text} ${lines[++at].replace(/\r$/u, '')}`
24
+ }
25
+ joined.push({ text, line: start })
26
+ }
27
+ return joined
28
+ }
29
+
30
+ const WRAPPERS = new Set(['sudo', 'env', 'time', 'nohup', 'exec', 'command', 'nice', 'stdbuf', 'xvfb-run', 'timeout', 'corepack'])
31
+ // A wrapper's options taking a value, as `sudo -u user`, `timeout 10m` do.
32
+ const WRAPPER_VALUES = new Map([['sudo', /^-[ugCDhpRrTU]$/u], ['env', /^-[uC]$/u], ['nice', /^-n$/u], ['timeout', /^-[sk]$/u]])
33
+
34
+ // What a command runs (`argv`): its words past settings, kept (`env`), and
35
+ // wrappers, a wrapper's options and their values, and `timeout`'s duration.
36
+ function argvOf(words) {
37
+ const env = {}
38
+ let at = 0
39
+ for (;;) {
40
+ for (; at < words.length && /^[A-Za-z_]\w*=/u.test(words[at]); at++) env[words[at].slice(0, words[at].indexOf('='))] = words[at].slice(words[at].indexOf('=') + 1)
41
+ const wrapper = words[at]?.replace(/^.*\//u, '')
42
+ if (!WRAPPERS.has(wrapper)) return { argv: words.slice(at), env }
43
+ at++
44
+ while (at < words.length && words[at].startsWith('-')) at += WRAPPER_VALUES.get(wrapper)?.test(words[at]) ? 2 : 1
45
+ if (wrapper === 'timeout' && /^\d/u.test(words[at] ?? '')) at++
46
+ }
47
+ }
48
+
49
+ // A command's words, quotes taken off, redirections dropped, and the file
50
+ // its output is written to (`> file`, `>> file`), or null.
51
+ function wordsOf(text) {
52
+ const words = []
53
+ let quote = null, word = null
54
+ for (let at = 0; at <= text.length; at++) {
55
+ const char = text[at]
56
+ if (quote) {
57
+ if (char === quote) quote = null
58
+ else if (char === undefined) break
59
+ else word += quote === '"' && char === '\\' && /["\\$`]/u.test(text[at + 1] ?? '') ? text[++at] : char
60
+ } else if (char === undefined || /\s/u.test(char)) {
61
+ if (word !== null) words.push(word)
62
+ word = null
63
+ } else if (char === '"' || char === "'") { quote = char; word ??= '' }
64
+ // A redirection joined to a word before it (`url>file`) starts its own.
65
+ else if ((char === '>' || char === '<') && word !== null && !/^\d*&?$|[<>]$/u.test(word)) { words.push(word); word = char }
66
+ else word = (word ?? '') + (char === '\\' && at + 1 < text.length ? text[++at] : char)
67
+ }
68
+ const kept = []
69
+ let out = null
70
+ for (let at = 0; at < words.length; at++) {
71
+ const redirect = /^(\d*)(>>?|<<?|&>>?)(.*)$/u.exec(words[at])
72
+ if (!redirect) { kept.push(words[at]); continue }
73
+ const target = redirect[3] === '' ? words[++at] : redirect[3]
74
+ if (redirect[2].includes('>') && (redirect[1] === '' || redirect[1] === '1') && target && !target.startsWith('&')) out = target
75
+ }
76
+ return { words: kept, out }
77
+ }
78
+
79
+ // Where a `$(…)` starting at `from` (its `(`) closes, or the line's end.
80
+ function closing(text, from) {
81
+ let depth = 0, quote = null
82
+ for (let at = from; at < text.length; at++) {
83
+ const char = text[at]
84
+ if (quote) { if (char === quote) quote = null; else if (char === '\\') at++ }
85
+ else if (char === "'" || char === '"') quote = char
86
+ else if (char === '(') depth++
87
+ else if (char === ')' && --depth === 0) return at
88
+ }
89
+ return text.length
90
+ }
91
+
92
+ // A line's commands, each `{ argv, env, piped, out, nested }`, `env` the
93
+ // settings it runs with, `piped` where a pipe feeds it, `out` the file it writes its output to and `nested` its
94
+ // command substitutions' text, to read as lines of their own; and those of
95
+ // none of them (`nested`).
96
+ function commandsOf(text) {
97
+ const commands = []
98
+ let nested = [], piped = false, quote = null, start = 0
99
+ const end = (at, next) => {
100
+ const { words, out } = wordsOf(text.slice(start, at))
101
+ const { argv, env } = argvOf(words)
102
+ if (argv.length > 0) { commands.push({ argv, env, piped, out, nested }); nested = [] }
103
+ piped = next
104
+ }
105
+ for (let at = 0; at < text.length; at++) {
106
+ const char = text[at]
107
+ if (quote === "'") { if (char === "'") quote = null; continue }
108
+ if (char === '\\') { at++; continue }
109
+ if (char === '$' && text[at + 1] === '(') {
110
+ const close = closing(text, at + 1)
111
+ nested.push(text.slice(at + 2, close))
112
+ at = close
113
+ } else if (quote === '"') { if (char === '"') quote = null }
114
+ else if (char === '"' || char === "'") quote = char
115
+ else if (char === '#' && (at === 0 || /\s/u.test(text[at - 1]))) { end(at, false); start = text.length; break }
116
+ else if ((char === '&' || char === '|') && text[at + 1] === char) { end(at, false); start = ++at + 1 }
117
+ else if (char === ';' || (char === '&' && !/[<>]/u.test(text[at - 1] ?? '') && text[at + 1] !== '>')) { end(at, false); start = at + 1 }
118
+ else if (char === '|') { end(at, true); start = at + (text[at + 1] === '&' ? 2 : 1); if (text[at + 1] === '&') at++ }
119
+ }
120
+ if (start < text.length) end(text.length, false)
121
+ return { commands, nested }
122
+ }
123
+
124
+ // The command line a shell is given to run (`bash -euc '…'`, `pwsh
125
+ // -Command '…'`), else null.
126
+ function shellLine([program, ...words]) {
127
+ if (!SHELL.test(program)) return null
128
+ const option = /(?:pwsh|powershell)(?:\.exe)?$/iu.test(program) ? /^-(?:c|command)$/iu : /^-[a-z]*c[a-z]*$/u
129
+ const at = words.findIndex(word => option.test(word))
130
+ return at === -1 ? null : words[at + 1] ?? null
131
+ }
132
+
133
+ // A path `to` from `dir`, both from the step's directory ('' for it); as
134
+ // written where a variable, `~`, `-` or an absolute path names it.
135
+ export const pathFrom = (dir, to) => /^[$~/-]/u.test(to) ? to : resolved(dir ? dir.split('/') : [], to)?.join('/') ?? [dir, to].filter(Boolean).join('/')
136
+
137
+ // Each command of a script, in the order they run (`order`): its `line` from
138
+ // 0, the directory the `cd`s before it moved to (`dir`, by pathFrom), and
139
+ // `pipedTo`, the commands its output is piped through; a command
140
+ // substitution's before its command's, a shell's command line's (`sh -c`)
141
+ // after it.
142
+ export function scriptCommands(script) {
143
+ const found = []
144
+ let dir = '', read = 0
145
+ // A substitution's or a shell's command line (`apart`) moves no `cd` of its caller's.
146
+ const readLine = (text, line, apart = false) => {
147
+ if (read++ >= 64) return
148
+ const outer = dir, { commands, nested } = commandsOf(text)
149
+ commands.forEach(({ nested: substituted, ...command }, at) => {
150
+ for (const each of substituted) readLine(each, line, true)
151
+ const end = commands.findIndex((each, after) => after > at && !each.piped)
152
+ found.push({ ...command, line, dir, order: found.length, pipedTo: commands.slice(at + 1, end === -1 ? undefined : end) })
153
+ if (command.argv[0] === 'cd') dir = pathFrom(dir, command.argv.slice(1).find(word => !/^-[LPe@]+$/u.test(word)) ?? '~')
154
+ const shell = shellLine(command.argv)
155
+ if (shell) readLine(shell, line, true)
156
+ })
157
+ for (const each of nested) readLine(each, line, true)
158
+ if (apart) dir = outer
159
+ }
160
+ for (const { text, line } of scriptLines(script)) {
161
+ read = 0
162
+ readLine(text, line)
163
+ }
164
+ return found
165
+ }