@preventive/triage 1.0.0-alpha.40 → 1.0.0-alpha.41
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/out/{chunk-XYDGU6BM.js → chunk-2AFQKRAM.js} +14 -14
- package/out/{chunk-AQ75QITE.js → chunk-KVEUXIEU.js} +1 -1
- package/out/{chunk-TGANXVTA.js → chunk-QZ3A376W.js} +1 -1
- package/out/{chunk-MYDDTA7W.js → chunk-TY4RZD3Z.js} +57 -56
- package/out/{chunk-IISEF2BZ.js → chunk-U6OPEXIA.js} +1 -1
- package/out/{chunk-B2RQY6OP.js → chunk-UZAZM2SX.js} +1 -1
- package/out/{chunk-UN5ZSYLE.js → chunk-XL35ZV6R.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +3 -3
- package/out/view.css +1 -1
- package/out/view.js +3 -3
- package/package.json +2 -1
- package/risk/files/runs-code.js +256 -19
- package/risk/index.d.ts +14 -0
- package/risk/npm/assess.js +9 -5
- package/risk/npm/score.js +59 -0
- package/server-managed/repository-risk.ts +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.41",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -282,6 +282,7 @@
|
|
|
282
282
|
"risk/npm/old-version.js",
|
|
283
283
|
"risk/npm/registry.js",
|
|
284
284
|
"risk/npm/release.js",
|
|
285
|
+
"risk/npm/score.js",
|
|
285
286
|
"risk/npm/specs.js",
|
|
286
287
|
"risk/npm/tarball.js",
|
|
287
288
|
"client/dependency-paths.d.ts",
|
package/risk/files/runs-code.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// What a file's code can do beyond itself, for a reviewer: run a string as
|
|
2
2
|
// code (eval, Function with new or without, node:vm, Module's _compile) or
|
|
3
3
|
// other programs (child_process), reach for credentials (a credential file's
|
|
4
|
-
// path, a token's name
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// path, a token's name, `gh auth token`, the cloud's instance metadata, the
|
|
5
|
+
// GitHub Actions runner's process or another process's memory, trufflehog)
|
|
6
|
+
// or the whole environment, publish to npm (`npm publish`, `/-/whoami`, a
|
|
7
|
+
// registry PUT), run a shell command fetching or handing off code (`curl …
|
|
8
|
+
// https://…`, `| sh`, `powershell -…`, rundll32, osascript, `/dev/tcp/`),
|
|
9
|
+
// and name a place code runs from again later (a shell's rc file,
|
|
10
|
+
// LaunchAgents, systemd's user units, `.claude/settings.json`,
|
|
11
|
+
// `.vscode/tasks.json`), as a file's stamps say (stamps.js) and a version's
|
|
12
|
+
// findings name what is new to a file. A shell script (`.sh`, `.bat`, `.ps1`
|
|
13
|
+
// …, or a shell's hashbang) is read whole for the last two and the
|
|
14
|
+
// credentials a command reaches for, its text its commands.
|
|
7
15
|
// A quick read, not a parse: a script's code with its strings, regular
|
|
8
16
|
// expressions and comments set aside as script.js tells them, its
|
|
9
17
|
// strings read for the paths, names and commands they hold, a module by the
|
|
@@ -52,8 +60,31 @@ const PICKED = String.raw`${GAP}\)${GAP}\??\.${GAP}(?:filter|find|findLast|some|
|
|
|
52
60
|
const COPY = String.raw`(?:\{${GAP}\.\.\.${GAP})?`
|
|
53
61
|
const ENV_WHOLE = String.raw`(?=JSON|Object|URLSearchParams)(?<![\p{ID_Continue}$.])(?:JSON${GAP}\.${GAP}stringify|URLSearchParams)${GAP}\(${GAP}${COPY}${ENV}`
|
|
54
62
|
+ String.raw`|(?=Object)(?<![\p{ID_Continue}$.])Object${GAP}\.${GAP}(?:values|entries)${GAP}\(${GAP}${COPY}${ENV}(?!${PICKED})`
|
|
55
|
-
//
|
|
56
|
-
|
|
63
|
+
// What a shell command, and a place code runs from again later, may say
|
|
64
|
+
// (SHELL, PERSISTENCE), and what of the credentials is told in any case:
|
|
65
|
+
// trufflehog (TRUFFLEHOG), GCP's metadata host, and a string naming gh
|
|
66
|
+
// (`'gh'`, `'/usr/bin/gh'`, `'GH.EXE'`) as a program run with its arguments
|
|
67
|
+
// apart is, whatever is between them (`'gh', ['auth', /* … */ 'token']`).
|
|
68
|
+
// A shell, as a hashbang or a pipe names it (`#!/bin/ksh`, `| fish`,
|
|
69
|
+
// BusyBox's `ash`, `| powershell`); after a pipe, by its path or env's too (`| /bin/sh`,
|
|
70
|
+
// `| /usr/bin/env bash`), env's options, their values, and settings before
|
|
71
|
+
// it on its line (`env -S`, `env -u NAME`, `env MODE=x`).
|
|
72
|
+
const SHELLS = String.raw`(?:(?:ba|z|da|k|mk|c|tc|fi|a)?sh|pwsh|powershell)`
|
|
73
|
+
const SHELL_PATH = String.raw`(?:(?:\/[\w.-]{1,40}){0,8}\/)?(?:env(?:[^\S\n]+(?:-[\w-]{1,20}(?:=\S{0,100}|[^\S\n]+(?!-)[^\s=]{1,100})?|\w{1,40}=\S{0,100})){0,6}[^\S\n]+)?`
|
|
74
|
+
const SHELL_OR_PLACE = String.raw`curl|wget|\|\s*(?:sudo|${SHELL_PATH}${SHELLS})|powershell|pwsh|rundll32|osascript|mshta|certutil|bitsadmin|\/dev\/(?:tcp|udp)|iex|invoke-expression`
|
|
75
|
+
+ String.raw`|\$profile|profile\.ps1|config\.fish|conf\.d|bashrc|zshrc|zshenv|zlogin|bash_profile|bash_login|zprofile|\.profile|Launch(?:Agents|Daemons)|systemd|systemctl|\.claude|\.vscode|trufflehog|metadata\.google\.internal|["'\x60\/\\]gh(?:\.exe)?["'\x60]`
|
|
76
|
+
// What says one of the credentials a command, an endpoint or a process holds
|
|
77
|
+
// (HOLDS' gh-token, cloud-metadata, runner-worker and process-memory), as
|
|
78
|
+
// code and its strings may say it, in the case it is written: `gh auth
|
|
79
|
+
// token`'s command, lowercase as gh reads it, the metadata endpoint's
|
|
80
|
+
// address, `Runner.Worker` and `/proc/`; what is told in any case is
|
|
81
|
+
// SHELL_OR_PLACE's.
|
|
82
|
+
const SAYS_CREDENTIALS = String.raw`auth(?:[^\S\n]|[\\^\x60]\r?\n)+token|169\.254\.169\.254|Runner\.Worker|\/proc\/`
|
|
83
|
+
// Any of RUNS_CODE, as a text may say it: a shell command or a place in any
|
|
84
|
+
// case, as Windows' are written (`PowerShell`, `RUNDLL32`).
|
|
85
|
+
const SAYS_SHELL_OR_PLACE = new RegExp(SHELL_OR_PLACE, 'iu')
|
|
86
|
+
const SAYS_CODE = new RegExp(String.raw`eval|Function${FUNCTION_CALLED}|vm["'\x60]|_compile|child_process|\.(?:npmrc|ssh|aws)|_TOKEN|${SAYS_CREDENTIALS}|(?:stringify|values|entries|URLSearchParams)${GAP}\(${GAP}(?:\{${GAP}\.\.\.${GAP})?process${GAP}\.${GAP}env|publish|whoami|registry\.(?:npmjs\.org|yarnpkg\.com)`, 'u')
|
|
87
|
+
const SAYS_ANY = { test: text => SAYS_CODE.test(text) || SAYS_SHELL_OR_PLACE.test(text) }
|
|
57
88
|
|
|
58
89
|
// A name read as one, not as a property's (`vm.eval`, `vm . eval`, a spread's
|
|
59
90
|
// `...eval` aside) or part of another's (`evaluate`), or as the global
|
|
@@ -103,12 +134,16 @@ const READS = new RegExp(String.raw`${JS_STRING_OR_COMMENT.source}|(?<eval>${nam
|
|
|
103
134
|
// `.sshd`); a token's name where it is read, the string the name alone (a
|
|
104
135
|
// key, `process.env['GITHUB_TOKEN']`) or the name expanded (a shell's or
|
|
105
136
|
// .npmrc's `$GITHUB_TOKEN`, `${NPM_TOKEN}`, cmd's `%NPM_TOKEN%`), not where
|
|
106
|
-
// text names it (`requires a token in GITHUB_TOKEN`); a
|
|
137
|
+
// text names it (`requires a token in GITHUB_TOKEN`); a credential a
|
|
138
|
+
// command, an endpoint or a process holds (`gh auth token`, the cloud's
|
|
139
|
+
// metadata endpoint, `Runner.Worker`, `/proc/<pid>/mem`, trufflehog, as
|
|
140
|
+
// HOLDS tells them); a command publishing (`npm publish`, `pnpm
|
|
107
141
|
// publish …`, options before it as npm takes them, `npm --tag next publish`)
|
|
108
142
|
// or the registry's who-am-I, a publisher's first step with a token. And the
|
|
109
143
|
// registry, where a PUT request is one publishing (REGISTRY_URL).
|
|
110
144
|
// Told first of a string by what any of them says.
|
|
111
|
-
const
|
|
145
|
+
const HOLDS_CODE = new RegExp(String.raw`\.(?:npmrc|ssh|aws)|_TOKEN|${SAYS_CREDENTIALS}|publish|whoami|registry\.`, 'u')
|
|
146
|
+
const HOLDS_ANY = { test: text => HOLDS_CODE.test(text) || SAYS_SHELL_OR_PLACE.test(text) }
|
|
112
147
|
// Before a token's name as a key, its absence told or the token dropped, as
|
|
113
148
|
// TOKEN_NAME tells of one as a name (`!process.env['GITHUB_TOKEN']`,
|
|
114
149
|
// `delete process.env['NPM_TOKEN']`): a few names and dots at most, and
|
|
@@ -147,11 +182,151 @@ const unescaped = text => text.replace(ESCAPE, (escape, hex, unit, point, octal,
|
|
|
147
182
|
const REGISTRY_HOST = String.raw`registry\.(?:npmjs\.org|yarnpkg\.com)`
|
|
148
183
|
const REGISTRY_URL = new RegExp(String.raw`(?:[/\\]{2}|\bhttps?:[/\\]?)(?:[^/\\?#]{0,256}@)?${REGISTRY_HOST}|^(["'\u0060])${REGISTRY_HOST}(?=\1$|[/:?#])`, 'iu')
|
|
149
184
|
const URL_STRIPPED = /[\t\n\r]/gu
|
|
185
|
+
// A shell command fetching or handing off code: curl or wget given a URL
|
|
186
|
+
// (`curl -fsSL https://…`, `ftp://`, `https:\/\/` as a string may escape
|
|
187
|
+
// it), long options before it too (`-H "Authorization: …"`), a quoted
|
|
188
|
+
// argument's `;`, `|` and `&` no command's end (`-d "a=1&b=2"`), its line
|
|
189
|
+
// continued too (`curl -fsSL \⏎ https://…`, `curl\⏎`, cmd's `^`,
|
|
190
|
+
// PowerShell's backtick), not the next line's, the next command's (`curl
|
|
191
|
+
// --version; echo https://…`) nor the next curl's, a download piped to a
|
|
192
|
+
// shell (`… | sh`, `payload|sudo -E -- bash`, `| ksh`, `| /bin/sh`, `cat
|
|
193
|
+
// hash | sh`, not a list of shells, `bash|zsh|sh`, a regular expression's
|
|
194
|
+
// alternatives, `(x|ss|sh)$`, nor `|| sh`, nor an escaped pipe, grep's
|
|
195
|
+
// `'bash\|zsh'`), PowerShell given options (`powershell -nop -enc …`),
|
|
196
|
+
// rundll32, osascript and mshta, certutil fetching (`certutil -urlcache -f
|
|
197
|
+
// http://…`) and bitsadmin's `/transfer`, and bash's network redirection
|
|
198
|
+
// (`/dev/tcp/host/port`), and PowerShell running what it fetches (`iex (iwr
|
|
199
|
+
// https://…).Content`, `irm https://… | iex`, long options before the pipe
|
|
200
|
+
// too, `iwr … -Headers @{…} | iex`, `IEX (New-Object
|
|
201
|
+
// Net.WebClient).DownloadString(…)`), its line continued by a backtick too,
|
|
202
|
+
// not the next pipeline's, `iex $x && iwr …`); in any case, as Windows' are
|
|
203
|
+
// written, and by its path (`/usr/bin/curl`).
|
|
204
|
+
const CONTINUED = String.raw`[\\^\u0060]\r?\n`
|
|
205
|
+
const ON_ITS_LINE = String.raw`(?:[^\n;|&]|${CONTINUED})`
|
|
206
|
+
// PowerShell's own, its backtick alone (not `\⏎`, a new statement there).
|
|
207
|
+
const POWERSHELL_CONTINUED = String.raw`\u0060\r?\n`
|
|
208
|
+
// A command's text up to its end, its arguments' quotes kept, a quote left
|
|
209
|
+
// open (`it's`) as any other character.
|
|
210
|
+
const ARGUMENTS = String.raw`(?:[^\n;|&"']|${CONTINUED}|"[^"\n]*"|'[^'\n]*'|"(?![^"\n]*")|'(?![^'\n]*'))`
|
|
211
|
+
const APART = String.raw`(?:[^\S\n]|${CONTINUED})`
|
|
212
|
+
// A command's options, a few, and their values (`-E`, `-u root`, `--now`),
|
|
213
|
+
// on its line or one continued.
|
|
214
|
+
const OPTIONS = String.raw`(?:${APART}+--?\w[\w-]*(?:=\S*|${APART}+(?!-)\S+)?){0,6}?`
|
|
215
|
+
const SHELL = String.raw`(?<![\w.-])(?:curl|wget)(?:\.exe)?${APART}(?:(?!(?:curl|wget)(?![\w-]))${ARGUMENTS}){0,2000}?["']?(?:https?|s?ftps?|scp):\\?\/\\?\/|(?<![|\\]|(?<![\w.\/-])${SHELLS}\s*)\|\s*(?:sudo${OPTIONS}(?:${APART}+--)?${APART}+)?${SHELL_PATH}${SHELLS}(?![\w)|$-])`
|
|
216
|
+
+ String.raw`|(?<![\w.-])(?:powershell|pwsh)(?:\.exe)?[^\S\n]+-\w|(?<![\w.-])(?:rundll32|osascript|mshta)(?:\.exe)?(?![\w-])`
|
|
217
|
+
+ String.raw`|(?<![\w.-])certutil(?:\.exe)?${APART}${ON_ITS_LINE}{0,100}?-urlcache|(?<![\w.-])bitsadmin(?:\.exe)?${APART}${ON_ITS_LINE}{0,100}?\/transfer|\/dev\/(?:tcp|udp)\/`
|
|
218
|
+
+ String.raw`|(?<![\w$.-])(?:iex|invoke-expression)(?![\w-])(?:[^\n;|&]|${POWERSHELL_CONTINUED}){0,200}?(?:(?<![\w$.-])(?:iwr|irm|invoke-webrequest|invoke-restmethod)(?![\w-])|\.downloadstring\s*\()`
|
|
219
|
+
+ String.raw`|(?<![\w$.-])(?:iwr|irm|invoke-webrequest|invoke-restmethod)(?![\w-])(?:(?!(?:iwr|irm|invoke-webrequest|invoke-restmethod)(?![\w-]))(?:[^\n;|&]|${POWERSHELL_CONTINUED})){0,2000}\|\s*(?:iex|invoke-expression)(?![\w-])`
|
|
220
|
+
// A place code runs from again later: a shell's rc file (`~/.bashrc`,
|
|
221
|
+
// `.zshrc`, `.zshenv`, `.zlogin`, `.bash_profile`, `.bash_login`,
|
|
222
|
+
// `.zprofile`, and `.profile` in a path, `~/.profile`, not a selector's
|
|
223
|
+
// class, nor one after another, `.nav`), PowerShell's profile (`$PROFILE`,
|
|
224
|
+
// `Microsoft.PowerShell_profile.ps1`, all hosts' `PowerShell\Profile.ps1`,
|
|
225
|
+
// `$PSHOME\Profile.ps1`, not another folder's), macOS's LaunchAgents and
|
|
226
|
+
// LaunchDaemons, fish's (`~/.config/fish/config.fish`, `conf.d`), systemd's
|
|
227
|
+
// user units (`~/.config/systemd/user`, `systemctl --user enable`, other
|
|
228
|
+
// options before it, `--now`, or `--user` after it in its command,
|
|
229
|
+
// `systemctl enable --user`; not `start`, which runs a unit once), and the
|
|
230
|
+
// settings an AI agent or an editor runs commands from on its own
|
|
231
|
+
// (`.claude/settings.json`, `.vscode/tasks.json`); in any case, as Windows'
|
|
232
|
+
// and macOS's paths are, a path's separator a slash or a backslash, escaped
|
|
233
|
+
// in a string as code writes it (`"\\.claude\\settings.json"`, `\/`).
|
|
234
|
+
const SEPARATOR = String.raw`(?:\\?\/|\\{1,2})`
|
|
235
|
+
const PERSISTENCE = String.raw`(?<![\w.-])\.(?:bashrc|zshrc|zshenv|zlogin|bash_profile|bash_login|zprofile)(?![\w-])|(?<=[\/\\])(?<!\.[\w-]*[\/\\])\.profile(?![\w-])|(?<![\w-])Launch(?:Agents|Daemons)(?![\w-])|systemd${SEPARATOR}user(?![\w-])|fish${SEPARATOR}(?:config\.fish|conf\.d)(?![\w-])`
|
|
236
|
+
+ String.raw`|(?<![\w$])\$profile(?![\w-])|(?<![\w.-])Microsoft\.\w+_profile\.ps1(?![\w-])|(?:PowerShell|\$pshome)${SEPARATOR}profile\.ps1(?![\w-])|systemctl${OPTIONS}${APART}+--user${OPTIONS}${APART}+enable(?![\w-])|systemctl${OPTIONS}${APART}+enable(?:${APART}+[^\s;|&]+){0,6}?${APART}+--user(?![\w-])|(?<![\w.-])\.claude${SEPARATOR}settings(?:\.local)?\.json|(?<![\w.-])\.vscode${SEPARATOR}tasks\.json`
|
|
237
|
+
// Credentials a command, an endpoint or a process holds, as recent npm
|
|
238
|
+
// malware reached for them: the GitHub CLI printing its token (`gh auth
|
|
239
|
+
// token`, by its path too, `gh.exe`, its name in any case as Windows runs
|
|
240
|
+
// it, `GH.EXE`, its line continued as a shell command's, `gh auth \⏎
|
|
241
|
+
// token`), not its other commands (`gh auth status`); the cloud's
|
|
242
|
+
// instance metadata endpoint, AWS's, Azure's and GCP's
|
|
243
|
+
// (`http://169.254.169.254/latest/meta-data/…`, `metadata.google.internal`,
|
|
244
|
+
// in any case as DNS reads it), as a host whole (HOST_START, HOST_END): not
|
|
245
|
+
// an address or a host either starts or ends (`169.254.169.2540`,
|
|
246
|
+
// `foo-169.254.169.254`, `169.254.169.254.example.com`,
|
|
247
|
+
// `metadata.google.internal.example.com`; a trailing dot,
|
|
248
|
+
// `metadata.google.internal.`, its host still), nor in a URL's path or
|
|
249
|
+
// userinfo (`https://example.com/169.254.169.254`,
|
|
250
|
+
// `https://169.254.169.254@example.com`; WHATWG's `\@` a path's, its host
|
|
251
|
+
// the address still), nor Azure's instance metadata
|
|
252
|
+
// (`169.254.169.254/metadata/instance/compute`, after a port too, `:80`, its
|
|
253
|
+
// slashes escaped too, `\/`), a machine's region and size, not its
|
|
254
|
+
// identity's token, as msal asks it; the GitHub Actions runner's process
|
|
255
|
+
// (`Runner.Worker`, `Runner.Worker.exe`, `GitHub.Runner.Worker`), whose
|
|
256
|
+
// memory holds a job's secrets, not in another name (`MyRunner.Worker`,
|
|
257
|
+
// `Runner.Workers`) nor case (`runner.worker`); another process's memory, by
|
|
258
|
+
// its pid's digits or a glob (`/proc/1234/mem`, `/proc/*/mem`,
|
|
259
|
+
// `/proc/[0-9]*/mem`) or a substitution (`/proc/${pid}/mem`, a shell's
|
|
260
|
+
// `$pid`, `$(pgrep …)` and backticks, Python's `{pid}`, a format's `%d`),
|
|
261
|
+
// not a process's own (`/proc/self/mem`) nor what else /proc tells of one
|
|
262
|
+
// (`/proc/1234/status`); and trufflehog, a scanner for secrets, in any case
|
|
263
|
+
// (`TruffleHog`), not in another name (`trufflehogs`, `TRUFFLEHOG_VERSION`).
|
|
264
|
+
const GH_TOKEN = String.raw`(?<![\w.-])[gG][hH](?:\.[eE][xX][eE])?${APART}+auth${APART}+token(?![\w-])`
|
|
265
|
+
const PID = String.raw`[\d*?[\]-]{1,20}|\$\{[^}\n]{0,100}\}|\$\([^)\n]{0,200}\)|\u0060[^\u0060\n]{0,200}\u0060|\$\w{1,64}|\{\w{0,64}\}|%d`
|
|
266
|
+
// A host whole, a URL's or alone: not after a word, a dot or a hyphen, nor a
|
|
267
|
+
// path's slash (`https://example.com/169.254.169.254`), a URL's two
|
|
268
|
+
// (`//`, `\/\/`) its start after a scheme's colon or alone
|
|
269
|
+
// (`//169.254.169.254`), not after a path's character
|
|
270
|
+
// (`https://example.com//169.254.169.254`); nor before more of a name or
|
|
271
|
+
// userinfo's `@` (`https://169.254.169.254@example.com`, `…:password@`).
|
|
272
|
+
const HOST_START = String.raw`(?<![\w.-]|(?<![\/\\])\/|[\w.~%-](?:\\?\/){2})`
|
|
273
|
+
const HOST_END = String.raw`(?![\w-]|\.[\w-]|(?::[^\s\/@'"\x60]{0,64})?@)`
|
|
274
|
+
const TRUFFLEHOG = String.raw`(?<![\p{ID_Continue}$-])trufflehog(?![\p{ID_Continue}$-])`
|
|
150
275
|
const HOLDS = [
|
|
151
276
|
['credential-files', /(?<![\w$.-])\.(?:npmrc|ssh|aws)(?![\w-])/gu],
|
|
152
277
|
['tokens', new RegExp(String.raw`^["'\u0060]${TOKEN_VAR}["'\u0060]$|(?:\$\{?|%)${TOKEN_VAR}(?![\p{ID_Continue}$])`, 'gu')],
|
|
278
|
+
['gh-token', new RegExp(GH_TOKEN, 'gu')],
|
|
279
|
+
['cloud-metadata', new RegExp(String.raw`${HOST_START}169\.254\.169\.254${HOST_END}(?!(?::\d{1,5})?\\?\/metadata\\?\/instance(?![\w-]))|${HOST_START}metadata\.google\.internal${HOST_END}`, 'giu')],
|
|
280
|
+
['runner-worker', /(?<![\w$-])Runner\.Worker(?![\w$-])/gu],
|
|
281
|
+
['process-memory', new RegExp(String.raw`\/proc\/(?:${PID})\/mem(?![\w-])`, 'gu')],
|
|
282
|
+
['trufflehog', new RegExp(TRUFFLEHOG, 'giu')],
|
|
153
283
|
['publish', /(?<![\w-])(?:npm|pnpm|yarn)(?:\s+-{1,2}\w[\w-]*(?:=\S*|\s+(?!-)\S+)?){0,8}?\s+publish(?![\w-])|\/-\/whoami(?![\w-])/gu],
|
|
284
|
+
['shell', new RegExp(SHELL, 'giu')],
|
|
285
|
+
['persistence', new RegExp(PERSISTENCE, 'giu')],
|
|
154
286
|
]
|
|
287
|
+
// What a shell script holds, read whole: its commands, the places they write
|
|
288
|
+
// to, and the credentials a command, a process or its memory holds (`gh auth
|
|
289
|
+
// token`, `Runner.Worker`, `/proc/<pid>/mem`, trufflehog); not the cloud's
|
|
290
|
+
// metadata endpoint, whose fetching is a shell command already (`curl
|
|
291
|
+
// http://169.254.169.254/…`) and which a script provisioning a machine asks
|
|
292
|
+
// its region and id, nor a credential file or a token, which a script
|
|
293
|
+
// publishing a package names.
|
|
294
|
+
const SHELL_KINDS = new Set(['gh-token', 'runner-worker', 'process-memory', 'trufflehog', 'shell', 'persistence'])
|
|
295
|
+
const SHELL_HOLDS = HOLDS.filter(([kind]) => SHELL_KINDS.has(kind))
|
|
296
|
+
// A shell script, by its extension, its name a shell's startup file's
|
|
297
|
+
// (`.bashrc`, `.profile`), or its hashbang: a shell's, by its path or env's,
|
|
298
|
+
// env's options and settings before it (`#!/bin/ksh`, `#!/usr/bin/env -S
|
|
299
|
+
// bash -e`, `#!/usr/bin/env -S MODE=x bash`, `#!/usr/bin/env pwsh`), past a
|
|
300
|
+
// byte order mark.
|
|
301
|
+
const SHELL_SCRIPT = /(?:\.(?:sh|bash|zsh|ksh|mksh|dash|ash|csh|tcsh|fish|command|bat|cmd|ps1|psm1)|(?:^|[\\/])\.(?:bashrc|zshrc|zshenv|zlogin|bash_profile|bash_login|zprofile|profile))$/iu
|
|
302
|
+
// A shell script's comment, a line's whole (`# curl … | sh`) or its end
|
|
303
|
+
// after a command (`echo ok # don't curl …`, `echo ok;# …`) where the quotes
|
|
304
|
+
// before it are closed, escapes read (`"a \" b"`, PowerShell's by a
|
|
305
|
+
// backtick), not in one (`"a # b"`), and no other `#` (`$#`, `<#`) is;
|
|
306
|
+
// PowerShell's, by its extension or hashbang, its blocks too (`<# … #>`,
|
|
307
|
+
// opened where a command or value may start, `$x=<# … #>1`, not in a
|
|
308
|
+
// string, `"<#"`, `"a <#"`, to the end where none closes); a batch file's, cmd's `::`
|
|
309
|
+
// and `REM` (`@REM`, and `REM` after `&` or `||`, `echo ok & REM …`); as
|
|
310
|
+
// none of its commands.
|
|
311
|
+
const SHELL_COMMENT = /^[ \t]*#.*$|^((?:[^'"\n#\\]|\\.|'[^'\n]*'|"(?:[^"\n\\]|\\.)*")*?[ \t;&|])#.*$/gmu
|
|
312
|
+
const POWERSHELL = /\.psm?1$/iu
|
|
313
|
+
const POWERSHELL_HASHBANG = /^\uFEFF?#!\s*\S*\/(?:env\s+(?:(?:-\S+|\w+=\S*)\s+)*)?pwsh(?![\w-])/u
|
|
314
|
+
const POWERSHELL_COMMENT = /^((?:[^'"\n#\u0060]|\u0060.|'[^'\n]*'|"(?:[^"\n\u0060]|\u0060.)*")*?)(?:(?<=^|[\s;({=,])<#[\s\S]*?(?:#>|(?![\s\S]))|(?<=^|[ \t;&|])#.*$)/gmu
|
|
315
|
+
const BATCH = /\.(?:bat|cmd)$/iu
|
|
316
|
+
const BATCH_COMMENT = /^[ \t]*@?(?:::|rem(?![\w-])).*$|(?<=&|\|\|)[ \t]*@?rem(?![\w-]).*$/gimu
|
|
317
|
+
const SHELL_HASHBANG = new RegExp(String.raw`^\uFEFF?#!\s*\S*\/(?:env\s+(?:(?:-\S+|\w+=\S*)\s+)*)?${SHELLS}(?![\w-])`, 'u')
|
|
318
|
+
// A directory and a file in it named apart, as path.join takes them
|
|
319
|
+
// (`join(home, '.claude', 'settings.json')`): a place code runs from again
|
|
320
|
+
// later where a file names both.
|
|
321
|
+
const PARTS = [
|
|
322
|
+
['claude', /^(["'\u0060])\.claude\1$/iu, /^(["'\u0060])settings(?:\.local)?\.json\1$/iu],
|
|
323
|
+
['vscode', /^(["'\u0060])\.vscode\1$/iu, /^(["'\u0060])tasks\.json\1$/iu],
|
|
324
|
+
]
|
|
325
|
+
// `.profile` named apart where the home directory is the part before it
|
|
326
|
+
// (`join(os.homedir(), '.profile')`, `resolve(process.env.HOME, …)`, `'~'`),
|
|
327
|
+
// not a selector's class alone.
|
|
328
|
+
const PROFILE = /^(["'\u0060])\.profile\1$/iu
|
|
329
|
+
const HOME_BEFORE = /(?:homedir\s*\(\s*\)|\b(?:HOME|USERPROFILE)|(["'\u0060])~\1)\s*,\s*$/u
|
|
155
330
|
// A module a kind loads: vm, child_process, and libnpmpublish, npm's own
|
|
156
331
|
// publishing.
|
|
157
332
|
const MODULES = [
|
|
@@ -169,6 +344,22 @@ const OPTION = /^(["'\u0060])-/u
|
|
|
169
344
|
const ARGUMENTS_BETWEEN = new RegExp(String.raw`^(?:[\s,[]|${COMMENT})*$`, 'u')
|
|
170
345
|
const PUT = /^(["'\u0060])put\1$/iu
|
|
171
346
|
const METHOD = new RegExp(String.raw`\bmethod["']?${GAP}[:=]${GAP}$`, 'u')
|
|
347
|
+
// A program run with its arguments apart, by its path too, a Windows one's
|
|
348
|
+
// from its drive with spaces in it (`spawn('curl', ['-fsSL', 'https://…'])`,
|
|
349
|
+
// `execFile('powershell.exe', ['-NoP', '-enc', …])`, `spawn('systemctl',
|
|
350
|
+
// ['--user', 'enable', …])`, `spawnSync('gh', ['auth', 'token'])`,
|
|
351
|
+
// `execFileSync('C:\\Program Files\\GitHub CLI\\gh.exe', …)`): its command
|
|
352
|
+
// line, its arguments' strings after its name, read as a shell command is, a
|
|
353
|
+
// variable's or a property's value between (`['-o', output, 'https://…']`,
|
|
354
|
+
// `outputs[i]`, `options?.out`), its first 20 arguments, however long.
|
|
355
|
+
const COMMAND = /^(["'\u0060])(?:\S*[\\/]|[a-z]:[\\/][^\n]*[\\/])?(?:curl|wget|certutil|bitsadmin|powershell|pwsh|systemctl|gh)(?:\.exe)?\1$/iu
|
|
356
|
+
// An argument a shell would need quoted, its separators no command's end
|
|
357
|
+
// (`'Cookie: a=b; c=d'`), its own double quotes single then.
|
|
358
|
+
const SEPARATES = /[;|&]/u
|
|
359
|
+
const VALUES_BETWEEN = new RegExp(String.raw`^(?:[\s,[\]\w$.?]|${COMMENT})*$`, 'u')
|
|
360
|
+
// A place named by its parts one after the other, as path.join takes them
|
|
361
|
+
// (`join(home, '.config', 'systemd', 'user')`), read as a path is.
|
|
362
|
+
const PLACE = new RegExp(PERSISTENCE, 'iu')
|
|
172
363
|
// What comes before a module's name where code loads it: `require(` (or
|
|
173
364
|
// `require?.(`), a bundler's `__require(` or `__non_webpack_require__(`,
|
|
174
365
|
// `import(`, `import` and `from`, comments allowed between
|
|
@@ -182,9 +373,10 @@ const TYPE_ONLY = new RegExp(String.raw`\b(?:import|export)${GAP}type\b(?!${GAP}
|
|
|
182
373
|
|
|
183
374
|
// Each kind, in the order its tags show: its tag, what it does, its name in
|
|
184
375
|
// the tag's tooltip, a row of its own where kinds share the tag (a string run
|
|
185
|
-
// as code by any way, `eval`'s; a credential file
|
|
186
|
-
// and for one marked as worse than
|
|
187
|
-
// environment read out,
|
|
376
|
+
// as code by any way, `eval`'s; a credential file, a token, a command, an
|
|
377
|
+
// endpoint or a process, `credentials`'), and for one marked as worse than
|
|
378
|
+
// the others' warning, `critical`: the whole environment read out,
|
|
379
|
+
// publishing, and a place code runs from again later.
|
|
188
380
|
const AS_CODE = { tag: 'eval', note: 'Runs a string as code' }
|
|
189
381
|
const CREDENTIALS = { tag: 'credentials', note: 'Reaches for credentials' }
|
|
190
382
|
export const RUNS_CODE = new Map([
|
|
@@ -195,24 +387,44 @@ export const RUNS_CODE = new Map([
|
|
|
195
387
|
['child-process', { tag: 'child_process', note: 'Runs other programs', name: 'child_process' }],
|
|
196
388
|
['credential-files', { ...CREDENTIALS, name: '.npmrc, .ssh or .aws' }],
|
|
197
389
|
['tokens', { ...CREDENTIALS, name: 'NPM_TOKEN or GITHUB_TOKEN' }],
|
|
390
|
+
['gh-token', { ...CREDENTIALS, name: 'gh auth token' }],
|
|
391
|
+
['cloud-metadata', { ...CREDENTIALS, name: '169.254.169.254 or metadata.google.internal' }],
|
|
392
|
+
['runner-worker', { ...CREDENTIALS, name: 'Runner.Worker' }],
|
|
393
|
+
['process-memory', { ...CREDENTIALS, name: '/proc/<pid>/mem' }],
|
|
394
|
+
['trufflehog', { ...CREDENTIALS, name: 'trufflehog' }],
|
|
198
395
|
['env', { tag: 'whole env', note: 'Reads out the whole environment', name: 'process.env, by JSON.stringify, Object.entries or values, URLSearchParams', mark: 'critical' }],
|
|
199
396
|
['publish', { tag: 'publish', note: 'Publishes to npm', name: 'npm publish, /-/whoami or a registry PUT', mark: 'critical' }],
|
|
397
|
+
['shell', { tag: 'shell', note: 'Runs shell commands fetching or handing off code', name: 'curl or wget a URL, | sh, powershell -…, iex, rundll32, osascript, /dev/tcp' }],
|
|
398
|
+
['persistence', { tag: 'persistence', note: 'Names a place code runs from again later', name: '.bashrc, .zshrc, $PROFILE, LaunchAgents, systemd user units, .claude/settings.json, .vscode/tasks.json', mark: 'critical' }],
|
|
200
399
|
])
|
|
201
400
|
|
|
202
401
|
// The kinds a file's code holds, each with the lines it is on, of the first
|
|
203
402
|
// NPM_MAX_FOUND places it does each, in RUNS_CODE's order; null for none, or
|
|
204
|
-
// for a file that is no script.
|
|
403
|
+
// for a file that is no script. A shell script's, read whole, are its shell
|
|
404
|
+
// commands and the places it names; one with a shell's hashbang is one
|
|
405
|
+
// whatever its extension, and read as JavaScript too where it is that, as
|
|
406
|
+
// a launcher (`#!/bin/sh` then `exec node "$0"`) is both.
|
|
205
407
|
export function npmRunsCode(path, text) {
|
|
206
|
-
|
|
408
|
+
const script = npmIsScript(path, text)
|
|
409
|
+
const shell = typeof text === 'string' && (SHELL_SCRIPT.test(path) || SHELL_HASHBANG.test(text))
|
|
410
|
+
if (!script && !shell) return null
|
|
207
411
|
const found = new Map()
|
|
208
412
|
const at = (kind, offset) => {
|
|
209
413
|
const offsets = found.get(kind)
|
|
210
414
|
if (!offsets) found.set(kind, [offset])
|
|
211
415
|
else if (offsets.length < NPM_MAX_FOUND) offsets.push(offset)
|
|
212
416
|
}
|
|
213
|
-
read(text, 0, at)
|
|
214
|
-
|
|
417
|
+
if (script) read(text, 0, at)
|
|
418
|
+
if (shell && SAYS_ANY.test(text)) {
|
|
419
|
+
// Its comments blanked, its lines where they were.
|
|
420
|
+
const comments = BATCH.test(path) ? BATCH_COMMENT : POWERSHELL.test(path) || POWERSHELL_HASHBANG.test(text) ? POWERSHELL_COMMENT : SHELL_COMMENT
|
|
421
|
+
const commands = text.replace(comments, (comment, command = '') => command + comment.slice(command.length).replaceAll(/[^\n]/gu, ' '))
|
|
422
|
+
for (const [kind, holds] of SHELL_HOLDS) for (const { index } of commands.matchAll(holds)) at(kind, index)
|
|
423
|
+
}
|
|
424
|
+
// A PUT request publishing where the file names the registry; a place
|
|
425
|
+
// named by its directory and its file apart.
|
|
215
426
|
if (found.has('registry')) for (const offset of found.get('put') ?? []) at('publish', offset)
|
|
427
|
+
for (const [part] of PARTS) if (found.has(`${part}-file`)) for (const offset of found.get(`${part}-dir`) ?? []) at('persistence', offset)
|
|
216
428
|
if (![...RUNS_CODE.keys()].some(kind => found.has(kind))) return null
|
|
217
429
|
const line = lineOf(text)
|
|
218
430
|
return new Map([...RUNS_CODE.keys()].filter(kind => found.has(kind)).map(kind => [kind, [...new Set(found.get(kind).map(line))].toSorted((a, b) => a - b)]))
|
|
@@ -227,10 +439,11 @@ export function npmRunsCode(path, text) {
|
|
|
227
439
|
// anything is read.
|
|
228
440
|
function read(code, base, at) {
|
|
229
441
|
if (!SAYS_ANY.test(code)) return
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// an
|
|
233
|
-
|
|
442
|
+
// The string before, and where, a place's part maybe; the command line
|
|
443
|
+
// since a program's name, and where it is named; where the string before
|
|
444
|
+
// ends; whether the strings since a program's name are its arguments, an
|
|
445
|
+
// option or its value each, and whether the last is an option.
|
|
446
|
+
let before = null, command = null, end = 0, option = false, program = false
|
|
234
447
|
for (const { 0: token, 1: string, groups, index } of code.matchAll(READS)) {
|
|
235
448
|
if (string === undefined) {
|
|
236
449
|
for (const kind of CODE_KINDS) if (groups[kind]) at(kind, base + index)
|
|
@@ -248,16 +461,40 @@ function read(code, base, at) {
|
|
|
248
461
|
const after = code.slice(index + token.length, index + token.length + 20)
|
|
249
462
|
return !KEY_SET.test(after) && (!KEY_ENDS.test(after) || !KEY_ABSENT.test(code.slice(Math.max(0, index - 300), index)))
|
|
250
463
|
}
|
|
251
|
-
|
|
464
|
+
// Each kind searched for before its places are listed, as matchAll copies
|
|
465
|
+
// its regular expression each time.
|
|
466
|
+
const held = HOLDS_ANY.test(text) ? HOLDS.flatMap(([kind, holds]) => text.search(holds) === -1 ? [] : [...text.matchAll(holds)].map(({ index: within }) => [kind, within])) : []
|
|
252
467
|
for (const [kind, within] of held) if (kind !== 'tokens' || within > 0 || keyed()) at(kind, base + index + within)
|
|
253
468
|
if (text.includes('registry.') && REGISTRY_URL.test(unescaped(text).replace(URL_STRIPPED, ''))) at('registry', base + index)
|
|
254
469
|
for (const [kind, name] of MODULES) if (name.test(string) && loaded(code, index)) at(kind, base + index)
|
|
470
|
+
if (PROFILE.test(string) && HOME_BEFORE.test(code.slice(Math.max(0, index - 100), index))) at('persistence', base + index)
|
|
471
|
+
for (const [part, dir, file] of PARTS) {
|
|
472
|
+
if (dir.test(string)) at(`${part}-dir`, base + index)
|
|
473
|
+
if (file.test(string)) at(`${part}-file`, base + index)
|
|
474
|
+
}
|
|
255
475
|
if (program && ARGUMENTS_BETWEEN.test(code.slice(end, index))) {
|
|
256
476
|
if (PUBLISH.test(string)) at('publish', base + index)
|
|
257
477
|
program = OPTION.test(string) || option
|
|
258
478
|
option = OPTION.test(string)
|
|
259
479
|
} else program = false
|
|
260
480
|
if (PROGRAM.test(string)) [program, option] = [true, false]
|
|
481
|
+
if (command && command.arguments < 20 && VALUES_BETWEEN.test(code.slice(end, index))) {
|
|
482
|
+
const argument = text.slice(1, -1)
|
|
483
|
+
command.line += ` ${SEPARATES.test(argument) ? `"${argument.replaceAll('"', "'")}"` : argument}`
|
|
484
|
+
command.arguments++
|
|
485
|
+
const { line } = command
|
|
486
|
+
const hit = SHELL_HOLDS.find(([, holds]) => line.search(holds) !== -1)
|
|
487
|
+
if (hit) {
|
|
488
|
+
at(hit[0], command.at)
|
|
489
|
+
command = null
|
|
490
|
+
}
|
|
491
|
+
} else command = null
|
|
492
|
+
if (COMMAND.test(string)) command = { arguments: 0, at: base + index, line: string.slice(1, -1) }
|
|
493
|
+
if (before && before.text.length + text.length < 200 && ARGUMENTS_BETWEEN.test(code.slice(end, index))) {
|
|
494
|
+
const [first, second] = [before.text.slice(1, -1), text.slice(1, -1)]
|
|
495
|
+
if (!PLACE.test(first) && !PLACE.test(second) && PLACE.test(`${first}/${second}`)) at('persistence', before.at)
|
|
496
|
+
}
|
|
497
|
+
before = { text, at: base + index }
|
|
261
498
|
if (PUT.test(string) && METHOD.test(code.slice(Math.max(0, index - 300), index))) at('put', base + index)
|
|
262
499
|
end = index + token.length
|
|
263
500
|
}
|
package/risk/index.d.ts
CHANGED
|
@@ -116,6 +116,19 @@ export interface DependencyRisk {
|
|
|
116
116
|
gap?: number
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// A version's risk as one number (risk/npm/score.js): what its findings call
|
|
120
|
+
// for (`raised`, each it counts), each by its severity, malware all of it,
|
|
121
|
+
// lowered by its exposure (`lowered`): how long it has been out (`days`)
|
|
122
|
+
// among how many download its package (`tier`, its rank, null past the last
|
|
123
|
+
// or where unknown), `by` the share taken off. Its `level` is named as the
|
|
124
|
+
// findings' severities are: from 75 critical, 50 high, 25 moderate, else low.
|
|
125
|
+
export interface RiskScore {
|
|
126
|
+
score: number
|
|
127
|
+
level: 'critical' | 'high' | 'moderate' | 'low'
|
|
128
|
+
raised: { id: string; severity: Severity }[]
|
|
129
|
+
lowered?: { days: number; tier: number | null; by: number }
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
export interface NpmAssessment {
|
|
120
133
|
name: string
|
|
121
134
|
version: string
|
|
@@ -129,6 +142,7 @@ export interface NpmAssessment {
|
|
|
129
142
|
files: Record<string, FileRisk>
|
|
130
143
|
// Against `oldVersion`, each it holds too, flagged against none (`held`).
|
|
131
144
|
dependencies: { added: DependencyRisk[]; changed: DependencyRisk[]; held?: DependencyRisk[] }
|
|
145
|
+
score: RiskScore
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
export function assessNpm(name: string, version: string, options?: AssessNpmOptions): Promise<NpmAssessment>
|
package/risk/npm/assess.js
CHANGED
|
@@ -7,17 +7,18 @@ import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
|
|
|
7
7
|
import { npmHeldFindings, npmVersionFindings } from './held.js'
|
|
8
8
|
import { npmVersionFileRisk } from './files.js'
|
|
9
9
|
import { npmDependencyChanges, npmFlagDependencyChanges } from './dependencies.js'
|
|
10
|
-
import { npmDownloadMonths, npmSettledDownloads, npmTierLevel } from './downloads.js'
|
|
10
|
+
import { npmDownloadMonths, npmDownloadTier, npmSettledDownloads, npmTierLevel } from './downloads.js'
|
|
11
11
|
import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
|
|
12
12
|
import { npmMalwareFinding } from './malware.js'
|
|
13
13
|
import { npmKnownScope } from './manifest.js'
|
|
14
14
|
import { DEFAULT_MINIMUM_RELEASE_AGE, npmAccountStanding } from './release.js'
|
|
15
15
|
import { npmAddedDependency } from './new-dependency.js'
|
|
16
|
+
import { npmRiskScore } from './score.js'
|
|
16
17
|
|
|
17
18
|
// Which reading of a version an assessment kept (`risk/npm/assessments`) is:
|
|
18
19
|
// one more whenever a heuristic changes what it finds, so one kept by an
|
|
19
20
|
// older reading is read anew.
|
|
20
|
-
const RISK_VERSION =
|
|
21
|
+
const RISK_VERSION = 14
|
|
21
22
|
|
|
22
23
|
// How long an assessment kept may be as JSON, and what risk reads of either
|
|
23
24
|
// version's files: a package past it, whose files name thousands of hosts or
|
|
@@ -191,15 +192,18 @@ export async function assessNpm(name, version, { oldVersion = null, fromEmpty =
|
|
|
191
192
|
const malware = npmMalwareFinding({ takenDown: facts.takenDown, advisories: advisories ? await npmVersionAdvisories(name, version, osv, context) : [] })
|
|
192
193
|
if (!facts.versions.has(version)) {
|
|
193
194
|
if (!malware) throw new NpmRiskError('version-not-found', `npm has no ${name}@${version}`)
|
|
194
|
-
return { name, version, oldVersion: null, published: facts.removed.get(version), removed: true, findings: [malware], files: {}, dependencies: { added: [], changed: [] }
|
|
195
|
+
return { name, version, oldVersion: null, published: facts.removed.get(version), removed: true, findings: [malware], files: {}, dependencies: { added: [], changed: [] },
|
|
196
|
+
score: npmRiskScore([malware]) }
|
|
195
197
|
}
|
|
196
198
|
const { published, manifest } = facts.versions.get(version)
|
|
197
199
|
const kept = await lasting(name, version, old, facts, context, fromEmpty)
|
|
200
|
+
const counted = downloads && popularity ? await npmDownloads(name, context) : null
|
|
198
201
|
const own = npmVersionFindings({ deprecated: facts.deprecated[version], published, minimumReleaseAge, now, known: Boolean(manifest.knownAccounts), scope: npmKnownScope(name),
|
|
199
|
-
provenance: Boolean(manifest.provenance), downloads:
|
|
202
|
+
provenance: Boolean(manifest.provenance), downloads: counted })
|
|
200
203
|
const was = old === null ? null : facts.versions.get(old).published
|
|
201
204
|
const added = downloads && dependencies ? await addedDependencies(kept.dependencies.added, [was, published], context, owned) : kept.dependencies.added
|
|
202
|
-
|
|
205
|
+
const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added)
|
|
206
|
+
return { name, version, oldVersion: old, published, ...assessment, score: npmRiskScore(assessment.findings, { published, now, tier: counted ? npmDownloadTier(counted) : null }) }
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
// An assessment of what of it holds whatever the time (`kept`,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A version's risk as one number, out of 100: what its findings call for,
|
|
2
|
+
// lowered by how long it has been out among how many who would have noticed.
|
|
3
|
+
// Its findings stay what a reviewer reads; the score orders what to read
|
|
4
|
+
// first.
|
|
5
|
+
import { DAY_MS } from './downloads.js'
|
|
6
|
+
|
|
7
|
+
// How much each finding raises the score, by its severity: a version's
|
|
8
|
+
// findings combined as independent chances are (1 − Π(1 − weight)), so that
|
|
9
|
+
// each adds less the more there are, none past 100. One red finding alone is
|
|
10
|
+
// 50; two, or one with two orange, past 75, as attacks show.
|
|
11
|
+
const WEIGHTS = { critical: 0.5, high: 0.2, moderate: 0.08, low: 0.03, notice: 0 }
|
|
12
|
+
// What the version's age tells instead (`too-new`), as exposure does below.
|
|
13
|
+
const TOLD_BY_AGE = new Set(['too-new'])
|
|
14
|
+
|
|
15
|
+
// How much a version's exposure lowers the score, at most: a version out for
|
|
16
|
+
// years among millions keeps a tenth of what its findings call for, as an
|
|
17
|
+
// attack that long among that many would have been found and pulled.
|
|
18
|
+
const MOST_LOWERED = 0.9
|
|
19
|
+
// From how old a version is lowered at all, and how old fully: two weeks, as
|
|
20
|
+
// risk's minimum release age (release.js), and two years, on a log scale.
|
|
21
|
+
const EXPOSED_FROM_DAYS = 14
|
|
22
|
+
const EXPOSED_DAYS = 730
|
|
23
|
+
// How much who downloads a package lowers it, by its tier (downloads.js): its
|
|
24
|
+
// rank by downloads. The top 200 fully; one few download hardly, as few would
|
|
25
|
+
// notice; one whose downloads aren't known (null, a caller's own package, or
|
|
26
|
+
// one npm counts none of) a little.
|
|
27
|
+
const TIER_EXPOSURE = [[200, 1], [1000, 0.95], [5000, 0.85], [20_000, 0.65], [100_000, 0.35], [Infinity, 0.1]]
|
|
28
|
+
const UNKNOWN_TIER_EXPOSURE = 0.25
|
|
29
|
+
|
|
30
|
+
// The score's levels, as the findings' severities name theirs.
|
|
31
|
+
const LEVELS = [[75, 'critical'], [50, 'high'], [25, 'moderate'], [0, 'low']]
|
|
32
|
+
export const npmRiskLevel = score => LEVELS.find(([from]) => score >= from)[1]
|
|
33
|
+
|
|
34
|
+
// How exposed a version has been, from 0 to 1: how long it has been out
|
|
35
|
+
// (`published` an ISO time, at `now`) times how widely its package is
|
|
36
|
+
// downloaded (`tier`, null where unknown).
|
|
37
|
+
export function npmExposure(published, now, tier) {
|
|
38
|
+
const days = typeof published === 'string' ? (now - Date.parse(published)) / DAY_MS : NaN
|
|
39
|
+
if (!(days > EXPOSED_FROM_DAYS)) return 0
|
|
40
|
+
const age = Math.min(1, Math.log(days / EXPOSED_FROM_DAYS) / Math.log(EXPOSED_DAYS / EXPOSED_FROM_DAYS))
|
|
41
|
+
const wide = tier === null ? UNKNOWN_TIER_EXPOSURE : TIER_EXPOSURE.find(([upTo]) => tier <= upTo)[1]
|
|
42
|
+
return age * wide
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A version's risk score (`score`, 0 to 100) and its `level`, from its
|
|
46
|
+
// assessment's `findings` and its exposure (npmExposure): 100 where malware
|
|
47
|
+
// is told of it, however old; else what its findings call for (`raised`, each
|
|
48
|
+
// `{ id, severity }` it counts), lowered by its exposure (`lowered`: its
|
|
49
|
+
// `days` out, its `tier`, and by how much, a share), none lowered under two
|
|
50
|
+
// weeks.
|
|
51
|
+
export function npmRiskScore(findings, { published = null, now = Date.now(), tier = null } = {}) {
|
|
52
|
+
if (findings.some(({ id }) => id === 'malware')) return { score: 100, level: 'critical', raised: [{ id: 'malware', severity: 'critical' }] }
|
|
53
|
+
const raised = findings.filter(({ id, severity }) => !TOLD_BY_AGE.has(id) && WEIGHTS[severity] > 0).map(({ id, severity }) => ({ id, severity }))
|
|
54
|
+
const called = 1 - raised.reduce((left, { severity }) => left * (1 - WEIGHTS[severity]), 1)
|
|
55
|
+
const exposure = npmExposure(published, now, tier)
|
|
56
|
+
const score = Math.round(100 * called * (1 - MOST_LOWERED * exposure))
|
|
57
|
+
return { score, level: npmRiskLevel(score), raised,
|
|
58
|
+
...exposure > 0 ? { lowered: { days: Math.floor((now - Date.parse(published)) / DAY_MS), tier: Number.isFinite(tier) ? tier : null, by: Math.round(100 * MOST_LOWERED * exposure) / 100 } } : {} }
|
|
59
|
+
}
|
|
@@ -72,7 +72,7 @@ export interface OpenedRepository {
|
|
|
72
72
|
recheck(): Promise<void>
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[] }
|
|
75
|
+
type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
|
|
76
76
|
|
|
77
77
|
export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
|
|
78
78
|
repos(): Promise<R[]>
|
|
@@ -100,13 +100,14 @@ type AdvisoryRow = Advisory & { malware: boolean }
|
|
|
100
100
|
type RiskFailure = { error: string; detail?: string }
|
|
101
101
|
|
|
102
102
|
// A risk line is of npm's package of its name and version, or, with an
|
|
103
|
-
// `integrity`, of a mirror's that its lockfiles hold to it
|
|
103
|
+
// `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
|
|
104
|
+
// of it, out of 100 (assessNpm `score`, risk/npm/score.js).
|
|
104
105
|
export type RepositoryRiskLine =
|
|
105
106
|
| { type: 'repos'; repos: { id: number; fullName: string }[] }
|
|
106
107
|
| ({ type: 'repo'; id: number } & ({ commit: string; lockfiles: { path: string; format: LockfileFormat }[] } | RiskFailure))
|
|
107
108
|
| ({ type: 'lockfile'; repo: number; path: string; format: LockfileFormat } & ({ packages: LockedPackage[] } | RiskFailure))
|
|
108
109
|
| ({ type: 'risk'; name: string; version: string; integrity?: string }
|
|
109
|
-
& ({ published: string | null; severity: Severity | null; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
|
|
110
|
+
& ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
|
|
110
111
|
| ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
|
|
111
112
|
| { type: 'done' }
|
|
112
113
|
|
|
@@ -198,9 +199,9 @@ async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped
|
|
|
198
199
|
try {
|
|
199
200
|
const read = doc ??= await documentOf(source, pkg, signal)
|
|
200
201
|
if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
|
|
201
|
-
const { published, findings } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
|
|
202
|
+
const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
|
|
202
203
|
const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
|
|
203
|
-
return { ...at, published, severity: highestSeverity(told), findings: told }
|
|
204
|
+
return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
|
|
204
205
|
} catch (error) {
|
|
205
206
|
if (signal.aborted) return null
|
|
206
207
|
if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
|
|
@@ -243,7 +244,8 @@ async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | nu
|
|
|
243
244
|
try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
|
|
244
245
|
const key = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
|
|
245
246
|
const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
|
|
246
|
-
|
|
247
|
+
// One kept before risk told a score (assessNpm `score`) is asked again.
|
|
248
|
+
if (kept && Date.now() - kept.at < RISK_KEPT_MS && ('error' in kept.line || typeof kept.line.score === 'number')) return kept.line
|
|
247
249
|
const line = await assess(scan, pkg, teamScoped)
|
|
248
250
|
if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
|
|
249
251
|
const at = Date.now()
|