@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2AFQKRAM.js +51 -0
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KVEUXIEU.js +1 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QZ3A376W.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TY4RZD3Z.js +226 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-U6OPEXIA.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-UZAZM2SX.js +2 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -0
- package/out/chunk-XL35ZV6R.js +1 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +298 -59
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +595 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +156 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +223 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +420 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -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
|
+
}
|