@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-cache.ts +37 -4
  130. package/server-managed/bundle-commits.ts +140 -0
  131. package/server-managed/bundle-summary-cache.ts +24 -10
  132. package/server-managed/config.ts +4 -0
  133. package/server-managed/db-methods.ts +98 -11
  134. package/server-managed/db-neon.ts +39 -4
  135. package/server-managed/db-schema.ts +14 -2
  136. package/server-managed/db-table-names.ts +1 -1
  137. package/server-managed/db.ts +7 -3
  138. package/server-managed/dependency-reviews.ts +248 -0
  139. package/server-managed/github-issue-oauth.ts +5 -6
  140. package/server-managed/github-metadata.ts +115 -0
  141. package/server-managed/github-oauth.ts +38 -2
  142. package/server-managed/http-response.ts +26 -0
  143. package/server-managed/http.ts +943 -52
  144. package/server-managed/index.ts +11 -5
  145. package/server-managed/kept.ts +28 -0
  146. package/server-managed/locked-packages.ts +85 -0
  147. package/server-managed/lru.ts +30 -0
  148. package/server-managed/npm-insights.ts +440 -0
  149. package/server-managed/npm-kept.ts +65 -0
  150. package/server-managed/npm-loads.ts +167 -0
  151. package/server-managed/npm-manifest.ts +115 -0
  152. package/server-managed/npm-packages.ts +378 -0
  153. package/server-managed/npm-provenance.ts +123 -0
  154. package/server-managed/npm-risk.ts +177 -0
  155. package/server-managed/pretty-print.ts +352 -0
  156. package/server-managed/public-workspace.ts +78 -7
  157. package/server-managed/rename-diff-worker.js +26 -0
  158. package/server-managed/rename-diff.ts +270 -0
  159. package/server-managed/report-sources.ts +13 -2
  160. package/server-managed/repository-browser.ts +19 -5
  161. package/server-managed/repository-risk.ts +413 -0
  162. package/server-managed/session.ts +16 -2
  163. package/server-managed/sql.ts +5 -2
  164. package/server-managed/storage-encryption.ts +5 -3
  165. package/server-managed/storage-payload.ts +4 -0
  166. package/server-managed/storage-stores.ts +2 -0
  167. package/server-managed/storage.ts +6 -3
  168. package/server-managed/team-app.ts +94 -0
  169. package/server-managed/team-feed.ts +1 -1
  170. package/server-managed/team-npm-scopes.ts +77 -0
  171. package/server-managed/type-only-worker.js +19 -0
  172. package/server-managed/type-only.ts +103 -0
  173. package/server-managed/uploads.ts +25 -4
  174. package/server-managed/upstream-cache.ts +147 -5
  175. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,222 @@
