@preventive/triage 1.0.0-alpha.40 → 1.0.0-alpha.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.40",
3
+ "version": "1.0.0-alpha.42",
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",
@@ -345,7 +346,7 @@
345
346
  "dependencies": {
346
347
  "@exodus/stasis": "1.0.0-beta.9",
347
348
  "@exodus/stasis-core": "1.0.0-beta.9",
348
- "@preventive/compare": "^1.2.0",
349
+ "@preventive/compare": "^1.2.3",
349
350
  "@preventive/diff": "^1.0.2",
350
351
  "@preventive/lockfile": "1.0.0",
351
352
  "@preventive/publishers": "^1.1.0",
@@ -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) or the whole environment, and publish to npm (`npm
5
- // publish`, `/-/whoami`, a registry PUT), as a file's stamps say
6
- // (stamps.js) and a version's findings name what is new to a file.
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
- // Any of RUNS_CODE, as a text may say it.
56
- const SAYS_ANY = new RegExp(String.raw`eval|Function${FUNCTION_CALLED}|vm["'\x60]|_compile|child_process|\.(?:npmrc|ssh|aws)|_TOKEN|(?:stringify|values|entries|URLSearchParams)${GAP}\(${GAP}(?:\{${GAP}\.\.\.${GAP})?process${GAP}\.${GAP}env|publish|whoami|registry\.(?:npmjs\.org|yarnpkg\.com)`, 'u')
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 command publishing (`npm publish`, `pnpm
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 HOLDS_ANY = /\.(?:npmrc|ssh|aws)|_TOKEN|publish|whoami|registry\./u
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 or token, `credentials`'),
186
- // and for one marked as worse than the others' warning, `critical`: the whole
187
- // environment read out, and publishing.
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
- if (!npmIsScript(path, text)) return null
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
- // A PUT request publishing where the file names the registry.
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
- // Where the string before ends; whether the strings since a program's name
231
- // are its arguments, an option or its value each, and whether the last is
232
- // an option.
233
- let end = 0, option = false, program = false
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
- const held = HOLDS_ANY.test(text) ? HOLDS.flatMap(([kind, holds]) => [...text.matchAll(holds)].map(({ index: within }) => [kind, within])) : []
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
  }
@@ -344,12 +581,20 @@ export function npmFoundAdded(found, was) {
344
581
  // a tag kinds share once: `eval, child_process`.
345
582
  export const npmRunsCodeKinds = founds => [...new Set([...RUNS_CODE].filter(([kind]) => founds.some(found => found.has(kind))).map(([, { tag }]) => tag))].join(', ')
346
583
 
347
- // What `found` holds (npmRunsCode's) but the kinds whose tags `tags` names,
348
- // each with its lines; null for none.
349
- export function npmRunsCodeWithout(found, tags) {
350
- const kept = new Map([...found ?? []].filter(([kind]) => !tags.has(RUNS_CODE.get(kind).tag)))
351
- return kept.size > 0 ? kept : null
584
+ // What `found` holds (npmRunsCode's) but the kinds whose tags `tags` names
585
+ // (npmRunsCodeWithout), or of those kinds alone (npmRunsCodeTagged), each
586
+ // with its lines; null for none.
587
+ function npmRunsCodeOf(found, kept) {
588
+ const of = new Map([...found ?? []].filter(([kind]) => kept(RUNS_CODE.get(kind).tag)))
589
+ return of.size > 0 ? of : null
352
590
  }
591
+ export const npmRunsCodeWithout = (found, tags) => npmRunsCodeOf(found, tag => !tags.has(tag))
592
+ export const npmRunsCodeTagged = (found, tags) => npmRunsCodeOf(found, tag => tags.has(tag))
593
+
594
+ // The kinds that reach for credentials, by their tag: a version's finding of
595
+ // their own (risk/npm/held.js `held-credentials`), apart from the code its
596
+ // files run beyond themselves.
597
+ export const CREDENTIAL_TAGS = new Set([CREDENTIALS.tag])
353
598
 
354
599
  // Its tags as a file's flags (file-flags.js), in their order, each once: a
355
600
  // warning's (`high`), or for one marked worse, `critical`; none for none.
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>
@@ -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 = 12
21
+ const RISK_VERSION = 16
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: downloads && popularity ? await npmDownloads(name, context) : null })
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
- return { name, version, oldVersion: old, published, ...npmAssessmentOf(kept, malware ? [malware, ...own] : own, added) }
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`,
@@ -25,12 +25,15 @@ const higher = (a, b) => SEVERITIES[Math.min(SEVERITIES.indexOf(a), SEVERITIES.i
25
25
  // scripts added or changed and what each version ran (`flagged`, `was`); a
26
26
  // dependency taken from elsewhere than npm's registry anew, its spec before
27
27
  // (`was`), or added that nothing imports (`added`). Code kept running beyond
28
- // itself (`kept-runs-code`) marks nothing, its files held already.
28
+ // itself or reaching for credentials (`kept-runs-code`, `kept-credentials`)
29
+ // marks nothing, its files held already.
29
30
  const byPath = (rows, key) => new Map(rows.map(row => [row.path, row[key]]))
30
31
  const marked = (held, rows) => ({ ...held, files: held.files.map(row => rows.has(row.path) ? { ...row, added: rows.get(row.path) } : row) })
31
32
  const FOLDS = {
32
33
  'runs-code': ['held-runs-code', (held, change) => marked(held, byPath(change.files, 'added'))],
33
34
  'kept-runs-code': ['held-runs-code', held => held],
35
+ credentials: ['held-credentials', (held, change) => marked(held, byPath(change.files, 'added'))],
36
+ 'kept-credentials': ['held-credentials', held => held],
34
37
  obfuscated: ['held-obfuscated', (held, change) => marked(held, byPath(change.files, 'obfuscation'))],
35
38
  'install-scripts': ['held-install-scripts', (held, change) => ({ ...held, flagged: change.flagged, was: [...change.was] })],
36
39
  'non-registry-deps': ['held-non-registry-deps', (held, change) => {
@@ -13,7 +13,7 @@ import { npmHostFinding } from './held.js'
13
13
  import { HOST_TAGS, npmHostSeverity, npmHostsTagged } from '../files/hosts.js'
14
14
  import { npmBecameUnreadable, npmBinaryCode, npmLargeCode, npmUnreadable } from '../files/readability.js'
15
15
  import { SEVERITIES } from '../files/stamps.js'
16
- import { npmFoundAdded, npmRunsCodeFlags, npmRunsCodeWithout } from '../files/runs-code.js'
16
+ import { CREDENTIAL_TAGS, npmFoundAdded, npmRunsCodeFlags, npmRunsCodeTagged, npmRunsCodeWithout } from '../files/runs-code.js'
17
17
  import { npmDependencySpecs } from './dependencies.js'
18
18
  import { npmFilesDiff } from './diff.js'
19
19
  import { npmInstallScripts } from './install-scripts.js'
@@ -21,7 +21,8 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
21
21
 
22
22
  // What the open version does to its files that calls for a look, as red
23
23
  // findings: code that runs beyond itself (runs-code.js), new to a file it
24
- // adds or changes, the kinds with their lines (`runs-code`); a service under
24
+ // adds or changes, the kinds with their lines (`runs-code`), those reaching
25
+ // for credentials apart (CREDENTIAL_TAGS, `credentials`); a service under
25
26
  // one of the hosts' tags new to a file it adds or changes (hosts.js
26
27
  // npmExfiltration), a finding a tag a level higher than its own
27
28
  // (npmHostSeverity: `exfiltration` and `remote` red, `ip-lookup` orange); a
@@ -33,7 +34,7 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
33
34
  // the one compared with's was text, or no code, or none (npmBinaryCode,
34
35
  // `binary-code`). Then in orange what any of its files runs beyond itself that
35
36
  // `runs-code` doesn't name, kept from the version compared with
36
- // (`kept-runs-code`). A renamed file by its old path; one renamed alone,
37
+ // (`kept-runs-code`, `kept-credentials`). A renamed file by its old path; one renamed alone,
37
38
  // unchanged, holds what it held. A file become unreadable calls for less
38
39
  // where its source map was rebuilt with it (`rebuilt`, rebuiltWithMap), as a
39
40
  // build minifies a file and writes its map together, and in a `major`
@@ -51,8 +52,11 @@ function npmFileFindings(base, other, files, major) {
51
52
  const exfiltration = gained('exfiltration'), runsCode = gained('runsCode')
52
53
  const flagged = new Set(runsCode.flatMap(row => npmRunsCodeFlags(row.added).map(({ name }) => name)))
53
54
  const kept = [...bundleFilesAsMap(other).keys()].map(path => ({ path, found: npmRunsCodeWithout(npmVersionFileRisk(other, path).runsCode, flagged) })).filter(row => row.found)
55
+ // Code that runs beyond itself, or of the kinds reaching for credentials.
56
+ const ofKinds = (rows, key, of) => rows.map(row => ({ ...row, [key]: of(row[key], CREDENTIAL_TAGS) })).filter(row => row[key])
54
57
  return [
55
- { id: 'runs-code', severity: 'critical', files: runsCode },
58
+ { id: 'runs-code', severity: 'critical', files: ofKinds(runsCode, 'added', npmRunsCodeWithout) },
59
+ { id: 'credentials', severity: 'critical', files: ofKinds(runsCode, 'added', npmRunsCodeTagged) },
56
60
  ...[...HOST_TAGS.keys()].map(tag => {
57
61
  const rows = exfiltration.map(row => ({ path: row.path, added: npmHostsTagged(row.added, tag) })).filter(row => row.added)
58
62
  return { id: npmHostFinding(tag), severity: rows.length > 0 ? npmHostSeverity(tag, rows[0].added, null) : HOST_TAGS.get(tag), files: rows }
@@ -65,7 +69,8 @@ function npmFileFindings(base, other, files, major) {
65
69
  .map(row => ({ path: row.path, obfuscation: after(row).readability.obfuscation })) },
66
70
  { id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability) && !npmBinaryCode(row.renamedFrom ?? row.path, before(row)?.readability))
67
71
  .map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
68
- { id: 'kept-runs-code', severity: 'high', files: kept },
72
+ { id: 'kept-runs-code', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeWithout) },
73
+ { id: 'kept-credentials', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeTagged) },
69
74
  ].filter(finding => finding.files.length > 0)
70
75
  }
71
76