@preventive/triage 1.0.0-alpha.43 → 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.
- package/common/bundle-compare-diff.js +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-sources.js +20 -1
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +91 -10
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +11 -0
- package/common/workspace-app.js +19 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-5DHL5NG3.js +2 -0
- package/out/chunk-6HNXNIQY.js +52 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-AFLZH7WG.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HMAJDVXN.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NPW52BM2.js +17 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-NV7ES4EW.js +1 -0
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PTGDYLNS.js +36 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-Q6MVYGYV.js +3 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-QRD6GIG5.js +229 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WI7Z5A2T.js +6 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/chunk-XVBEOGOS.js +3 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +31 -31
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +55 -6
- package/risk/actions/findings.js +156 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +165 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +68 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +13 -8
- package/risk/npm/compare.js +34 -10
- package/risk/npm/dependencies.js +11 -0
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +62 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/db-methods.ts +29 -10
- package/server-managed/db-neon.ts +30 -2
- package/server-managed/db-schema.ts +9 -2
- package/server-managed/db.ts +7 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +65 -15
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +23 -9
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http.ts +1219 -81
- package/server-managed/index.ts +6 -2
- package/server-managed/locked-packages.ts +48 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +173 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +51 -4
- package/server-managed/npm-loads.ts +20 -13
- package/server-managed/npm-packages.ts +24 -64
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +280 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/repository-actions.ts +209 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +54 -13
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +135 -0
- package/server-managed/repository-history.ts +445 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +432 -0
- package/server-managed/repository-risk-read.ts +165 -0
- package/server-managed/repository-risk.ts +116 -106
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +9 -5
- package/server-managed/sql.ts +1 -1
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/team-app.ts +54 -14
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +11 -0
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4F6JSF57.js +0 -3
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-7RU5EDRO.js +0 -226
- package/out/chunk-A7M4GV7M.js +0 -1
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LAAB5SZT.js +0 -2
- package/out/chunk-NWZ4PVPN.js +0 -1
- package/out/chunk-OSIMGFUF.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-RSHMVYNS.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UXNUAIIC.js +0 -33
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-XOCAPDCM.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
- package/out/chunk-ZMQQCGF6.js +0 -2
|
@@ -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
|
+
}
|