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