@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,36 @@
1
+ // What tells of malware in a version: npm's security team taking its package
2
+ // down, whatever version is asked, as it does for malicious code; and an
3
+ // advisory of malware naming the version, npm's or OSV's (registry.js
4
+ // npmVersionAdvisories).
5
+
6
+ // Whether an advisory is of malware rather than a vulnerability: OSV's of the
7
+ // malicious packages OpenSSF lists, each `MAL-…`, or one such a record names
8
+ // (its `aliases`, as upstream's `osv` gives npm's advisory it names); GitHub
9
+ // files its malware advisories as CWE-506, embedded malicious code, whatever
10
+ // their title ("Critical severity vulnerability that affects event-stream
11
+ // and flatmap-stream"); and its title may say so where its CWE doesn't
12
+ // (@solana/web3.js's GHSA-jcxm-7wvp-g6p5, CWE-200 alone).
13
+ export const npmMalwareAdvisory = ({ id, aliases, cwe, title }) => [id, ...aliases ?? []].some(name => (name ?? '').startsWith('MAL-'))
14
+ || (cwe ?? []).includes('CWE-506') || /\bmalware\b/iu.test(title ?? '')
15
+
16
+ // Whether `json`, a version's document, is the placeholder npm publishes in
17
+ // place of a package its security team took down: a "security holding
18
+ // package" from npm/security-holder, published by npm, the account npm's
19
+ // registry names as its publisher, which no package's author can. Not one
20
+ // by another account, such as the `0.0.1-security` versions 2017's hacktask
21
+ // typosquats are listed as theirs, each npm's own `0.0.2-security` after it.
22
+ export function npmPlaceholder(json) {
23
+ const repository = typeof json?.repository === 'string' ? json.repository : json?.repository?.url
24
+ const npms = json?.description === 'security holding package' || /(?:^|[/:])npm\/security-holder(?:\.git)?\/?$/iu.test(typeof repository === 'string' ? repository : '')
25
+ return npms && json?._npmUser?.name === 'npm'
26
+ }
27
+
28
+ // The `malware` finding of a version, critical: its package taken down by npm
29
+ // (`takenDown`, its latest version npm's placeholder), and the advisories of
30
+ // malware among `advisories` (`{ id, url?, title?, cwe }`), each `{ id, url?,
31
+ // title? }` once; null for neither.
32
+ export function npmMalwareFinding({ takenDown, advisories }) {
33
+ const named = new Map(advisories.filter(npmMalwareAdvisory).map(({ id, url, title }) => [id, { id, ...url ? { url } : {}, ...title ? { title } : {} }]))
34
+ if (!takenDown && named.size === 0) return null
35
+ return { id: 'malware', severity: 'critical', ...takenDown ? { takenDown: true } : {}, ...named.size > 0 ? { advisories: [...named.values()] } : {} }
36
+ }
@@ -0,0 +1,65 @@
1
+ // What risk reads of a version from npm's version document, as the npm viewer
2
+ // is served it (server-managed/npm-packages.ts npmManifest): the npm account
3
+ // that published it, or the CI that did by npm's trusted publishing, which of
4
+ // its accounts are known to publish (@preventive/publishers), whether npm
5
+ // keeps a provenance statement for it, the scripts npm runs installing it,
6
+ // its dependencies, and its deprecation; and whether a package's scope is one
7
+ // of npm's most downloaded packages'.
8
+ import publishers from '@preventive/publishers/npm.js'
9
+ import scopes from '@preventive/publishers/npm-scopes.js'
10
+ import { INSTALL_SCRIPTS } from './install-scripts.js'
11
+
12
+ const NPM_ACCOUNT = /^[\w.-]{1,214}$/u
13
+ // The npm accounts known to publish: those with standing on one of npm's
14
+ // most downloaded packages.
15
+ const KNOWN_PUBLISHERS = new Set(publishers.map(name => name.toLowerCase()))
16
+ // The scopes of npm's most downloaded packages, which only their owners, an
17
+ // npm organization or account, publish in; and those accounts' own, by
18
+ // scope: npm's accounts and organizations share one namespace, so `@name` is
19
+ // the account `name`'s.
20
+ const KNOWN_SCOPES = new Set(scopes)
21
+ const PUBLISHER_SCOPES = new Map(publishers.map(name => [`@${name.toLowerCase()}`, name]))
22
+
23
+ // A package's scope where it is known: one of npm's most downloaded
24
+ // packages' (`{ scope }`, `@babel` of `@babel/core`), or else a known
25
+ // publisher's own (`{ scope, account }`, `@sindresorhus` of
26
+ // `@sindresorhus/is`); else null.
27
+ export function npmKnownScope(name) {
28
+ const scope = name.startsWith('@') ? name.slice(0, name.indexOf('/')) : null
29
+ if (!scope) return null
30
+ if (KNOWN_SCOPES.has(scope)) return { scope }
31
+ const account = PUBLISHER_SCOPES.get(scope)
32
+ return account ? { scope, account } : null
33
+ }
34
+ const plainObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
35
+ const stringRecord = value => plainObject(value) ? Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === 'string')) : undefined
36
+
37
+ // One of package.json's people's name: `{ name }`, or `Name <email> (url)` in
38
+ // one string.
39
+ function personName(value) {
40
+ const text = part => typeof part === 'string' && part.trim() !== '' ? part.trim() : null
41
+ if (plainObject(value)) return text(value.name)
42
+ return typeof value === 'string' ? text(/^([^<(]*)/u.exec(value)?.[1]) : null
43
+ }
44
+
45
+ // A version document's manifest, as risk reads it: `publisher` (or
46
+ // `trustedPublisher`), `knownAccounts`, `provenance`, `installScripts`,
47
+ // `dependencies`, `peerDependencies` and `optionalDependencies`, and
48
+ // `deprecated`, each where it has one.
49
+ export function npmVersionManifest(json) {
50
+ const pick = {}
51
+ if (typeof json?.deprecated === 'string') pick.deprecated = json.deprecated
52
+ const publisher = personName(json?._npmUser)
53
+ if (publisher && NPM_ACCOUNT.test(publisher)) pick.publisher = publisher
54
+ else if (publisher && publisher.length <= 64 && plainObject(json._npmUser.trustedPublisher)) pick.trustedPublisher = publisher
55
+ if (pick.publisher && KNOWN_PUBLISHERS.has(pick.publisher.toLowerCase())) pick.knownAccounts = [pick.publisher]
56
+ for (const key of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
57
+ const record = stringRecord(json?.[key])
58
+ if (record && Object.keys(record).length > 0) pick[key] = record
59
+ }
60
+ if (plainObject(json?.dist) && plainObject(json.dist.attestations) && plainObject(json.dist.attestations.provenance)) pick.provenance = true
61
+ const scripts = stringRecord(json?.scripts)
62
+ const install = scripts ? INSTALL_SCRIPTS.filter(script => script in scripts) : []
63
+ if (install.length > 0) pick.installScripts = Object.fromEntries(install.map(script => [script, scripts[script]]))
64
+ return pick
65
+ }
@@ -0,0 +1,30 @@
1
+ // What risk tells of a dependency a version adds, by its package: its tier by
2
+ // its downloads, its latest two weeks' left out (downloads.js
3
+ // npmSettledDownloads), as a package a release just pulled in, as a
4
+ // compromised one depending on it would, could owe those to that release
5
+ // alone; and below the top 20,000, how old the package was where it didn't
6
+ // exist when the version compared with was published, as plain-crypto-js
7
+ // was eighteen hours old when axios 1.14.1 was published depending on it.
8
+ import { npmDownloadTier, npmSettledDownloads, npmTierLow } from './downloads.js'
9
+
10
+ // A dependency added, from its package's facts so far: `downloads`, npm's
11
+ // count of them (null where npm counts none, as it counts none of a package's
12
+ // until the day after it was first published, nor any of a private one's);
13
+ // `listed`, once its version list is read, `{ created, private }`, when its
14
+ // first version was published and whether only a token reads it; and
15
+ // `published`, when the version compared with and the open one were. Its
16
+ // `tier` (Infinity, unpopular, where npm counts none of a public package's;
17
+ // null where it can't yet be told), whether its version list is `wanted` to
18
+ // tell more (below the top 20,000, or none counted), and `created`, `gap`
19
+ // where its package is new since the version compared with: when its first
20
+ // version was published, and how long before the open version that was
21
+ // (negative for after, as a name someone took once a release named it).
22
+ export function npmAddedDependency({ downloads, listed = null, published = [] }, now = Date.now()) {
23
+ const settled = npmSettledDownloads(downloads, now)
24
+ const counted = settled ? npmDownloadTier(settled) : null
25
+ const wanted = !settled || (counted !== null && npmTierLow(counted))
26
+ const tier = settled ? counted : listed && !listed.private ? Infinity : null
27
+ const [was, open] = published
28
+ const created = wanted && tier !== null && listed?.created && was && open && Date.parse(listed.created) > Date.parse(was) ? listed.created : null
29
+ return { tier, wanted, created, gap: created ? Date.parse(open) - Date.parse(created) : null }
30
+ }
@@ -0,0 +1,108 @@
1
+ // The version an open version is compared with where the caller leaves it to
2
+ // risk (assess.js `oldVersion: true`): of the package's others, the one most
3
+ // likely free of what an attack injects, as a pair's findings flag only what
4
+ // is new against it, so an attack's own earlier version, or one published
5
+ // with it, would hide what it injected. Told by what npm's document says of
6
+ // each version alone (registry.js npmPackageFacts): when it was published,
7
+ // its publisher and whether that account is known (manifest.js), its
8
+ // deprecation, and those since removed from npm, whose times it keeps; no
9
+ // tarball is read for it.
10
+ import { DAY_MS } from './downloads.js'
11
+ import { npmAccountStanding } from './release.js'
12
+
13
+ // How long a version is on npm before it is taken as looked at: a month, as
14
+ // an attack's versions are mostly found and pulled within days.
15
+ const SETTLED_MS = 30 * DAY_MS
16
+ // How near the open version's publish, either side, a version comes with it:
17
+ // a week, as an attack publishes its versions within days of each other.
18
+ const CLOSE_MS = 7 * DAY_MS
19
+ // How near a version since removed from npm, either side, a version comes
20
+ // with it: a day, as an attack's versions come within hours of each other,
21
+ // and npm removes those it finds.
22
+ const REMOVED_MS = DAY_MS
23
+
24
+ // A version as npm publishes one, `{ core, pre }`: its MAJOR.MINOR.PATCH, each
25
+ // to 16 digits and a safe integer as semver takes them, and its prerelease's
26
+ // identifiers, build metadata aside; null for any other.
27
+ const VERSION = /^(0|[1-9]\d{0,15})\.(0|[1-9]\d{0,15})\.(0|[1-9]\d{0,15})(?:-([\da-z-]+(?:\.[\da-z-]+)*))?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?$/iu
28
+ function parsed(version) {
29
+ const match = VERSION.exec(version)
30
+ const core = match?.slice(1, 4).map(Number)
31
+ return core?.every(Number.isSafeInteger) ? { core, pre: match[4]?.split('.') ?? [] } : null
32
+ }
33
+
34
+ // Which of two versions is higher, by semver's precedence: their cores, a
35
+ // release above its prereleases, and prereleases by their identifiers, a
36
+ // number below a word.
37
+ function compared(a, b) {
38
+ const core = a.core.findIndex((part, at) => part !== b.core[at])
39
+ if (core !== -1) return a.core[core] - b.core[core]
40
+ if (a.pre.length === 0 || b.pre.length === 0) return b.pre.length - a.pre.length
41
+ for (let at = 0; at < Math.max(a.pre.length, b.pre.length); at++) {
42
+ const x = a.pre[at], y = b.pre[at]
43
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1
44
+ if (x === y) continue
45
+ const xNumber = /^\d+$/u.test(x), yNumber = /^\d+$/u.test(y)
46
+ if (xNumber !== yNumber) return xNumber ? -1 : 1
47
+ if (xNumber && x.length !== y.length) return x.length - y.length
48
+ return x < y ? -1 : 1
49
+ }
50
+ return 0
51
+ }
52
+
53
+ // Of a package's versions (`releases`, `{ version, published, publisher,
54
+ // known, deprecated, removed }` each, `removed` one since removed from npm,
55
+ // its time alone kept), the one `version` is compared with at `now`, or null
56
+ // where none is below it. Each account by its standing on the package when
57
+ // the open version was published (release.js npmAccountStanding), the most
58
+ // first: known, one that publishes one of npm's most downloaded packages;
59
+ // one of its maintainers, on it three months or more, or first to publish
60
+ // it; and new to it. Of those below it, a release where it is one, the
61
+ // highest that is preferred, each preference kept only where some version
62
+ // holds it, in turn:
63
+ //
64
+ // - Where an account new to it published it, one another published, as an
65
+ // attack may have published more than one.
66
+ // - One published before it, not after, as one published since (a
67
+ // backport) is no version it came from.
68
+ // - Where the highest of those an account of a higher standing than its own
69
+ // published, as when a package is taken from its maintainers, one an
70
+ // account of that standing or higher published.
71
+ // - One published a day or more apart from any version since removed from
72
+ // npm, as not one of the attack npm removed those of.
73
+ // - One published a week or more apart from it, as not one of an attack's,
74
+ // and on npm a month or more, as looked at; but for one a known account
75
+ // other than its own published, trusted however new, and one a maintainer
76
+ // other than its own published, held to the week alone, as an account taken
77
+ // leaves another's versions alone, and a maintainer few know of is taken
78
+ // with its peers as often. Its own account's are held to both, as it may be
79
+ // the one taken.
80
+ // - One npm doesn't say is deprecated, as one a maintainer took back.
81
+ //
82
+ // The highest is in its branch (`1.x.y`, `0.2.y`, `0.0.y`) where one is
83
+ // there, else in the closest branch below.
84
+ const RANKS = { new: 0, maintainer: 1, known: 2 }
85
+ export function npmOldVersion(version, releases, now) {
86
+ const open = releases.find(row => row.version === version && !row.removed)
87
+ const at = open && parsed(version)
88
+ if (!at) return null
89
+ const prefer = (rows, wanted) => { const kept = rows.filter(wanted); return kept.length > 0 ? kept : rows }
90
+ const time = row => row.published === null ? Number.NaN : Date.parse(row.published)
91
+ const opened = time(open)
92
+ const versions = releases.flatMap(row => { const of = parsed(row.version); return of ? [{ ...row, of }] : [] })
93
+ const removed = versions.filter(row => row.removed).map(time).filter(Number.isFinite)
94
+ const standing = npmAccountStanding(releases, open.published)
95
+ const rank = row => row?.publisher ? RANKS[standing(row.publisher, row.known)] : RANKS.new
96
+ const own = rank(open)
97
+ let rows = prefer(versions.filter(row => !row.removed && compared(row.of, at) < 0).toSorted((a, b) => compared(b.of, a.of)),
98
+ row => row.of.pre.length === 0 || at.pre.length > 0)
99
+ if (open.publisher && own === RANKS.new) rows = prefer(rows, row => row.publisher !== open.publisher)
100
+ rows = prefer(rows, row => time(row) < opened)
101
+ const top = rank(rows[0])
102
+ if (open.publisher && top > own) rows = rows.filter(row => rank(row) >= top)
103
+ rows = prefer(rows, row => removed.every(gone => Math.abs(time(row) - gone) >= REMOVED_MS))
104
+ const vouched = row => row.publisher === open.publisher ? RANKS.new : rank(row)
105
+ rows = prefer(prefer(rows, row => vouched(row) === RANKS.known || Math.abs(time(row) - opened) >= CLOSE_MS),
106
+ row => vouched(row) >= RANKS.maintainer || now - time(row) >= SETTLED_MS)
107
+ return prefer(rows, row => !row.deprecated)[0]?.version ?? null
108
+ }
@@ -0,0 +1,282 @@
1
+ // What risk asks npm for, each kept in the caller's cache where it gives one
2
+ // (`cache`, as @preventive/upstream's CacheStore: `read(type, key)` and
3
+ // `write(type, key, value)`, each type its caller's to keep where it will):
4
+ //
5
+ // - `npm/tarballs`, `<name>@<version>`: a version's tarball, its bytes, as
6
+ // upstream's getTarball keeps one, checked against its integrity.
7
+ // - `risk/npm/versions`, `<name>@<version>`: what a version's document says,
8
+ // which npm never changes once published: its manifest (manifest.js, its
9
+ // deprecation aside), when it was published, and its dist.
10
+ // - `risk/npm/packages`, `<name>`: the versions npm says are deprecated, those
11
+ // it removed (each one's when), and whether it took the package down, as
12
+ // `at` a time, asked again after an hour, as npm deprecates and removes a
13
+ // version any time.
14
+ // - `risk/npm/old-versions`, `<name>@<version>`: the version it is compared
15
+ // with where risk picks it (old-version.js npmOldVersion), or none, as `at`
16
+ // a time, picked again after an hour, as npm publishes and deprecates
17
+ // versions any time and those it has grow older.
18
+ // - `risk/npm/public`, `<name>`: when a package anyone can read was first
19
+ // published, or `{ missing: true }`, as `at` a time, asked again after an
20
+ // hour.
21
+ // - `risk/npm/downloads`, `<name>@<from>:<to>`: its downloads a day over those
22
+ // days, `{ start, end, days }`, or `{ none: true }` where npm counts none.
23
+ //
24
+ // The package assessed is asked for as upstream asks, with the npm token the
25
+ // environment has for a scoped one (PREVENTIVE_MEASURES_NPM_TOKEN,
26
+ // STASIS_NPM_TOKEN or NPM_TOKEN), so a private one can be read; its
27
+ // dependencies' public facts and downloads with none. A caller may ask for
28
+ // packages' documents (`packument`) and versions' tarballs (`tarball`)
29
+ // itself, as the managed server does, within the bounds it holds every read
30
+ // of npm to; else a tarball is upstream's getTarball's, bounded as upstream
31
+ // bounds it. What risk asks npm itself is given up on after 30 seconds, and
32
+ // read no further than its bounds, `upstream-too-large` past them, as is a
33
+ // tarball unpacking past tarball.js's, `package-too-large`.
34
+ import { advisories } from '@preventive/upstream/advisories.js'
35
+ import { getTarball } from '@preventive/upstream/npm.js'
36
+ import { npmVersionManifest } from './manifest.js'
37
+ import { npmPlaceholder } from './malware.js'
38
+ import { npmOldVersion } from './old-version.js'
39
+ import { npmTarballVersion } from './tarball.js'
40
+ import { DAY_MS } from './downloads.js'
41
+
42
+ const REGISTRY = 'https://registry.npmjs.org'
43
+ const DOWNLOADS = 'https://api.npmjs.org/downloads/range'
44
+ const PACKAGE_MS = 60 * 60_000
45
+ // How long a version's advisories are kept, as the managed server keeps a
46
+ // package's and a lockfile's: an hour, as a new one may come at any time.
47
+ const ADVISORIES_MS = 60 * 60_000
48
+ // How much of npm's answers is read: a whole document, as the managed server
49
+ // reads one (server-managed/npm-packages.ts PACKUMENT_BYTES), and a few
50
+ // months' downloads a day.
51
+ const PACKUMENT_BYTES = 64 * 1024 * 1024
52
+ const DOWNLOADS_BYTES = 1024 * 1024
53
+ // How long npm is waited on for an answer, as the managed server waits on its
54
+ // upstreams (server-managed/npm-packages.ts askUpstream).
55
+ const REQUEST_MS = 30_000
56
+
57
+ export class NpmRiskError extends Error {
58
+ constructor(code, message = code) { super(message); this.code = code }
59
+ }
60
+
61
+ const isTime = value => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
62
+ // A scoped name in a downloads API path, its slash escaped.
63
+ const encoded = name => name.replace('/', '%2f')
64
+ const token = () => typeof process === 'undefined' ? null
65
+ : ['PREVENTIVE_MEASURES_NPM_TOKEN', 'STASIS_NPM_TOKEN', 'NPM_TOKEN'].map(key => process.env[key]).find(Boolean) ?? null
66
+
67
+ // A value kept under `type` and `key`, else `ask`ed and kept; one `fresh`
68
+ // says is stale asked again.
69
+ async function kept({ cache }, type, key, ask, fresh = () => true) {
70
+ const hit = cache ? await cache.read(type, key) : null
71
+ if (hit != null && fresh(hit)) return hit
72
+ const value = await ask()
73
+ if (cache && value != null) await cache.write(type, key, value)
74
+ return value
75
+ }
76
+
77
+ // An answer's body as JSON, read no further than `limit` bytes.
78
+ async function jsonOf(res, limit, what) {
79
+ const tooLarge = () => new NpmRiskError('upstream-too-large', `npm's answer for ${what} is over ${limit} bytes`)
80
+ if (Number(res.headers.get('content-length')) > limit) { await res.body?.cancel(); throw tooLarge() }
81
+ const chunks = []
82
+ let size = 0
83
+ for await (const chunk of res.body ?? []) {
84
+ size += chunk.byteLength
85
+ if (size > limit) throw tooLarge()
86
+ chunks.push(chunk)
87
+ }
88
+ try { return JSON.parse(await new Blob(chunks).text()) } catch { throw new NpmRiskError('upstream-invalid', `npm's answer for ${what} isn't JSON`) }
89
+ }
90
+
91
+ // What npm answers `url` as JSON (jsonOf), null where it answers 404; given up
92
+ // on after REQUEST_MS, or once the caller's `signal` aborts, its abort
93
+ // rethrown as it is. `upstream-busy` where npm asks to be asked later (429),
94
+ // as its downloads API does past a burst of requests;
95
+ // `upstream-unavailable` where it can't be asked or answers otherwise.
96
+ async function askNpm(url, headers, limit, what, { signal }) {
97
+ try {
98
+ const res = await fetch(url, { headers: { accept: 'application/json', ...headers }, redirect: 'error', signal: AbortSignal.any([AbortSignal.timeout(REQUEST_MS), ...signal ? [signal] : []]) })
99
+ if (res.status === 404) { await res.body?.cancel(); return null }
100
+ if (!res.ok) { await res.body?.cancel(); throw new NpmRiskError(res.status === 429 ? 'upstream-busy' : 'upstream-unavailable', `npm answered ${res.status} for ${what}`) }
101
+ return await jsonOf(res, limit, what)
102
+ } catch (error) {
103
+ if (error instanceof NpmRiskError || signal?.aborted) throw error
104
+ throw new NpmRiskError('upstream-unavailable', `npm couldn't be asked for ${what}: ${error.message}`)
105
+ }
106
+ }
107
+
108
+ // A package's whole document, null where npm has none for this asker: the
109
+ // caller's (`packument`) where it gives one, else risk's own.
110
+ function packageDocument(name, context, authorized) {
111
+ if (context.packument) return context.packument(name, { authorized, signal: context.signal })
112
+ const secret = authorized && name.startsWith('@') ? token() : null
113
+ return askNpm(`${REGISTRY}/${name}`, secret ? { authorization: `Bearer ${secret}` } : {}, PACKUMENT_BYTES, name, context)
114
+ }
115
+
116
+ // When a document says its package's first version was published, one since
117
+ // unpublished among them: the earliest time it lists.
118
+ const firstPublished = doc => Object.entries(doc?.time ?? {}).filter(([key, at]) => key !== 'modified' && isTime(at)).map(([, at]) => at)
119
+ .reduce((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null)
120
+
121
+ // Each version a package's document lists, as old-version.js npmOldVersion
122
+ // picks among them: `{ version, published, publisher, known, deprecated,
123
+ // removed }`, its publisher's account as manifest.js reads it, and whether
124
+ // that is known; and each it lists a time of and no document, as one since
125
+ // removed from npm, `removed`, its time alone, as npmPackageFacts reads none
126
+ // of it. Its times' other keys (`created`, `modified`) are no versions, as
127
+ // none of a version starts with a letter.
128
+ const versionsOf = doc => [...new Set([...Object.keys(doc.versions ?? {}), ...Object.keys(doc.time ?? {}).filter(key => /^\d/u.test(key))])]
129
+ const releasesOf = (doc, deprecated = {}) => versionsOf(doc).map(version => {
130
+ const json = doc.versions?.[version]
131
+ const { publisher = null, knownAccounts } = json ? npmVersionManifest(json) : {}
132
+ return { version, published: isTime(doc.time?.[version]) ? doc.time[version] : null, publisher, known: Boolean(knownAccounts),
133
+ deprecated: Object.hasOwn(deprecated, version), removed: !json }
134
+ })
135
+
136
+ // What risk reads of `version` of a package, of the one it is compared with
137
+ // (`oldVersion`: one, null for none, or true for the one old-version.js
138
+ // npmOldVersion picks of its versions, by its document), and of the package:
139
+ // `versions`, each one's `{ manifest, published, dist }`, those of them npm
140
+ // removed since they were published (`removed`, each one's when), none
141
+ // picked to compare a removed one with, `deprecated`, by version, whether
142
+ // npm's security team took the package down (`takenDown`, its latest version
143
+ // npm's placeholder, malware.js npmPlaceholder), `removals`, how many
144
+ // versions of it npm removed, `oldVersion`, the one compared with or null,
145
+ // and where its document was read and either the pick
146
+ // or another account publishing each wants them, `releases`, each version it
147
+ // lists (releasesOf), so that no more of the document is held. Its document
148
+ // is read once at most, and not at all where all of it is kept and fresh. A
149
+ // version's facts are kept for good, as npm never changes a version, but npm
150
+ // may remove it: the package's, kept an hour, has which it removed, and they
151
+ // go by that.
152
+ export async function npmPackageFacts(name, version, oldVersion, context) {
153
+ const { cache, now } = context
154
+ const fresh = hit => hit != null && now - hit.at < PACKAGE_MS
155
+ const picked = cache && oldVersion === true ? await cache.read('risk/npm/old-versions', `${name}@${version}`) : null
156
+ const given = oldVersion === true ? fresh(picked) ? picked.oldVersion : undefined : oldVersion
157
+ if (cache && given !== undefined) {
158
+ const wanted = given === null ? [version] : [version, given]
159
+ const [facts, pkg] = await Promise.all([Promise.all(wanted.map(at => cache.read('risk/npm/versions', `${name}@${at}`))), cache.read('risk/npm/packages', name)])
160
+ const gone = at => Object.hasOwn(pkg.removed, at)
161
+ if (fresh(pkg) && 'removed' in pkg && wanted.every((at, i) => facts[i] || gone(at))) {
162
+ return { versions: new Map(wanted.flatMap((at, i) => gone(at) ? [] : [[at, facts[i]]])), removed: new Map(wanted.filter(gone).map(at => [at, pkg.removed[at]])),
163
+ removals: Object.keys(pkg.removed).length, deprecated: pkg.deprecated, takenDown: pkg.takenDown, oldVersion: given }
164
+ }
165
+ }
166
+ const doc = await packageDocument(name, context, true)
167
+ if (!doc) throw new NpmRiskError('package-not-found', `npm has no package ${name}`)
168
+ const removed = new Map()
169
+ const factsOf = at => {
170
+ const json = doc.versions?.[at]
171
+ if (!json && isTime(doc.time?.[at])) { removed.set(at, doc.time[at]); return null }
172
+ if (!json) throw new NpmRiskError('version-not-found', `npm has no ${name}@${at}`)
173
+ const { deprecated: _, ...manifest } = npmVersionManifest(json)
174
+ return { manifest, published: isTime(doc.time?.[at]) ? doc.time[at] : null, dist: { tarball: json.dist?.tarball, integrity: json.dist?.integrity } }
175
+ }
176
+ const opened = factsOf(version)
177
+ const deprecated = Object.fromEntries(Object.entries(doc.versions ?? {}).filter(([, json]) => typeof json?.deprecated === 'string' && json.deprecated !== '').map(([at, json]) => [at, json.deprecated]))
178
+ let releases = oldVersion === true && opened ? releasesOf(doc, deprecated) : null
179
+ const old = oldVersion === true ? opened && npmOldVersion(version, releases, now) : oldVersion
180
+ const read = new Map([...opened ? [[version, opened]] : [], ...old === null ? [] : [[old, factsOf(old)]]].filter(([, fact]) => fact))
181
+ const takenDown = npmPlaceholder(doc.versions?.[doc['dist-tags']?.latest])
182
+ const publishers = [version, old].map(at => read.get(at)?.manifest.publisher)
183
+ if (publishers.every(Boolean) && publishers[0] !== publishers[1]) releases ??= releasesOf(doc, deprecated)
184
+ const gone = Object.fromEntries(Object.entries(doc.time ?? {}).filter(([at, when]) => at !== 'created' && at !== 'modified' && isTime(when) && !doc.versions?.[at]))
185
+ if (cache) {
186
+ await Promise.all([...read].map(([at, fact]) => cache.write('risk/npm/versions', `${name}@${at}`, fact)))
187
+ await cache.write('risk/npm/packages', name, { at: now, deprecated, takenDown, removed: gone })
188
+ if (oldVersion === true) await cache.write('risk/npm/old-versions', `${name}@${version}`, { at: now, oldVersion: old })
189
+ }
190
+ return { versions: read, removed, removals: Object.keys(gone).length, deprecated, takenDown, oldVersion: old, ...releases ? { releases } : {} }
191
+ }
192
+
193
+ // npm's advisories of `version` of a package, as `npm audit` asks them, and
194
+ // where `osv`, OSV's records npm's registry hasn't, as the malicious
195
+ // packages' of nx's versions are (MAL-2025-41443, of GHSA-cxm3-wv7p-598c,
196
+ // which npm's registry hasn't): upstream's `advisories` and its `osv`, an
197
+ // advisory of npm's an OSV record names taking the record's ids among its
198
+ // `aliases`. Each `{ id, url?, title?, cwe, aliases }`, kept ADVISORIES_MS,
199
+ // and given up on once `signal` aborts.
200
+ export async function npmVersionAdvisories(name, version, osv, context) {
201
+ const what = `${name}@${version}`
202
+ const { rows } = await kept(context, 'risk/npm/advisories', what, async () => {
203
+ context.signal?.throwIfAborted()
204
+ const asked = await untilAborted(advisories([{ ecosystem: 'npm', name, versions: [version] }], { cache: false, osv }), context.signal).catch(error => {
205
+ throw context.signal?.aborted ? error : new NpmRiskError(error?.status === 429 ? 'upstream-busy' : 'upstream-unavailable', `advisories of ${what} couldn't be had: ${error.message}`)
206
+ })
207
+ return { at: context.now, osv, rows: asked.map(({ id, url, title, cwe, aliases }) => ({ id, ...url ? { url } : {}, ...title ? { title } : {}, cwe, aliases })) }
208
+ }, hit => context.now - hit.at < ADVISORIES_MS && hit.osv === osv)
209
+ return rows
210
+ }
211
+
212
+ // Each version a package's document lists (releasesOf), the document read
213
+ // anew, as npmPackageFacts reads it.
214
+ export async function npmPackageReleases(name, context) {
215
+ const doc = await packageDocument(name, context, true)
216
+ if (!doc) throw new NpmRiskError('package-not-found', `npm has no package ${name}`)
217
+ return releasesOf(doc)
218
+ }
219
+
220
+ // `promise`, given up on once `signal` aborts, its reason thrown: what it
221
+ // waits on, which takes no signal (upstream's getTarball), left to settle
222
+ // unheeded.
223
+ function untilAborted(promise, signal) {
224
+ if (!signal) return promise
225
+ if (signal.aborted) return Promise.reject(signal.reason)
226
+ return new Promise((resolve, reject) => {
227
+ const onAbort = () => reject(signal.reason)
228
+ signal.addEventListener('abort', onAbort, { once: true })
229
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
230
+ })
231
+ }
232
+
233
+ // A version's files, as compare.js takes a version, from its tarball
234
+ // (tarball.js): the caller's (`tarball`) where it gives one, its failures
235
+ // thrown as they are, else upstream's, given up on once `signal` aborts; with
236
+ // `npm` its `{ name, version, manifest, published }`.
237
+ export async function npmVersionFiles(name, version, { manifest, published, dist }, { cache, tarball: read, signal }) {
238
+ const what = `${name}@${version}`
239
+ signal?.throwIfAborted()
240
+ const tarball = read ? await read(name, version, { signal }) : await untilAborted(getTarball(name, version, dist, cache === undefined ? {} : { cache }), signal).catch(error => {
241
+ throw signal?.aborted ? error : new NpmRiskError('upstream-unavailable', `npm's tarball of ${what} couldn't be had: ${error.message}`)
242
+ })
243
+ try { return { npm: { name, version, manifest, published }, ...await npmTarballVersion(tarball) } } catch (error) {
244
+ throw new NpmRiskError(error.message === 'package-too-large' ? 'package-too-large' : 'upstream-invalid', `npm's tarball of ${what}: ${error.message}`)
245
+ }
246
+ }
247
+
248
+ // When a package anyone can read was first published, `{ created }`, or null
249
+ // for one npm has none of for anyone.
250
+ export async function npmPublicFacts(name, context) {
251
+ const facts = await kept(context, 'risk/npm/public', name,
252
+ async () => { const doc = await packageDocument(name, context, false); return doc ? { at: context.now, created: firstPublished(doc) } : { at: context.now, missing: true } },
253
+ hit => context.now - hit.at < PACKAGE_MS)
254
+ return facts.missing ? null : { created: facts.created }
255
+ }
256
+
257
+ // The days a package's downloads are asked over at `now`: from the first of
258
+ // the month before last to yesterday, as the npm viewer asks them.
259
+ const day = time => new Date(time).toISOString().slice(0, 10)
260
+ function rangeAt(now) {
261
+ const today = new Date(now)
262
+ return `${day(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 2, 1))}:${day(now - DAY_MS)}`
263
+ }
264
+
265
+ // A package's downloads a day over rangeAt's days, `{ start, end, days }`,
266
+ // null where npm counts none.
267
+ export async function npmDownloads(name, context) {
268
+ const range = rangeAt(context.now)
269
+ const answer = await kept(context, 'risk/npm/downloads', `${name}@${range}`, async () => {
270
+ const json = await askNpm(`${DOWNLOADS}/${range}/${encoded(name)}`, {}, DOWNLOADS_BYTES, `${name}'s downloads`, context)
271
+ if (json === null) return { none: true }
272
+ const start = Date.parse(json.start)
273
+ const length = Math.round((Date.parse(json.end) - start) / DAY_MS) + 1
274
+ const days = Array.from({ length: Number.isSafeInteger(length) && length > 0 && length <= 400 ? length : 0 }, () => 0)
275
+ for (const row of json.downloads ?? []) {
276
+ const at = Math.round((Date.parse(row.day) - start) / DAY_MS)
277
+ if (at >= 0 && at < days.length && Number.isSafeInteger(row.downloads)) days[at] = row.downloads
278
+ }
279
+ return { start: json.start, end: json.end, days }
280
+ })
281
+ return answer.none ? null : answer
282
+ }
@@ -0,0 +1,85 @@
1
+ // What calls for a look at a version beside its files: one too new to
2
+ // trust yet, one npm says is deprecated, a publisher that changed, provenance
3
+ // the version before had and this one lost, and install scripts it adds or
4
+ // changes. Each from what npm's version documents say, as manifest.js reads
5
+ // them (`publisher`, `knownAccounts`, `provenance`, `installScripts`,
6
+ // `deprecated`).
7
+ import { DAY_MS } from './downloads.js'
8
+ import { npmInstallScripts } from './install-scripts.js'
9
+
10
+ // How old a version must be to be trusted, by default: two weeks, as a
11
+ // compromised release is mostly found and pulled within days, so one this
12
+ // new hasn't yet had the time to be. A caller's own packages (a team's npm
13
+ // scopes) take 0: trusted however new.
14
+ export const DEFAULT_MINIMUM_RELEASE_AGE = 14 * DAY_MS
15
+
16
+ // Whether a version published at `published` (an ISO time) is too new to
17
+ // trust at `now`: younger than `minimumReleaseAge`, none at 0.
18
+ export const npmTooNew = (published, { minimumReleaseAge = DEFAULT_MINIMUM_RELEASE_AGE, now = Date.now() } = {}) =>
19
+ minimumReleaseAge > 0 && typeof published === 'string' && now - Date.parse(published) < minimumReleaseAge
20
+
21
+ // How long before a version an account has published its package to be one
22
+ // of its maintainers: three months, as an account given a package to take it
23
+ // publishes its attack within weeks.
24
+ const TENURE_MS = 90 * DAY_MS
25
+
26
+ // Each account's standing on a package when a version of it was published
27
+ // (`at`, an ISO time), by the package's versions as npm's document lists them
28
+ // (`releases`, registry.js, `{ publisher, published }` each), as
29
+ // `(account, known) => standing`: `known` where it publishes one of npm's
30
+ // most downloaded packages (`known`, manifest.js `knownAccounts`), green;
31
+ // `maintainer` where it published the package three months or more before,
32
+ // or first, as its sole maintainer has, yellow; else `new`, as an account
33
+ // taking the package from its maintainers is, orange. An account published it
34
+ // first only where its versions tell so: the earliest npm lists a time of
35
+ // was its, none of another's (or of none npm names, as one since removed) as
36
+ // early, and each npm lists no time of was its too, as any could be earlier.
37
+ export function npmAccountStanding(releases, at) {
38
+ const since = new Map()
39
+ let earliest = null
40
+ for (const { publisher, published } of releases) {
41
+ const time = Date.parse(published)
42
+ if (Number.isNaN(time)) continue
43
+ if (publisher && !(since.get(publisher) <= time)) since.set(publisher, time)
44
+ if (earliest === null || time < earliest.time) earliest = { publisher, time }
45
+ else if (time === earliest.time && publisher !== earliest.publisher) earliest.publisher = null
46
+ }
47
+ const first = earliest?.publisher && releases.every(({ publisher, published }) => publisher === earliest.publisher || !Number.isNaN(Date.parse(published)))
48
+ ? earliest.publisher : null
49
+ return (account, known) => known ? 'known' : account && (account === first || Date.parse(at) - since.get(account) >= TENURE_MS) ? 'maintainer' : 'new'
50
+ }
51
+
52
+ // The npm account that published each version, where npm says for both and
53
+ // they differ, as when a package changes hands, or is taken over: `{ was,
54
+ // now, wasKnown, nowKnown, severity }`, each account known where it publishes
55
+ // one of npm's most downloaded packages (`knownAccounts`), and with the
56
+ // package's versions told (`standing`, npmAccountStanding's of the open
57
+ // version), each one's standing on it (`wasStanding`, `nowStanding`);
58
+ // `moderate` where the open version's is known, or one of the package's
59
+ // maintainers, `critical` where a known one gave way to one that isn't, else
60
+ // `high`. Null where it didn't change.
61
+ export function npmPublisherChange(from, to, standing = null) {
62
+ const now = to?.publisher, was = from?.publisher
63
+ if (!was || !now || was === now) return null
64
+ const nowKnown = to.knownAccounts?.includes(now) === true, wasKnown = from.knownAccounts?.includes(was) === true
65
+ const told = standing ? { wasStanding: standing(was, wasKnown), nowStanding: standing(now, nowKnown) } : {}
66
+ return { was, now, wasKnown, nowKnown, ...told, severity: nowKnown || told.nowStanding === 'maintainer' ? 'moderate' : wasKnown ? 'critical' : 'high' }
67
+ }
68
+
69
+ // Provenance the one compared with has and the open version hasn't, as a
70
+ // version published by hand, with a token taken from its maintainers,
71
+ // wouldn't.
72
+ export const npmProvenanceLost = (from, to) => Boolean(from?.provenance && !to?.provenance)
73
+
74
+ // The scripts npm runs installing the open version (`other`, its manifest
75
+ // and `paths`, its files; install-scripts.js npmInstallScripts, node-gyp's
76
+ // for a binding.gyp among them) that the one compared with (`base`) didn't,
77
+ // or ran another command for: `{ was, now, flagged }`, each version's
78
+ // scripts by name and the names flagged; null for none. One dropped isn't
79
+ // flagged.
80
+ export function npmInstallScriptsChange(base, other) {
81
+ const scripts = ({ manifest, paths }) => new Map(npmInstallScripts(manifest, paths))
82
+ const now = scripts(other), was = scripts(base)
83
+ const flagged = [...now].filter(([script, command]) => was.get(script) !== command).map(([script]) => script)
84
+ return flagged.length === 0 ? null : { was, now, flagged }
85
+ }