1
+ // What calls for a look at a package version against the one before it, as
2
+ // findings: `{ id, severity, ... }` each (stamps.js SEVERITIES), what it found
3
+ // beside. Each version as risk holds one (`{ kind: 'sourcemap', npm, json,
4
+ // fileSizes? }`: npm's facts, `npm.manifest` as manifest.js reads a version's
5
+ // document, its files as a source map lists them, `json.sources` and
6
+ // `json.sourcesContent`, a text or null each, and their sizes by path where
7
+ // known), and `files` how they differ (diff.js npmFilesDiff): `onlyOther`, the
8
+ // files the open version adds, and `changed`, those it changes, each `{ path,
9
+ // renamedFrom?, modified? }`, by its path in the open version.
10
+ import { bundleFileSizes, bundleFilesAsMap } from '../../common/bundle-sources.js'
11
+ import { npmVersionFileRisk, npmVersionHosts } from './files.js'
12
+ import { npmHostFinding } from './held.js'
13
+ import { HOST_TAGS, npmHostSeverity, npmHostsTagged } from '../files/hosts.js'
14
+ import { npmBecameUnreadable, npmLargeCode, npmUnreadable } from '../files/readability.js'
15
+ import { SEVERITIES } from '../files/stamps.js'
16
+ import { npmFoundAdded, npmRunsCodeFlags, npmRunsCodeWithout } from '../files/runs-code.js'
17
+ import { npmDependencySpecs } from './dependencies.js'
18
+ import { npmFilesDiff } from './diff.js'
19
+ import { npmInstallScripts } from './install-scripts.js'
20
+ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from './release.js'
21
+
22
+ // What the open version does to its files that calls for a look, as red
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
25
+ // one of the hosts' tags new to a file it adds or changes (hosts.js
26
+ // npmExfiltration), a finding a tag a level higher than its own
27
+ // (npmHostSeverity: `exfiltration` and `remote` red, `ip-lookup` orange); a
28
+ // code file of 256 KiB or more it adds (npmLargeCode, `large-code`); a file
29
+ // it changes that had no line over 200 characters and now has one over 2,000
30
+ // (npmBecameUnreadable, `now-unreadable`); and a file it adds or changes with
31
+ // an obfuscator's fingerprint where the one compared with had none
32
+ // (`obfuscated`). Then in orange what any of its files runs beyond itself that
33
+ // `runs-code` doesn't name, kept from the version compared with
34
+ // (`kept-runs-code`). A renamed file by its old path; one renamed alone,
35
+ // unchanged, holds what it held. A file become unreadable calls for less
36
+ // where its source map was rebuilt with it (`rebuilt`, rebuiltWithMap), as a
37
+ // build minifies a file and writes its map together, and in a `major`
38
+ // release (npmMajorRelease): `now-unreadable` orange where every such file is
39
+ // either, else red.
40
+ function npmFileFindings(base, other, files, major) {
41
+ const added = new Set(files.onlyOther)
42
+ const after = row => npmVersionFileRisk(other, row.path)
43
+ const before = row => added.has(row) ? null : npmVersionFileRisk(base, row.renamedFrom ?? row.path)
44
+ const changed = files.changed.filter(row => row.renamedFrom == null || row.modified)
45
+ const touched = [...files.onlyOther, ...changed]
46
+ const sizes = bundleFileSizes(other)
47
+ // The files that hold what they didn't before, each with that.
48
+ const gained = key => touched.map(row => ({ path: row.path, added: npmFoundAdded(after(row)[key], before(row)?.[key] ?? null) })).filter(row => row.added)
49
+ const exfiltration = gained('exfiltration'), runsCode = gained('runsCode')
50
+ const flagged = new Set(runsCode.flatMap(row => npmRunsCodeFlags(row.added).map(({ name }) => name)))
51
+ const kept = [...bundleFilesAsMap(other).keys()].map(path => ({ path, found: npmRunsCodeWithout(npmVersionFileRisk(other, path).runsCode, flagged) })).filter(row => row.found)
52
+ return [
53
+ { id: 'runs-code', severity: 'critical', files: runsCode },
54
+ ...[...HOST_TAGS.keys()].map(tag => {
55
+ const rows = exfiltration.map(row => ({ path: row.path, added: npmHostsTagged(row.added, tag) })).filter(row => row.added)
56
+ return { id: npmHostFinding(tag), severity: rows.length > 0 ? npmHostSeverity(tag, rows[0].added, null) : HOST_TAGS.get(tag), files: rows }
57
+ }),
58
+ { id: 'large-code', severity: 'critical', files: files.onlyOther.filter(row => npmLargeCode(row.path, sizes.get(row.path))).map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
59
+ { id: 'now-unreadable', ...unreadableFinding(major, changed.filter(row => npmBecameUnreadable(row.path, before(row)?.readability, after(row).readability))
60
+ .map(row => ({ path: row.path, was: before(row).readability.longest, now: after(row).readability.longest,
61
+ ...rebuiltWithMap(base, other, row.path, row.renamedFrom ?? row.path) ? { rebuilt: true } : {} }))) },
62
+ { id: 'obfuscated', severity: 'critical', files: touched.filter(row => after(row).readability.obfuscation && !before(row)?.readability.obfuscation)
63
+ .map(row => ({ path: row.path, obfuscation: after(row).readability.obfuscation })) },
64
+ { id: 'kept-runs-code', severity: 'high', files: kept },
65
+ ].filter(finding => finding.files.length > 0)
66
+ }
67
+
68
+ // Hosts whose naming anew calls for no look: where packages keep their code
69
+ // and docs, as their links and error messages name them, the most a version
70
+ // names anew of popular packages a year apart (lit.dev, Lit's), and none
71
+ // whose content anyone may publish (a wiki's raw pages) but github.com's
72
+ // pages, as hosts.js githubNamed names them: a repository's own URL, its
73
+ // files and releases are named by it. Each as it is written, not a host
74
+ // under it, so one an attack sends to or fetches from there
75
+ // (`api.github.com`, `raw.githubusercontent.com`) is still named; and no link
76
+ // shortener (`git.io`, `git.new`), which can lead anywhere.
77
+ const ALLOWED_HOSTS = new Set(['github.com', 'lit.dev', 'developer.mozilla.org', 'json-schema.org', 'nodejs.org', 'babeljs.io', 'tseslint.com',
78
+ 'devblogs.microsoft.com', 'semver.org'])
79
+
80
+ // A severity one level lower (stamps.js SEVERITIES).
81
+ const lower = severity => SEVERITIES[Math.min(SEVERITIES.indexOf(severity) + 1, SEVERITIES.length - 1)]
82
+
83
+ // Whether the open version (`now`) is a major release past the one compared
84
+ // with (`was`): its major number higher, or its minor for 0.x. A rewrite
85
+ // changes how a package's files read and who publishes it, and npm's `^`
86
+ // ranges don't take a major release by themselves, as a hijacked release
87
+ // wants them to.
88
+ function npmMajorRelease(was, now) {
89
+ const [a, b] = [was, now].map(version => /^v?(\d+)\.(\d+)\./u.exec(version ?? '')?.slice(1).map(Number))
90
+ return Boolean(a && b) && (b[0] > a[0] || (a[0] === 0 && b[0] === 0 && b[1] > a[1]))
91
+ }
92
+
93
+ // Whether the open version's file at `path` (`basePath` in the one compared
94
+ // with) had its source map rebuilt with it: its last line, a
95
+ // `sourceMappingURL` comment and no more, names a map file of the package by
96
+ // a plain path from the file's own (MAP_NAME), a source map
97
+ // (its `mappings`, or each of an indexed map's `sections`, told), new or
98
+ // changed from the one at its path, or beside the file's own before it was
99
+ // renamed, as a build writes them together. An attack editing a built file
100
+ // leaves its map as it was.
101
+ const SOURCE_MAP_FILE = /^\s*(?:\/\/[#@] sourceMappingURL=(\S+)|\/\*[#@] sourceMappingURL=(\S+?)\s*\*\/)\s*$/u
102
+ // A map name a build writes (`a.js.map`, `../maps/a.map`): segments of
103
+ // letters, digits and `._~@+-`, as a browser reads them alike. One at a URL,
104
+ // from a site's root, or with an escape or a backslash names none.
105
+ const MAP_NAME = /^(?:[\w.~@+-]+\/)*[\w.~@+-]+\.map$/u
106
+ function besideFile(file, name) {
107
+ const parts = file.split('/').slice(0, -1)
108
+ for (const part of name.split('/')) {
109
+ if (part === '..' && parts.length === 0) return null
110
+ if (part === '..') parts.pop()
111
+ else if (part !== '.') parts.push(part)
112
+ }
113
+ return parts.join('/')
114
+ }
115
+ function rebuiltWithMap(base, other, path, basePath) {
116
+ const text = bundleFilesAsMap(other).get(path)
117
+ const directive = typeof text === 'string' ? SOURCE_MAP_FILE.exec(text.trimEnd().split('\n').at(-1)) : null
118
+ const name = directive?.[1] ?? directive?.[2]
119
+ if (!name || !MAP_NAME.test(name)) return false
120
+ const [map, was] = [bundleFilesAsMap(other).get(besideFile(path, name)), bundleFilesAsMap(base)]
121
+ return isSourceMap(map) && map !== was.get(besideFile(path, name)) && map !== was.get(besideFile(basePath, name))
122
+ }
123
+ const isSourceMap = text => {
124
+ try {
125
+ const json = JSON.parse(text)
126
+ return typeof json?.mappings === 'string'
127
+ || (Array.isArray(json?.sections) && json.sections.length > 0 && json.sections.every(section => typeof section?.map?.mappings === 'string'))
128
+ } catch { return false }
129
+ }
130
+
131
+ // The files become unreadable as a finding says them: red, but orange in a
132
+ // major release, or where each had its source map rebuilt with it.
133
+ const unreadableFinding = (major, files) => ({ severity: major || files.every(row => row.rebuilt) ? 'high' : 'critical', ...major ? { major } : {}, files })
134
+
135
+ // The hosts the open version's code names in its URLs that the one compared
136
+ // with's named in none of its, ALLOWED_HOSTS aside, each `{ host, path, line,
137
+ // places }`: the file it is first named in and its line, and `places` every
138
+ // file that names it, `{ path, line }` each, the line it is first named on
139
+ // there. A file unchanged names what it named, so the files the open version
140
+ // adds or changes hold all it names anew.
141
+ function npmNewHosts(base, other, files) {
142
+ const found = new Map(), known = npmVersionHosts(base)
143
+ for (const { path } of [...files.onlyOther, ...files.changed]) {
144
+ for (const [host, line] of npmVersionFileRisk(other, path).hosts ?? []) {
145
+ if (known.has(host) || ALLOWED_HOSTS.has(host)) continue
146
+ const place = { path, line }
147
+ if (found.has(host)) found.get(host).places.push(place)
148
+ else found.set(host, { host, ...place, places: [place] })
149
+ }
150
+ }
151
+ return [...found.values()]
152
+ }
153
+
154
+ // Whether the files of the version compared with could change what
155
+ // npmCompareFindings finds of the open version (`other`), told before they
156
+ // are read, by the open version's files and the manifest npm's document of
157
+ // the one compared with gives (`manifest`): what its files call for
158
+ // (npmFileFindings, npmNewHosts) is only ever what the open version's own
159
+ // files hold that the one compared with's didn't, so where none of them runs
160
+ // code beyond itself, names a service under one of the hosts' tags or a host
161
+ // past ALLOWED_HOSTS, bears an obfuscator's fingerprint, has a line no one
162
+ // writes (npmUnreadable) or is large code, nothing is found of them whatever
163
+ // the one compared with holds. Nor do the one compared with's install scripts
164
+ // differ by a binding.gyp among its files, but where its manifest names
165
+ // neither `preinstall` nor `install`, and the open version has some.
166
+ export function npmBaseFilesTell(other, manifest) {
167
+ if (npmInstallScripts(other.npm.manifest, other.json.sources).length > 0
168
+ && npmInstallScripts(manifest).length !== npmInstallScripts(manifest, ['binding.gyp']).length) return true
169
+ const sizes = bundleFileSizes(other)
170
+ return [...bundleFilesAsMap(other).keys()].some(path => {
171
+ const { runsCode, exfiltration, readability, hosts } = npmVersionFileRisk(other, path)
172
+ return Boolean(runsCode || exfiltration || readability.obfuscation) || npmUnreadable(path, readability) || npmLargeCode(path, sizes.get(path))
173
+ || [...hosts?.keys() ?? []].some(host => !ALLOWED_HOSTS.has(host))
174
+ })
175
+ }
176
+
177
+ // Of the dependencies the open version adds and changes against `base`, or
178
+ // alone, none given, each with its flags (dependencies.js
179
+ // npmFlagDependencyChanges): those it takes from elsewhere than npm's
180
+ // registry, where the one compared with didn't, or by another spec
181
+ // (`non-registry-deps`, each `{ name, was, now }`, its specs); and those it
182
+ // adds that nothing of it imports, which npm installs all the same, its
183
+ // install scripts and all, as a dependency added to run them would be
184
+ // (`unused-deps`, each `{ name, kind, range }`).
185
+ export function npmDependencyFindings({ added, changed }, base) {
186
+ const flagged = check => [...added, ...changed].filter(row => row.flags.some(flag => flag.check === check))
187
+ const was = base ? npmDependencySpecs(base.npm.manifest) : null
188
+ const fetched = flagged('source').map(row => ({ name: row.name, was: was?.get(row.name) ?? null, now: row.to ?? row.range }))
189
+ const unimported = flagged('unimported').map(({ name, kind, range }) => ({ name, kind, range }))
190
+ return [
191
+ ...fetched.length > 0 ? [{ id: 'non-registry-deps', severity: 'high', dependencies: fetched }] : [],
192
+ ...unimported.length > 0 ? [{ id: 'unused-deps', severity: 'high', dependencies: unimported }] : [],
193
+ ]
194
+ }
195
+
196
+ // Everything that calls for a look at the open version (`other`) against the
197
+ // one compared with (`base`), whatever the time, in the order a reviewer reads
198
+ // them: a publisher that changed (release.js npmPublisherChange,
199
+ // `publisher`, each account's standing on the package told where `standing`
200
+ // is given), a level lower in a major release (npmMajorRelease, `major`),
201
+ // as a package changing hands with a rewrite does; provenance lost (`provenance-lost`); install scripts added or
202
+ // changed (`install-scripts`); its `dependencies` added and changed, as
203
+ // flagged (npmDependencyFindings); its files, by how they differ (diff.js
204
+ // npmFilesDiff, npmFileFindings); and the hosts its code names anew
205
+ // (npmNewHosts, `new-hosts`). A `base` without its files (`json` null),
206
+ // where npmBaseFilesTell says they would change nothing, is found nothing of
207
+ // by them.
208
+ export function npmCompareFindings(base, other, dependencies, standing = null) {
209
+ const major = npmMajorRelease(base.npm.version, other.npm.version)
210
+ const publisher = npmPublisherChange(base.npm.manifest, other.npm.manifest, standing)
211
+ const scripts = npmInstallScriptsChange(...[base, other].map(version => ({ manifest: version.npm.manifest, paths: version.json?.sources })))
212
+ const files = base.json ? npmFilesDiff(base, other) : null
213
+ const hosts = files ? npmNewHosts(base, other, files) : []
214
+ return [
215
+ ...publisher ? [{ id: 'publisher', ...publisher, ...major ? { severity: lower(publisher.severity), major } : {} }] : [],
216
+ ...npmProvenanceLost(base.npm.manifest, other.npm.manifest) ? [{ id: 'provenance-lost', severity: 'critical' }] : [],
217
+ ...scripts ? [{ id: 'install-scripts', severity: 'high', ...scripts }] : [],
218
+ ...npmDependencyFindings(dependencies, base),
219
+ ...files ? npmFileFindings(base, other, files, major) : [],
220
+ ...hosts.length > 0 ? [{ id: 'new-hosts', severity: 'high', hosts }] : [],
221
+ ]
222
+ }
@@ -0,0 +1,153 @@
1
+ // What calls for a look among the dependencies a package version names: one
2
+ // npm fetches from elsewhere than its registry, and one nothing of the
3
+ // version imports, which npm installs all the same, running its install
4
+ // scripts, as a dependency added to run them alone would be.
5
+ import { isNpmPackageName } from '../../common/managed/npm-packages.js'
6
+ import { NPM_PROSE } from '../files/readability.js'
7
+ import { npmAlias, npmDependencySource, npmRegistrySpec } from './specs.js'
8
+ import { npmInstallScripts } from './install-scripts.js'
9
+
10
+ // The kinds of dependency a manifest names, by its field (npmDependencies).
11
+ const NPM_DEPENDENCY_KINDS = [['dependencies', null], ['peerDependencies', 'peer'], ['optionalDependencies', 'optional']]
12
+
13
+ // A version's dependencies, each `{ name, range, kind, opens }`, in name
14
+ // order: `kind` null for a plain dependency, else 'peer' or 'optional';
15
+ // `opens` the package the viewer opens for it, the one an `npm:` alias
16
+ // names, or null when it names none, or where npm doesn't install it from
17
+ // its registry (specs.js npmRegistrySpec: a path, a repository, a URL),
18
+ // whose package of that name isn't what it installs.
19
+ export function npmDependencies(manifest) {
20
+ const rows = []
21
+ for (const [field, kind] of NPM_DEPENDENCY_KINDS) {
22
+ for (const [name, range] of Object.entries(manifest?.[field] ?? {})) {
23
+ const opens = npmAlias(range)?.name ?? name
24
+ rows.push({ name, range, kind, opens: isNpmPackageName(opens) && npmRegistrySpec(range) ? opens : null })
25
+ }
26
+ }
27
+ return rows.toSorted((a, b) => a.name.localeCompare(b.name) || (a.kind ?? '').localeCompare(b.kind ?? ''))
28
+ }
29
+
30
+ // What two versions' dependencies differ in, by name and kind: those only
31
+ // `base` has, those only `other` has, and those whose range changed.
32
+ export function npmDependencyChanges(base, other) {
33
+ const index = manifest => new Map(npmDependencies(manifest).map(row => [`${row.kind ?? ''}\0${row.name}`, row]))
34
+ const after = index(other), before = index(base)
35
+ const added = [], changed = [], removed = []
36
+ for (const [key, { name, kind, range, opens }] of before) {
37
+ const next = after.get(key)
38
+ if (!next) removed.push({ key, name, kind, range, opens })
39
+ else if (next.range !== range) changed.push({ key, name, kind, from: range, to: next.range, opens: next.opens })
40
+ }
41
+ for (const [key, { name, kind, range, opens }] of after) if (!before.has(key)) added.push({ key, name, kind, range, opens })
42
+ return { removed, added, changed }
43
+ }
44
+
45
+ // Files no import is read from: prose, as the Overview tells it
46
+ // (readability.js NPM_PROSE; not code named alike, as
47
+ // `lib/history.js`), but MDX, which imports what it renders; source maps,
48
+ // which quote whatever their sources import, and lockfiles, which name every
49
+ // package installed; nor the package's own package.json, which names them all.
50
+ const MDX = /\.mdx$/iu
51
+ const NO_IMPORTS = /(?:\.map|(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml))$/iu
52
+ // A quote, then a run of the characters names and paths are made of (after
53
+ // webpack's `~` too), and what ends it: a quote, escaped too, as a quote in
54
+ // a quoted command is in binding.gyp (`require(\'nan\')`), or a template's `${`.
55
+ const QUOTED = /["'`]~?([\w@./-]+)(\\?["'`]|\$\{)?/gu
56
+
57
+ // What of a version names a package as an import would, once a version, in
58
+ // one pass over its code files however many names are asked about: `whole`,
59
+ // each name quoted alone or before a path in it (`'name'`, `"name/sub"`);
60
+ // `parts`, each part of a name up to a `-` or `/` that a quote or a
61
+ // template's `${` ends, as a package builds a name from one to pick the
62
+ // build for the platform it runs on (`` `@rollup/rollup-${platform}` ``,
63
+ // `"@typescript/" + name`); and `commands`, the install scripts npm runs.
64
+ const importsByVersion = new WeakMap()
65
+ function importsOf({ json, npm }) {
66
+ let found = importsByVersion.get(json)
67
+ if (found) return found
68
+ const sources = json.sources ?? [], texts = json.sourcesContent ?? []
69
+ found = { whole: new Set(), parts: new Set(), commands: npmInstallScripts(npm?.manifest, sources).map(([, command]) => command) }
70
+ for (const [i, path] of sources.entries()) {
71
+ if (typeof texts[i] !== 'string' || path === 'package.json' || (NPM_PROSE.test(path) && !MDX.test(path)) || NO_IMPORTS.test(path)) continue
72
+ for (const [, run, end] of texts[i].matchAll(QUOTED)) {
73
+ if (end && '-/'.includes(run.at(-1))) found.parts.add(run)
74
+ if (end && end !== '${') found.whole.add(run)
75
+ for (let at = run.indexOf('/'); at !== -1; at = run.indexOf('/', at + 1)) found.whole.add(run.slice(0, at))
76
+ }
77
+ }
78
+ importsByVersion.set(json, found)
79
+ return found
80
+ }
81
+
82
+ // Whether a command names `name`: as a word (`prebuild-install || node-gyp
83
+ // rebuild`), or as the package installed in `node_modules` that a path
84
+ // leads into (`node node_modules/name/setup.js`); not as part of another
85
+ // path (`node scripts/name.js`, `node_modules/@scope/name`).
86
+ function commandNames(command, name) {
87
+ for (let at = command.indexOf(name); at !== -1; at = command.indexOf(name, at + 1)) {
88
+ const before = command.slice(0, at)
89
+ if ((!/[\w@./-]$/u.test(before) || /node_modules\/(?:\.bin\/)?$/u.test(before)) && !/^[\w.-]/u.test(command.slice(at + name.length))) return true
90
+ }
91
+ return false
92
+ }
93
+
94
+ // DefinitelyTyped's stub of a package's types, once the package has types
95
+ // of its own (`@types/mkdirp` 2.0.0), depending on that package, the
96
+ // registry's by its name (`spec`, no `npm:` alias of another, no repository,
97
+ // URL or path): named as DefinitelyTyped publishes the package's types
98
+ // (`@types/name`, a scoped one's `@types/scope__name`), with nothing but
99
+ // prose beside its package.json, no declarations of its own.
100
+ const typesOf = name => `@types/${name.startsWith('@') ? name.slice(1).replace('/', '__') : name}`
101
+ const typesStubOf = ({ json, npm }, name, spec) => npm?.name === typesOf(name) && npmRegistrySpec(spec) && (npmAlias(spec)?.name ?? name) === name
102
+ && (json.sources ?? []).every(path => path === 'package.json' || (NPM_PROSE.test(path) && !MDX.test(path)))
103
+
104
+ // Whether nothing of a version (`details`, its files and manifest) imports
105
+ // a dependency it names (`{ name, kind, range }`, or a changed one's `to`,
106
+ // npm-package.js npmDependencies): no code file names it as an import
107
+ // would, whole or by a part of it, nor does an install script npm runs
108
+ // (importsOf); never where its files aren't read. Not a peer dependency,
109
+ // which the package expects whoever installs it to provide, nor types from
110
+ // DefinitelyTyped (`@types/`), imported by another name, where no one else
111
+ // publishes; nor the package a stub of DefinitelyTyped's types of it
112
+ // depends on (typesStubOf), with no code to import it.
113
+ export function npmDependencyUnimported(details, { name, kind, range, to }) {
114
+ if (!details?.json || kind === 'peer' || name.startsWith('@types/') || typesStubOf(details, name, to ?? range)) return false
115
+ const { whole, parts, commands } = importsOf(details)
116
+ return !whole.has(name) && ![...name.slice(1).matchAll(/[-/]/gu)].some(({ index }) => parts.has(name.slice(0, index + 2)))
117
+ && !commands.some(command => commandNames(command, name))
118
+ }
119
+
120
+ // Where a source takes a dependency from, in a sentence.
121
+ const SOURCE_NAMES = new Map([['URL', 'a URL'], ['git', 'a git repository']])
122
+
123
+ // What calls for a look at a dependency a version (`details`) names
124
+ // (`{ name, kind, range }`, or a changed one's `to`), as tags
125
+ // (`{ check, label, tooltip }`): `source` where npm fetches it from
126
+ // elsewhere than its registry (npmDependencySource), its spec in the
127
+ // tooltip, and `unimported` where nothing of the version imports it
128
+ // (npmDependencyUnimported). Against the specs another version named its
129
+ // dependencies by (`was`, npmDependencySpecs), only where it named this one
130
+ // by another spec, or, imported by nothing, not at all.
131
+ export function npmDependencyFlags(details, row, was = null) {
132
+ const spec = row.to ?? row.range
133
+ const source = npmDependencySource(spec)
134
+ return [
135
+ ...source && was?.get(row.name) !== spec ? [{ check: 'source', label: 'non-registry', tooltip: `From ${SOURCE_NAMES.get(source) ?? source}, not npm's registry: ${spec}` }] : [],
136
+ ...!was?.has(row.name) && npmDependencyUnimported(details, row) ? [{ check: 'unimported', label: 'unimported',
137
+ tooltip: `${was ? `New in ${details.npm?.version}, and nothing of it` : `Nothing of ${details.npm?.version}`} imports it: no code file names it as an import would, nor does an install script` }] : [],
138
+ ]
139
+ }
140
+
141
+ // The specs a manifest names its dependencies by, by name, whatever their
142
+ // kind.
143
+ export const npmDependencySpecs = manifest => new Map(NPM_DEPENDENCY_KINDS.flatMap(([field]) => Object.entries(manifest?.[field] ?? {})))
144
+
145
+ // Two versions' dependency changes (npm-package.js npmDependencyChanges),
146
+ // each the open version (`other`) adds or changes with its `flags`
147
+ // (npmDependencyFlags) against the one compared with (`base`), or alone,
148
+ // none given.
149
+ export function npmFlagDependencyChanges({ removed, added, changed }, base, other) {
150
+ const was = base ? npmDependencySpecs(base.npm?.manifest) : null
151
+ const flag = row => ({ ...row, flags: npmDependencyFlags(other, row, was) })
152
+ return { removed, added: added.map(flag), changed: changed.map(flag) }
153
+ }
@@ -0,0 +1,43 @@
1
+ // How two versions' files differ, as Compare's diff has them
2
+ // (ui/view/bundle-compare-diff.js computeBundleDiff), for risk's findings
3
+ // (compare.js): the files the open version adds, and those it changes, a file
4
+ // moved to another path paired with its old one by content, as git diff -M
5
+ // pairs them (@preventive/compare compareTrees).
6
+ import { compareTrees } from '@preventive/compare'
7
+ import { bundleFilesAsMap } from '../../common/bundle-sources.js'
8
+
9
+ // git's diff.renameLimit for a package's renames: past this many removed files
10
+ // by added ones, squared, no pair is scored, and only identical content pairs
11
+ // them.
12
+ const RENAME_LIMIT = 250
13
+
14
+ // Whether two sides hold the same file: text as text, one that isn't by its
15
+ // digest (bundle-compare-diff.js sameContent).
16
+ const same = (a, b) => a === b || (a?.format === 'digest' && b?.format === 'digest' && a.digest === b.digest)
17
+
18
+ // A file's content as compareTrees reads it to find renames: its text, its
19
+ // digest for one that isn't, or else its place in its list, so that only its
20
+ // path pairs it (bundle-compare-diff.js treeData).
21
+ const treeData = (content, at) => typeof content === 'string' ? `\0text\0${content.toWellFormed()}`
22
+ : content?.format === 'digest' ? `\0digest\0${content.digest}` : `\0none\0${at}`
23
+
24
+ // `onlyOther`, the files the open version (`other`) adds, `{ path }` each, and
25
+ // `changed`, those it changes, `{ path, modified }`, or moves, `{ path,
26
+ // renamedFrom, modified }`, `modified` whether its content changed too; each
27
+ // version as compare.js takes one. A file that isn't text is
28
+ // told by its digest (npmBinaries), as the viewer's Compare tells it.
29
+ export function npmFilesDiff(base, other) {
30
+ const after = bundleFilesAsMap(other), before = bundleFilesAsMap(base)
31
+ const changed = [], onlyBase = [], onlyOther = []
32
+ for (const [path, text] of after) {
33
+ if (!before.has(path)) onlyOther.push(path)
34
+ else if (!same(before.get(path), text)) changed.push({ path, modified: true })
35
+ }
36
+ for (const path of before.keys()) if (!after.has(path)) onlyBase.push(path)
37
+ const entries = (paths, files, side) => paths.filter(path => path.isWellFormed()).map((path, at) => ({ name: path, data: treeData(files.get(path), `${side}${at}`) }))
38
+ const renames = compareTrees(entries(onlyBase, before, 'a'), entries(onlyOther, after, 'b'), { paths: 'keys', renameLimit: RENAME_LIMIT, lines: false })
39
+ .filter(change => change.status === 'renamed')
40
+ const moved = new Set(renames.map(change => change.new.path))
41
+ for (const change of renames) changed.push({ path: change.new.path, renamedFrom: change.old.path, modified: change.similarity !== 100 })
42
+ return { onlyOther: onlyOther.filter(path => !moved.has(path)).map(path => ({ path })), changed }
43
+ }
@@ -0,0 +1,84 @@
1
+ // A package's downloads, as npm's downloads API counts them a day
2
+ // (`{ start, end, days }`, the first day's date and a count a day from it):
3
+ // in weeks and months, the tier among npm's packages they put it in, and a
4
+ // dependency's to the day before its latest two weeks.
5
+
6
+ export const DAY_MS = 24 * 60 * 60_000
7
+ // The date `offset` days past a count's first day (`start`).
8
+ export const dayOf = (start, offset) => new Date(Date.parse(start) + offset * DAY_MS)
9
+
10
+ // The downloads in 7-day weeks ending on the last day, oldest first, a
11
+ // partial oldest week left out: `{ from, to, total }`, dates as `Date`s.
12
+ export function npmDownloadWeeks(downloads) {
13
+ const weeks = []
14
+ if (!downloads?.days) return weeks
15
+ for (let end = downloads.days.length - 1; end >= 6; end -= 7) {
16
+ let total = 0
17
+ for (let day = end - 6; day <= end; day++) total += downloads.days[day]
18
+ weeks.push({ from: dayOf(downloads.start, end - 6), to: dayOf(downloads.start, end), total })
19
+ }
20
+ return weeks.toReversed()
21
+ }
22
+
23
+ // The downloads in calendar months (UTC), oldest first, the partial months
24
+ // at either end left out: `{ from, to, total }`, as npmDownloadWeeks has them.
25
+ export function npmDownloadMonths(downloads) {
26
+ const months = []
27
+ if (!downloads?.days) return months
28
+ const start = Date.parse(downloads.start)
29
+ let month = null
30
+ downloads.days.forEach((count, i) => {
31
+ const day = new Date(start + i * DAY_MS)
32
+ if (day.getUTCDate() === 1) {
33
+ month = { from: day, to: day, total: 0 }
34
+ months.push(month)
35
+ }
36
+ if (month) { month.total += count; month.to = day }
37
+ })
38
+ const last = months.at(-1)
39
+ if (last && new Date(last.to.getTime() + DAY_MS).getUTCDate() !== 1) months.pop()
40
+ return months
41
+ }
42
+
43
+ // The weekly downloads from which a package is among npm's top 10, 20, 50,
44
+ // 100, …, 100,000 packages, rounded: download-counts' snapshot of a month's
45
+ // downloads (Jan 30 – Feb 28, 2026), each rank's count grown by the median
46
+ // growth of the packages ranked around it to the 30 days Sep 9 – Oct 8,
47
+ // 2026, as npm counts them, days it failed to count among them, then made a
48
+ // week. An estimate, to be revisited. Below the last, a package is
49
+ // unpopular (Infinity).
50
+ const TIERS = [
51
+ [10, 550e6], [20, 405e6], [50, 285e6], [100, 225e6], [200, 165e6], [500, 95e6], [1000, 50e6], [2000, 20e6], [5000, 4e6],
52
+ [10_000, 850e3], [20_000, 155e3], [50_000, 18e3], [100_000, 2e3], [Infinity, 0],
53
+ ]
54
+
55
+ // The tier a package's downloads put it in (TIERS), by the most it had in any
56
+ // of its latest six 7-day weeks (npmDownloadWeeks): a week lower for a day
57
+ // npm failed to count, as it now and then does, or for a holiday, is passed
58
+ // over. Infinity for an unpopular one; null without a week.
59
+ export function npmDownloadTier(downloads) {
60
+ const latest = npmDownloadWeeks(downloads).slice(-6)
61
+ if (latest.length === 0) return null
62
+ const most = Math.max(...latest.map(week => week.total))
63
+ return TIERS.find(([, from]) => most >= from)[0]
64
+ }
65
+
66
+ // A tier's name, and its tint: solid green to the top 200, green to the top
67
+ // 5,000, yellow to the top 20,000, orange to the top 100,000, and red for
68
+ // the unpopular, a package few would notice changing.
69
+ export const npmTierLevel = tier => tier <= 200 ? 'is-solid' : tier <= 5000 ? 'is-top' : tier <= 20_000 ? 'is-mid' : tier <= 100_000 ? 'is-low' : 'is-unpopular'
70
+ // A dependency's downloads to the day before its latest two weeks: a
71
+ // package a release just pulled in, as a compromised one depending on it
72
+ // would, could owe those to that release alone. Its own package's tier goes
73
+ // by all of them, its Overview charting them.
74
+ const DEPENDENCY_DAYS_LEFT_OUT = 14
75
+ export function npmSettledDownloads(downloads, now = Date.now()) {
76
+ if (!downloads) return null
77
+ const first = Math.floor(Date.parse(downloads.start) / DAY_MS), last = Math.floor(now / DAY_MS) - 1 - DEPENDENCY_DAYS_LEFT_OUT
78
+ const days = downloads.days.slice(0, Math.max(0, last - first + 1))
79
+ return { start: downloads.start, end: new Date((first + days.length - 1) * DAY_MS).toISOString().slice(0, 10), days }
80
+ }
81
+
82
+ // A tier orange or red, past the top 20,000: one few would notice changing,
83
+ // as a release past the top 20,000 is flagged (compare.js).
84
+ export const npmTierLow = tier => tier > 20_000
@@ -0,0 +1,34 @@
1
+ // What risk reads of each file of a version, once a version (stamps.js
2
+ // npmFileRisk), and every host its scripts name: for compare.js's findings
3
+ // and held.js's, and for the viewer, which reads its files' tags from it.
4
+ import { bundleFileSizes, bundleFilesAsMap } from '../../common/bundle-sources.js'
5
+ import { npmFileRisk } from '../files/stamps.js'
6
+
7
+ // What a version's files are kept by: its bundle where it is one, else its
8
+ // files' list, else itself.
9
+ const keyOf = version => version.bundle ?? version.json ?? version
10
+
11
+ // What risk reads of each file of a version (stamps.js npmFileRisk), once a
12
+ // version and path.
13
+ const reads = new WeakMap()
14
+ export function npmVersionFileRisk(version, path) {
15
+ let files = reads.get(keyOf(version))
16
+ if (!files) reads.set(keyOf(version), files = new Map())
17
+ if (!files.has(path)) {
18
+ const text = bundleFilesAsMap(version).get(path)
19
+ files.set(path, npmFileRisk(path, typeof text === 'string' ? text : null, bundleFileSizes(version).get(path)))
20
+ }
21
+ return files.get(path)
22
+ }
23
+
24
+ // Every host a version's scripts name (hosts.js npmHosts), once a version.
25
+ const versionHosts = new WeakMap()
26
+ export function npmVersionHosts(version) {
27
+ let hosts = versionHosts.get(keyOf(version))
28
+ if (!hosts) {
29
+ hosts = new Set()
30
+ for (const path of bundleFilesAsMap(version).keys()) for (const host of npmVersionFileRisk(version, path).hosts?.keys() ?? []) hosts.add(host)
31
+ versionHosts.set(keyOf(version), hosts)
32
+ }
33
+ return hosts
34
+ }
@@ -0,0 +1,60 @@
1
+ // What calls for a look at a version on its own, as the npm Overview shows
2
+ // it: what npm says of it and when it was published (npmVersionFindings),
3
+ // and what it holds that calls for a look whatever came before it
4
+ // (npmHeldFindings), as Compare's findings (compare.js) say what changed.
5
+ import { bundleFilesAsMap } from '../../common/bundle-sources.js'
6
+ import { npmDownloadTier, npmTierLevel, npmTierLow } from './downloads.js'
7
+ import { npmInstallScripts } from './install-scripts.js'
8
+ import { npmTooNew } from './release.js'
9
+ import { HOST_TAGS, npmHostsTagged } from '../files/hosts.js'
10
+ import { npmVersionFileRisk } from './files.js'
11
+
12
+ // What npm says of a version and of its package, told fresh as it changes in
13
+ // time: deprecated, `deprecated` its message (`deprecated`, red); younger
14
+ // than `minimumReleaseAge` at `now`, published at `published` (release.js
15
+ // npmTooNew, `too-new`, orange); and its package past the top 20,000 by
16
+ // `downloads` (downloads.js, `popularity`, red where unpopular, else orange),
17
+ // `tier` its rank (Infinity past the last); yellow at most, whatever its
18
+ // tier, where an account known to publish one of npm's most downloaded
19
+ // packages published the version (`known`, manifest.js `knownAccounts`), as
20
+ // few download a new package such an account starts; or else, as many are
21
+ // published by a CI that names no account, where the version was published
22
+ // with provenance (`provenance`, manifest.js), built where it says, as a
23
+ // token alone, stolen, publishes none, and the package is in a known scope
24
+ // (`scope`, manifest.js npmKnownScope: one of those packages', or one of
25
+ // those accounts' own, its `account` named), whose owner alone publishes in
26
+ // it.
27
+ export function npmVersionFindings({ deprecated, published, minimumReleaseAge, now, downloads, known = false, scope = null, provenance = false }) {
28
+ const tier = downloads ? npmDownloadTier(downloads) : null
29
+ const scoped = provenance ? scope : null
30
+ const severity = known || scoped ? 'moderate' : npmTierLevel(tier) === 'is-unpopular' ? 'critical' : 'high'
31
+ return [
32
+ ...deprecated ? [{ id: 'deprecated', severity: 'critical', message: deprecated }] : [],
33
+ ...published && npmTooNew(published, { minimumReleaseAge, now }) ? [{ id: 'too-new', severity: 'high', published }] : [],
34
+ ...tier !== null && npmTierLow(tier) ? [{ id: 'popularity', severity, tier, ...known ? { known } : scoped ? { known: true, ...scoped } : {} }] : [],
35
+ ]
36
+ }
37
+
38
+ // What a version holds that calls for a look, whatever came before it, in
39
+ // orange, as its files' tags are: the scripts npm runs installing it
40
+ // (install-scripts.js, `held-install-scripts`, `[script, command]` each); and
41
+ // its files, each with what it holds, that run code beyond themselves
42
+ // (`held-runs-code`), name a service under one of the hosts' tags, at the
43
+ // tag's severity (hosts.js HOST_TAGS: `held-exfiltration` red, `held-remote`
44
+ // orange, `held-ip-lookup` yellow), or bear an obfuscator's fingerprint
45
+ // (`held-obfuscated`).
46
+ export function npmHeldFindings(version) {
47
+ const scripts = npmInstallScripts(version.npm.manifest, version.json?.sources)
48
+ const reads = [...bundleFilesAsMap(version).keys()].map(path => ({ path, read: npmVersionFileRisk(version, path) }))
49
+ const holding = of => reads.map(({ path, read }) => ({ path, found: of(read) })).filter(row => row.found)
50
+ return [
51
+ ...scripts.length > 0 ? [{ id: 'held-install-scripts', severity: 'high', scripts }] : [],
52
+ { id: 'held-runs-code', severity: 'high', files: holding(read => read.runsCode) },
53
+ ...[...HOST_TAGS].map(([tag, severity]) => ({ id: `held-${npmHostFinding(tag)}`, severity, files: holding(read => npmHostsTagged(read.exfiltration, tag)) })),
54
+ { id: 'held-obfuscated', severity: 'high', files: holding(read => read.readability.obfuscation) },
55
+ ].filter(finding => !finding.files || finding.files.length > 0)
56
+ }
57
+
58
+ // A hosts' tag's finding, as its id names it: `exfiltration`, `remote`,
59
+ // `ip-lookup`.
60
+ export const npmHostFinding = tag => tag.toLowerCase().replaceAll(' ', '-')
@@ -0,0 +1,33 @@
1
+ // The scripts npm runs as it installs a package: what release.js flags added
2
+ // or changed, and the npm viewer lists (its Overview, and Compare's tags on a
3
+ // package.json).
4
+
5
+ // The scripts npm runs installing a package from the registry, in the order
6
+ // it runs them; not `prepare`, which runs only for a local or git install.
7
+ export const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
8
+
9
+ // A package's install scripts, `[script, command]`, in the order npm runs
10
+ // them: those `scripts` names, and `node-gyp rebuild` where the package has
11
+ // a `binding.gyp` at its root (`bindingGyp`) and neither install script of
12
+ // its own, as npm runs then.
13
+ export function installScripts(scripts, bindingGyp = false) {
14
+ const named = { ...scripts }
15
+ if (!named.preinstall && !named.install && bindingGyp) named.install = 'node-gyp rebuild'
16
+ return INSTALL_SCRIPTS.filter(script => typeof named[script] === 'string').map(script => [script, named[script]])
17
+ }
18
+
19
+ // An npm version's install scripts: those its manifest's `installScripts`
20
+ // names, and node-gyp's where `paths`, its files' (a list or a set), have a
21
+ // binding.gyp at its root.
22
+ export function npmInstallScripts(manifest, paths = []) {
23
+ return installScripts(manifest?.installScripts, Boolean(paths && (paths instanceof Set ? paths.has('binding.gyp') : paths.includes('binding.gyp'))))
24
+ }
25
+
26
+ // A package.json's install scripts, from its text; none where it isn't one,
27
+ // or names none.
28
+ export function packageJsonInstallScripts(text, bindingGyp = false) {
29
+ let json = null
30
+ try { json = typeof text === 'string' ? JSON.parse(text) : null } catch {}
31
+ const scripts = json && typeof json === 'object' && json.scripts && typeof json.scripts === 'object' ? json.scripts : {}
32
+ return installScripts(scripts, bindingGyp)
33
+ }