@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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 +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -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 +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -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 +333 -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 +18 -0
- package/common/dependency-reviews.js +64 -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 +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -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 +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -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/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- 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 +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -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 +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -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 +96 -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 +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- 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 +44 -13
- 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 +19 -27
- 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 +3 -3
- 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 +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -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 +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -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 +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -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 +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -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 +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +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}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// A package version's risk, as the managed server asks it: what calls for a
|
|
2
|
+
// look at it, against the version before it where one is given or risk picks
|
|
3
|
+
// one (old-version.js), each file's stamps, and its dependencies', from npm's
|
|
4
|
+
// registry, kept in the caller's cache (registry.js) as it goes.
|
|
5
|
+
import { npmBaseFilesTell, npmCompareFindings } from './compare.js'
|
|
6
|
+
import { npmCombinedFindings, npmHeldDependencyFindings } from './combined.js'
|
|
7
|
+
import { npmHeldFindings, npmVersionFindings } from './held.js'
|
|
8
|
+
import { npmVersionFileRisk } from './files.js'
|
|
9
|
+
import { npmDependencyChanges, npmFlagDependencyChanges, npmUnusedDependencyCounted } from './dependencies.js'
|
|
10
|
+
import { npmDownloadMonths, npmDownloadTier, npmSettledDownloads, npmTierLevel } from './downloads.js'
|
|
11
|
+
import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPublicFacts, npmVersionAdvisories, npmVersionFiles } from './registry.js'
|
|
12
|
+
import { npmMalwareFinding } from './malware.js'
|
|
13
|
+
import { npmKnownScope } from './manifest.js'
|
|
14
|
+
import { DEFAULT_MINIMUM_RELEASE_AGE, npmAccountStanding } from './release.js'
|
|
15
|
+
import { npmAddedDependency, npmUnusedDependencySeverity } from './new-dependency.js'
|
|
16
|
+
import { SEVERITIES } from '../files/stamps.js'
|
|
17
|
+
import { npmRiskScore } from './score.js'
|
|
18
|
+
|
|
19
|
+
// Which reading of a version an assessment kept (`risk/npm/assessments`) is:
|
|
20
|
+
// one more whenever a heuristic changes what it finds, so one kept by an
|
|
21
|
+
// older reading is read anew.
|
|
22
|
+
const RISK_VERSION = 18
|
|
23
|
+
|
|
24
|
+
// How long an assessment kept may be as JSON, and what risk reads of either
|
|
25
|
+
// version's files: a package past it, whose files name thousands of hosts or
|
|
26
|
+
// do what they run on thousands of lines each, is refused as too large as its
|
|
27
|
+
// files are read, before more of them is held (filesRisk), and the assessment
|
|
28
|
+
// once told, rather than kept and answered. A package's files tell a few
|
|
29
|
+
// hundred characters each, a few MiB for the most files npm's viewer reads.
|
|
30
|
+
const MAX_ASSESSMENT_LENGTH = 16 * 1024 * 1024
|
|
31
|
+
const tooLarge = what => new NpmRiskError('package-too-large', `what risk tells of ${what} is past ${MAX_ASSESSMENT_LENGTH} characters`)
|
|
32
|
+
|
|
33
|
+
// A value as JSON holds it: a Map as an object, its keys as strings.
|
|
34
|
+
const plain = value => value instanceof Map ? Object.fromEntries([...value].map(([key, item]) => [key, plain(item)]))
|
|
35
|
+
: Array.isArray(value) ? value.map(plain)
|
|
36
|
+
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plain(item)])) : value
|
|
37
|
+
|
|
38
|
+
// A tier among npm's packages as an assessment says it: `{ tier, level }`, the
|
|
39
|
+
// rank a package is among (null past the last, unpopular) and its tint
|
|
40
|
+
// (downloads.js npmTierLevel).
|
|
41
|
+
const tierOf = tier => ({ tier: Number.isFinite(tier) ? tier : null, level: npmTierLevel(tier) })
|
|
42
|
+
|
|
43
|
+
// Each of a version's files' stamps and what risk read of it (stamps.js
|
|
44
|
+
// npmFileRisk), by path, as JSON holds them: read a file at a time and given
|
|
45
|
+
// up on, as too large, once they are past MAX_ASSESSMENT_LENGTH as JSON
|
|
46
|
+
// altogether, so that no more of them is held than that.
|
|
47
|
+
function filesRisk(version, what) {
|
|
48
|
+
let length = 0
|
|
49
|
+
return Object.fromEntries(version.json.sources.map(path => {
|
|
50
|
+
const { readability, runsCode, exfiltration, hosts, stamps } = npmVersionFileRisk(version, path)
|
|
51
|
+
const risk = plain({ stamps, readability, runsCode, exfiltration, hosts })
|
|
52
|
+
length += path.length + JSON.stringify(risk).length
|
|
53
|
+
if (length > MAX_ASSESSMENT_LENGTH) throw tooLarge(what)
|
|
54
|
+
return [path, risk]
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// What of an assessment holds whatever the time, of the open version
|
|
59
|
+
// (`other`) against the one compared with (`base`), or alone without one,
|
|
60
|
+
// each as compare.js takes a version, as JSON holds it: what it holds,
|
|
61
|
+
// always (held.js npmHeldFindings, and its dependencies against none,
|
|
62
|
+
// combined.js npmHeldDependencyFindings), and against `base` what its files,
|
|
63
|
+
// dependencies, install scripts, publisher and provenance call for anew
|
|
64
|
+
// (compare.js npmCompareFindings), folded into what it holds, never lowering
|
|
65
|
+
// it (combined.js npmCombinedFindings); each file's stamps and what risk read
|
|
66
|
+
// of it (filesRisk); and its dependencies added and changed, each with its
|
|
67
|
+
// flags (dependencies.js), against `base` each it holds too (`held`). Its own findings are told fresh
|
|
68
|
+
// (npmAssessmentOf). The managed server's assessments keep it (`lasting`),
|
|
69
|
+
// and a test can tell what one says of versions it holds. Each publisher's
|
|
70
|
+
// standing on the package is told where `standing` is given (release.js
|
|
71
|
+
// npmAccountStanding, of the open version).
|
|
72
|
+
export const npmLastingRisk = (other, base = null, standing = null) => lastingOf(other, base, filesRisk(other, `${other.npm.name}@${other.npm.version}`), standing)
|
|
73
|
+
|
|
74
|
+
// npmLastingRisk's, the open version's files read within bounds (`files`,
|
|
75
|
+
// filesRisk's), as the one compared with's are where `base` has them, before
|
|
76
|
+
// the findings read them all.
|
|
77
|
+
function lastingOf(other, base, files, standing) {
|
|
78
|
+
if (base?.json) filesRisk(base, `${base.npm.name}@${base.npm.version}`)
|
|
79
|
+
const all = npmFlagDependencyChanges(npmDependencyChanges(undefined, other.npm.manifest), null, other)
|
|
80
|
+
const held = [...npmHeldFindings(other), ...npmHeldDependencyFindings(all)]
|
|
81
|
+
const { added, changed } = base ? npmFlagDependencyChanges(npmDependencyChanges(base.npm.manifest, other.npm.manifest), base, other) : all
|
|
82
|
+
const findings = base ? npmCombinedFindings(held, npmCompareFindings(base, other, { added, changed }, standing)) : held
|
|
83
|
+
const kept = { findings: plain(findings), files, dependencies: plain({ added, changed, ...base ? { held: all.added } : {} }) }
|
|
84
|
+
if (JSON.stringify(kept).length > MAX_ASSESSMENT_LENGTH) throw tooLarge(`${other.npm.name}@${other.npm.version}`)
|
|
85
|
+
return kept
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What of an assessment holds whatever the time (npmLastingRisk), kept once a
|
|
89
|
+
// reading and pair, its versions' files read from npm only where it isn't:
|
|
90
|
+
// the open version's first, then the one compared with's only where they
|
|
91
|
+
// could change what is found (compare.js npmBaseFilesTell), that one else
|
|
92
|
+
// told by what npm's document of it says alone; and where another account
|
|
93
|
+
// published it, each one's standing on the package (release.js
|
|
94
|
+
// npmAccountStanding), by its versions as `facts` read them, or as its
|
|
95
|
+
// document is read again where they were kept. A standing changes as npm
|
|
96
|
+
// removes a version, its publisher then unknown, and never else, so such a
|
|
97
|
+
// pair is kept by how many npm has removed (`~<removals>`), read anew as it
|
|
98
|
+
// removes another.
|
|
99
|
+
async function lasting(name, version, oldVersion, facts, context, fromEmpty) {
|
|
100
|
+
const old = oldVersion === null ? null : facts.versions.get(oldVersion)
|
|
101
|
+
const publishers = [old, facts.versions.get(version)].map(fact => fact?.manifest.publisher)
|
|
102
|
+
const changed = publishers.every(Boolean) && publishers[0] !== publishers[1]
|
|
103
|
+
const key = `${RISK_VERSION}/${name}@${version}${fromEmpty ? '..(empty)' : oldVersion === null ? '' : `..${oldVersion}`}${changed ? `~${facts.removals}` : ''}`
|
|
104
|
+
const hit = context.cache ? await context.cache.read('risk/npm/assessments', key) : null
|
|
105
|
+
if (hit) return hit
|
|
106
|
+
const other = await npmVersionFiles(name, version, facts.versions.get(version), context)
|
|
107
|
+
const files = filesRisk(other, `${name}@${version}`)
|
|
108
|
+
const base = fromEmpty ? { npm: { name, version: '', manifest: {} }, json: { sources: [], sourcesContent: [] } } : old === null ? null : npmBaseFilesTell(other, old.manifest) ? await npmVersionFiles(name, oldVersion, old, context)
|
|
109
|
+
: { npm: { name, version: oldVersion, manifest: old.manifest, published: old.published }, json: null }
|
|
110
|
+
const standing = changed ? npmAccountStanding(facts.releases ?? await npmPackageReleases(name, context), other.npm.published) : null
|
|
111
|
+
const kept = lastingOf(other, base, files, standing)
|
|
112
|
+
if (context.cache) await context.cache.write('risk/npm/assessments', key, kept)
|
|
113
|
+
return kept
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// How many dependencies added are told of at most, however many a version
|
|
117
|
+
// adds, as each costs npm a request or two: the rest are left as they are,
|
|
118
|
+
// their packages unknown.
|
|
119
|
+
const MAX_TOLD_DEPENDENCIES = 100
|
|
120
|
+
|
|
121
|
+
// Each dependency `added` that npm installs from its registry, up to
|
|
122
|
+
// MAX_TOLD_DEPENDENCIES of them, but for the caller's own packages (`owned`,
|
|
123
|
+
// as its own scopes take, few downloading them, as new as its releases),
|
|
124
|
+
// with what risk tells of its package
|
|
125
|
+
// (new-dependency.js npmAddedDependency): its tier, its last whole month's
|
|
126
|
+
// downloads by what its tier goes by where it has one (`month`, `{ total,
|
|
127
|
+
// from }`), `uncounted` where npm counts none of them, `created` and `gap`
|
|
128
|
+
// where it is new since the version compared with (`published`, when that
|
|
129
|
+
// and the open version were), and for one nothing imports (flagged so, or
|
|
130
|
+
// among `unusedKeys`) that risk counts (dependencies.js
|
|
131
|
+
// npmUnusedDependencyCounted), the version its spec installs (`installs`,
|
|
132
|
+
// registry.js npmPublicFacts); `missing` where
|
|
133
|
+
// its package was looked up and npm has none of it for anyone, as once
|
|
134
|
+
// unpublished or taken down; four asked at a time, whether each is the
|
|
135
|
+
// caller's own among them, none more once one fails or MAX_TOLD_DEPENDENCIES
|
|
136
|
+
// not the caller's own were taken, however many a version adds.
|
|
137
|
+
async function addedDependencies(added, published, context, owned, unusedKeys = new Set()) {
|
|
138
|
+
const told = [...added]
|
|
139
|
+
const asked = told.flatMap((row, at) => row.opens ? [at] : [])
|
|
140
|
+
let next = 0, taken = 0
|
|
141
|
+
const tell = async row => {
|
|
142
|
+
const downloads = await npmDownloads(row.opens, context)
|
|
143
|
+
const unused = (row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))) && npmUnusedDependencyCounted(row)
|
|
144
|
+
let facts = npmAddedDependency({ downloads, published, unused }, context.now), missing = false
|
|
145
|
+
if (facts.wanted) {
|
|
146
|
+
const listed = await npmPublicFacts(row.opens, context, unused ? row.range : null)
|
|
147
|
+
missing = listed === null
|
|
148
|
+
facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published, unused }, context.now)
|
|
149
|
+
}
|
|
150
|
+
const settled = npmSettledDownloads(downloads, context.now)
|
|
151
|
+
const month = npmDownloadMonths(settled).at(-1)
|
|
152
|
+
return { ...row, ...facts.tier === null ? {} : { ...tierOf(facts.tier), ...settled ? month && { month: { total: month.total, from: month.from.toISOString().slice(0, 10) } } : { uncounted: true } },
|
|
153
|
+
...facts.created ? { created: facts.created, gap: facts.gap } : {}, ...facts.installs ? { installs: facts.installs } : {}, ...missing ? { missing } : {} }
|
|
154
|
+
}
|
|
155
|
+
await Promise.all(Array.from({ length: 4 }, async () => {
|
|
156
|
+
while (next < asked.length && taken < MAX_TOLD_DEPENDENCIES) {
|
|
157
|
+
const at = asked[next++]
|
|
158
|
+
try {
|
|
159
|
+
if (await owned(told[at].opens)) continue
|
|
160
|
+
if (taken >= MAX_TOLD_DEPENDENCIES) break
|
|
161
|
+
taken++
|
|
162
|
+
told[at] = await tell(told[at])
|
|
163
|
+
} catch (error) { next = asked.length; throw error }
|
|
164
|
+
}
|
|
165
|
+
}))
|
|
166
|
+
return told
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// What calls for a look at `version` of package `name`, as JSON holds it:
|
|
170
|
+
// malware first (malware.js npmMalwareFinding: its package taken down by
|
|
171
|
+
// npm, and npm's advisories of malware naming it where `advisories`, true
|
|
172
|
+
// unless given false, has them asked, OSV's too where `osv` is, true unless
|
|
173
|
+
// given false, as a private package's name is never sent it); against `oldVersion` where given,
|
|
174
|
+
// true for the one of its versions most likely free of what an attack
|
|
175
|
+
// injects, as risk picks it by npm's document (old-version.js npmOldVersion;
|
|
176
|
+
// none where none is below it), the one it picked said as `oldVersion`
|
|
177
|
+
// (compare.js npmCompareFindings's order), else what it holds (held.js
|
|
178
|
+
// npmHeldFindings), a deprecation first, and last
|
|
179
|
+
// one younger than `minimumReleaseAge` (two weeks unless given; 0 for none, as
|
|
180
|
+
// a caller's own scopes take), and the package's tier past the top 20,000
|
|
181
|
+
// (`popularity`, held.js npmVersionFindings, unless `popularity` is given
|
|
182
|
+
// false, as a caller's own scopes take); `files`, each file's stamps and what risk read of it; and
|
|
183
|
+
// `dependencies`, those added and changed against it, each with its flags,
|
|
184
|
+
// an added one with its package's tier and age where `downloads` (true unless
|
|
185
|
+
// given false) has them asked, and `dependencies` (true unless given false)
|
|
186
|
+
// too, but where `owned` says its package is the caller's own, as its own scopes
|
|
187
|
+
// take (none unless given). `cache` keeps what is read (registry.js),
|
|
188
|
+
// `packument` asks for a package's whole document and `tarball` for a
|
|
189
|
+
// version's tarball in risk's stead (registry.js), `now` is when it is asked, `signal` gives up on what is
|
|
190
|
+
// still asked of npm. A version npm removed, as it removes every one it finds
|
|
191
|
+
// malware in, is told of by its malware alone (`removed`), nothing of it left
|
|
192
|
+
// to read. Throws an NpmRiskError (registry.js), its `code` saying why, where
|
|
193
|
+
// npm has no such package or version (nor malware explaining one removed),
|
|
194
|
+
// answers past risk's bounds, or can't be asked.
|
|
195
|
+
export async function assessNpm(name, version, { oldVersion = null, fromEmpty = false, minimumReleaseAge = DEFAULT_MINIMUM_RELEASE_AGE, popularity = true, cache, packument, tarball, now = Date.now(), signal, downloads = true, dependencies = true, advisories = true, osv = true, owned = () => false } = {}) {
|
|
196
|
+
const context = { cache, packument, tarball, now, signal }
|
|
197
|
+
const facts = await npmPackageFacts(name, version, oldVersion, context)
|
|
198
|
+
const old = facts.oldVersion
|
|
199
|
+
if (old !== null && !facts.versions.has(old)) throw new NpmRiskError('version-not-found', `npm has no ${name}@${old}`)
|
|
200
|
+
// Its advisories, its lasting risk and its downloads need none of each
|
|
201
|
+
// other, so all are asked at once, and waited on in this order, so that a
|
|
202
|
+
// failure fails it as the first of them failing would.
|
|
203
|
+
const listed = facts.versions.has(version)
|
|
204
|
+
const advised = settledLater(advisories ? npmVersionAdvisories(name, version, osv, context) : Promise.resolve([]))
|
|
205
|
+
const keeping = listed ? settledLater(lasting(name, version, old, facts, context, fromEmpty)) : null
|
|
206
|
+
const counting = listed && downloads && popularity ? settledLater(npmDownloads(name, context)) : null
|
|
207
|
+
const malware = npmMalwareFinding({ takenDown: facts.takenDown, advisories: await advised })
|
|
208
|
+
if (!listed) {
|
|
209
|
+
if (!malware) throw new NpmRiskError('version-not-found', `npm has no ${name}@${version}`)
|
|
210
|
+
return { name, version, oldVersion: null, published: facts.removed.get(version), removed: true, findings: [malware], files: {}, dependencies: { added: [], changed: [] },
|
|
211
|
+
score: npmRiskScore([malware]) }
|
|
212
|
+
}
|
|
213
|
+
const { published, manifest } = facts.versions.get(version)
|
|
214
|
+
const kept = await keeping
|
|
215
|
+
const counted = await counting
|
|
216
|
+
const own = npmVersionFindings({ deprecated: facts.deprecated[version], published, minimumReleaseAge, now, known: Boolean(manifest.knownAccounts), scope: npmKnownScope(name),
|
|
217
|
+
provenance: Boolean(manifest.provenance), downloads: counted })
|
|
218
|
+
const was = old === null ? null : facts.versions.get(old).published
|
|
219
|
+
// Those it holds that nothing imports by another spec than the version
|
|
220
|
+
// compared with's, told by their new spec as an added one nothing imports
|
|
221
|
+
// is (`respecified`), and an added one nothing imports however it was
|
|
222
|
+
// flagged, as one moved from another kind of dependency is.
|
|
223
|
+
const unusedKeys = unusedKeysOf(kept)
|
|
224
|
+
const respecified = kept.dependencies.changed.filter(row => unusedKeys.has(keyOf(row))).map(row => ({ ...row, range: row.to }))
|
|
225
|
+
const told = downloads && dependencies ? await addedDependencies([...kept.dependencies.added, ...respecified], [was, published], context, owned, unusedKeys)
|
|
226
|
+
: [...kept.dependencies.added, ...respecified]
|
|
227
|
+
const added = told.slice(0, kept.dependencies.added.length)
|
|
228
|
+
const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added, told.slice(added.length))
|
|
229
|
+
return { name, version, oldVersion: old, published, ...assessment, score: npmRiskScore(assessment.findings, { published, now, tier: counted ? npmDownloadTier(counted) : null }) }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A dependency's key among a version's, by its kind and name.
|
|
233
|
+
const keyOf = row => `${row.kind ?? ''}\0${row.name}`
|
|
234
|
+
|
|
235
|
+
// `promise`, waited on later: its failure, if it fails before then, not
|
|
236
|
+
// thrown as unhandled meanwhile.
|
|
237
|
+
const settledLater = promise => { promise.catch(() => {}); return promise }
|
|
238
|
+
|
|
239
|
+
// The keys of the dependencies a version holds that nothing of it imports
|
|
240
|
+
// (`kept`, npmLastingRisk's: its `held-unused-deps`), those risk counts
|
|
241
|
+
// (dependencies.js npmUnusedDependencyCounted).
|
|
242
|
+
const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).filter(npmUnusedDependencyCounted).map(keyOf))
|
|
243
|
+
|
|
244
|
+
// The dependencies nothing of a version imports (`held-unused-deps`), each
|
|
245
|
+
// graded by what risk tells of its package fresh (`told`, as
|
|
246
|
+
// addedDependencies tells it; new-dependency.js
|
|
247
|
+
// npmUnusedDependencySeverity), with its tier, when its package was first
|
|
248
|
+
// published and the version its spec installs where told: kept from the
|
|
249
|
+
// version compared with where there was one (`compared`) and it named it as
|
|
250
|
+
// the same kind of dependency by the same spec: not one marked new, nor
|
|
251
|
+
// `added` (its kind and name new, as one moved from another kind is), nor
|
|
252
|
+
// `changed` (each said with the spec it had, `was`); one risk doesn't count
|
|
253
|
+
// (dependencies.js npmUnusedDependencyCounted) a notice, told all the same.
|
|
254
|
+
// The finding as its most severe.
|
|
255
|
+
function unusedGraded(finding, told, compared, { added, changed }) {
|
|
256
|
+
const facts = new Map(told.map(row => [keyOf(row), row])), was = new Map(changed.map(row => [keyOf(row), row.from]))
|
|
257
|
+
const anew = new Set(added.map(keyOf))
|
|
258
|
+
const dependencies = finding.dependencies.map(row => {
|
|
259
|
+
const of = facts.get(keyOf(row))
|
|
260
|
+
return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencyCounted(row)
|
|
261
|
+
? npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))) : 'notice',
|
|
262
|
+
...of?.level ? { tier: of.tier, level: of.level } : {}, ...of?.created ? { created: of.created, gap: of.gap } : {}, ...of?.installs ? { installs: of.installs } : {},
|
|
263
|
+
...of?.missing ? { missing: true } : {} }
|
|
264
|
+
})
|
|
265
|
+
return { ...finding, severity: SEVERITIES[Math.min(...dependencies.map(row => SEVERITIES.indexOf(row.severity)))], dependencies }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// An assessment of what of it holds whatever the time (`kept`,
|
|
269
|
+
// npmLastingRisk's) and what is told fresh: the open version's own findings
|
|
270
|
+
// (`own`, held.js npmVersionFindings, and its malware), its malware and
|
|
271
|
+
// deprecation first and the rest last, its tier as JSON holds it (tierOf),
|
|
272
|
+
// and its dependencies `added`, as addedDependencies tells them, those it
|
|
273
|
+
// holds by another spec that nothing imports beside them (`respecified`),
|
|
274
|
+
// for its unused dependencies' grades. The managed
|
|
275
|
+
// server's (assessNpm), and a test's of versions it holds.
|
|
276
|
+
const FIRST = new Set(['malware', 'deprecated'])
|
|
277
|
+
export function npmAssessmentOf(kept, own, added = kept.dependencies.added, respecified = []) {
|
|
278
|
+
return {
|
|
279
|
+
findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings.map(finding => finding.id === 'held-unused-deps'
|
|
280
|
+
? unusedGraded(finding, [...added, ...respecified], Boolean(kept.dependencies.held), kept.dependencies) : finding),
|
|
281
|
+
...own.filter(({ id }) => !FIRST.has(id)).map(finding => finding.id === 'popularity' ? { ...finding, ...tierOf(finding.tier) } : finding)],
|
|
282
|
+
files: kept.files,
|
|
283
|
+
dependencies: { ...kept.dependencies, added },
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// What calls for a look at a version against the one compared with, as one
|
|
2
|
+
// list: what it holds, always (held.js npmHeldFindings, and its dependencies
|
|
3
|
+
// against none, npmHeldDependencyFindings), as no change hides any of it; and
|
|
4
|
+
// what changed (compare.js npmCompareFindings), each change to what it holds
|
|
5
|
+
// folded into its held finding, never lowering it: its severity the higher of
|
|
6
|
+
// the two, the files, scripts or dependencies the change brought marked
|
|
7
|
+
// (`added`); the rest (a publisher changed, provenance lost, large code
|
|
8
|
+
// added, a file become unreadable, hosts named anew) as they are.
|
|
9
|
+
import { SEVERITIES } from '../files/stamps.js'
|
|
10
|
+
import { HOST_TAGS } from '../files/hosts.js'
|
|
11
|
+
import { npmDependencyFindings } from './compare.js'
|
|
12
|
+
import { npmHostFinding } from './held.js'
|
|
13
|
+
|
|
14
|
+
// A version's dependencies that call for a look whatever came before it
|
|
15
|
+
// (compare.js npmDependencyFindings against none, `dependencies` flagged
|
|
16
|
+
// against none): `held-non-registry-deps` and `held-unused-deps`.
|
|
17
|
+
export const npmHeldDependencyFindings = dependencies => npmDependencyFindings(dependencies, null).map(finding => ({ ...finding, id: `held-${finding.id}` }))
|
|
18
|
+
|
|
19
|
+
// The more severe of two severities (stamps.js SEVERITIES).
|
|
20
|
+
const higher = (a, b) => SEVERITIES[Math.min(SEVERITIES.indexOf(a), SEVERITIES.indexOf(b))]
|
|
21
|
+
|
|
22
|
+
// What a change to what a version holds marks of its held finding's rows,
|
|
23
|
+
// by the change's id: a file's what it runs, the services it names under a
|
|
24
|
+
// hosts' tag, and an obfuscator's fingerprint new to it (`added`); the
|
|
25
|
+
// scripts added or changed and what each version ran (`flagged`, `was`); a
|
|
26
|
+
// dependency taken from elsewhere than npm's registry anew, its spec before
|
|
27
|
+
// (`was`), or added that nothing imports (`added`). Code kept running beyond
|
|
28
|
+
// itself or reaching for credentials (`kept-runs-code`, `kept-credentials`)
|
|
29
|
+
// marks nothing, its files held already.
|
|
30
|
+
const byPath = (rows, key) => new Map(rows.map(row => [row.path, row[key]]))
|
|
31
|
+
const marked = (held, rows) => ({ ...held, files: held.files.map(row => rows.has(row.path) ? { ...row, added: rows.get(row.path) } : row) })
|
|
32
|
+
const FOLDS = {
|
|
33
|
+
'runs-code': ['held-runs-code', (held, change) => marked(held, byPath(change.files, 'added'))],
|
|
34
|
+
'kept-runs-code': ['held-runs-code', held => held],
|
|
35
|
+
credentials: ['held-credentials', (held, change) => marked(held, byPath(change.files, 'added'))],
|
|
36
|
+
'kept-credentials': ['held-credentials', held => held],
|
|
37
|
+
obfuscated: ['held-obfuscated', (held, change) => marked(held, byPath(change.files, 'obfuscation'))],
|
|
38
|
+
'install-scripts': ['held-install-scripts', (held, change) => ({ ...held, flagged: change.flagged, was: [...change.was] })],
|
|
39
|
+
'non-registry-deps': ['held-non-registry-deps', (held, change) => {
|
|
40
|
+
const was = new Map(change.dependencies.map(row => [row.name, row.was]))
|
|
41
|
+
return { ...held, dependencies: held.dependencies.map(row => was.has(row.name) ? { ...row, was: was.get(row.name), added: true } : row) }
|
|
42
|
+
}],
|
|
43
|
+
'unused-deps': ['held-unused-deps', (held, change) => {
|
|
44
|
+
const added = new Set(change.dependencies.map(row => `${row.kind ?? ''}\0${row.name}`))
|
|
45
|
+
return { ...held, dependencies: held.dependencies.map(row => added.has(`${row.kind ?? ''}\0${row.name}`) ? { ...row, added: true } : row) }
|
|
46
|
+
}],
|
|
47
|
+
...Object.fromEntries([...HOST_TAGS.keys()].map(npmHostFinding).map(id => [id, [`held-${id}`, (held, change) => marked(held, byPath(change.files, 'added'))]])),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Who published the open version and how, ahead of what it holds, as a
|
|
51
|
+
// reviewer reads them; what else changed after it.
|
|
52
|
+
const FIRST = new Set(['publisher', 'provenance-lost'])
|
|
53
|
+
|
|
54
|
+
// What a version holds (`held`) with what changed against the one compared
|
|
55
|
+
// with (`changed`) folded in, as one list in the order a reviewer reads it: a
|
|
56
|
+
// publisher changed and provenance lost, what it holds, then the rest of what
|
|
57
|
+
// changed. A change whose held finding is missing is kept as it is.
|
|
58
|
+
export function npmCombinedFindings(held, changed) {
|
|
59
|
+
const combined = new Map(held.map(finding => [finding.id, finding]))
|
|
60
|
+
const rest = []
|
|
61
|
+
for (const change of changed) {
|
|
62
|
+
const [id, fold] = FOLDS[change.id] ?? []
|
|
63
|
+
const into = id && combined.get(id)
|
|
64
|
+
if (into) combined.set(id, { ...fold(into, change), severity: higher(into.severity, change.severity) })
|
|
65
|
+
else rest.push(change)
|
|
66
|
+
}
|
|
67
|
+
return [...rest.filter(({ id }) => FIRST.has(id)), ...combined.values(), ...rest.filter(({ id }) => !FIRST.has(id))]
|
|
68
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
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 { npmLoadedScripts, 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 { npmIsScript } from '../files/script.js'
|
|
16
|
+
import { SEVERITIES } from '../files/stamps.js'
|
|
17
|
+
import { CREDENTIAL_TAGS, npmFoundAdded, npmRunsCodeFlags, npmRunsCodeTagged, npmRunsCodeWithout } from '../files/runs-code.js'
|
|
18
|
+
import { npmDependencySpecs } from './dependencies.js'
|
|
19
|
+
import { npmFilesDiff } from './diff.js'
|
|
20
|
+
import { npmInstallScripts } from './install-scripts.js'
|
|
21
|
+
import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from './release.js'
|
|
22
|
+
|
|
23
|
+
// The files the open version (`other`) runs as scripts that the one compared
|
|
24
|
+
// with (`base`) held, changed or not, but didn't run, by their names
|
|
25
|
+
// (script.js npmIsScript, a `.js` renamed `.txt`) or as code loaded them
|
|
26
|
+
// (npm/files.js npmLoadedScripts), as its code now loads them, each a row as npmFilesDiff's
|
|
27
|
+
// `changed` holds one, its own where it has one: what node runs of them is
|
|
28
|
+
// new, as a changed file's is.
|
|
29
|
+
function npmWokenFiles(base, other, files) {
|
|
30
|
+
const added = new Set(files.onlyOther.map(row => row.path)), rows = new Map(files.changed.map(row => [row.path, row]))
|
|
31
|
+
const before = bundleFilesAsMap(base), loadedBefore = npmLoadedScripts(base)
|
|
32
|
+
// A file that isn't text by its name alone (`payload.js`).
|
|
33
|
+
const ran = path => loadedBefore.has(path) || (before.has(path) && npmIsScript(path, typeof before.get(path) === 'string' ? before.get(path) : ''))
|
|
34
|
+
return [...npmLoadedScripts(other)].filter(path => !added.has(path)).map(path => rows.get(path) ?? { path })
|
|
35
|
+
.filter(row => !ran(row.renamedFrom ?? row.path))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What the open version does to its files that calls for a look, as red
|
|
39
|
+
// findings: code that runs beyond itself (runs-code.js), new to a file it
|
|
40
|
+
// adds or changes, the kinds with their lines (`runs-code`), those reaching
|
|
41
|
+
// for credentials apart (CREDENTIAL_TAGS, `credentials`); a service under
|
|
42
|
+
// one of the hosts' tags new to a file it adds or changes (hosts.js
|
|
43
|
+
// npmExfiltration), a finding a tag a level higher than its own
|
|
44
|
+
// (npmHostSeverity: `exfiltration` and `remote` red, `ip-lookup` orange); a
|
|
45
|
+
// code file of 256 KiB or more it adds, one node runs as a script whatever
|
|
46
|
+
// its name too (npmLargeCode, `large-code`); a file
|
|
47
|
+
// it changes that had no line over 200 characters and now has one over 2,000
|
|
48
|
+
// (npmBecameUnreadable, `now-unreadable`); a file it adds or changes with
|
|
49
|
+
// an obfuscator's fingerprint where the one compared with had none
|
|
50
|
+
// (`obfuscated`); and a code file it adds or changes that isn't text, where
|
|
51
|
+
// the one compared with's was text, or no code, or none (npmBinaryCode,
|
|
52
|
+
// `binary-code`). Then in orange what any of its files runs beyond itself that
|
|
53
|
+
// `runs-code` doesn't name, kept from the version compared with
|
|
54
|
+
// (`kept-runs-code`, `kept-credentials`). A renamed file by its old path; one renamed alone,
|
|
55
|
+
// unchanged, holds what it held. A file become unreadable calls for less
|
|
56
|
+
// where its source map was rebuilt with it (`rebuilt`, rebuiltWithMap), as a
|
|
57
|
+
// build minifies a file and writes its map together, and in a `major`
|
|
58
|
+
// release (npmMajorRelease): `now-unreadable` orange where every such file is
|
|
59
|
+
// either, else red. A file it held, changed or not, that node now runs as a
|
|
60
|
+
// script (npmWokenFiles) counts as one it changes, large code where it is
|
|
61
|
+
// one, and unreadable now where it wasn't as no script, whatever its lines
|
|
62
|
+
// were.
|
|
63
|
+
function npmFileFindings(base, other, files, major) {
|
|
64
|
+
const added = new Set(files.onlyOther)
|
|
65
|
+
const after = row => npmVersionFileRisk(other, row.path)
|
|
66
|
+
const before = row => added.has(row) ? null : npmVersionFileRisk(base, row.renamedFrom ?? row.path)
|
|
67
|
+
const changed = files.changed.filter(row => row.renamedFrom == null || row.modified)
|
|
68
|
+
const woken = npmWokenFiles(base, other, files)
|
|
69
|
+
const touched = [...new Set([...files.onlyOther, ...changed, ...woken])]
|
|
70
|
+
const sizes = bundleFileSizes(other)
|
|
71
|
+
// The files that hold what they didn't before, each with that.
|
|
72
|
+
const gained = key => touched.map(row => ({ path: row.path, added: npmFoundAdded(after(row)[key], before(row)?.[key] ?? null) })).filter(row => row.added)
|
|
73
|
+
const exfiltration = gained('exfiltration'), runsCode = gained('runsCode')
|
|
74
|
+
const flagged = new Set(runsCode.flatMap(row => npmRunsCodeFlags(row.added).map(({ name }) => name)))
|
|
75
|
+
const kept = [...bundleFilesAsMap(other).keys()].map(path => ({ path, found: npmRunsCodeWithout(npmVersionFileRisk(other, path).runsCode, flagged) })).filter(row => row.found)
|
|
76
|
+
// Code that runs beyond itself, or of the kinds reaching for credentials.
|
|
77
|
+
const ofKinds = (rows, key, of) => rows.map(row => ({ ...row, [key]: of(row[key], CREDENTIAL_TAGS) })).filter(row => row[key])
|
|
78
|
+
return [
|
|
79
|
+
{ id: 'runs-code', severity: 'critical', files: ofKinds(runsCode, 'added', npmRunsCodeWithout) },
|
|
80
|
+
{ id: 'credentials', severity: 'critical', files: ofKinds(runsCode, 'added', npmRunsCodeTagged) },
|
|
81
|
+
...[...HOST_TAGS.keys()].map(tag => {
|
|
82
|
+
const rows = exfiltration.map(row => ({ path: row.path, added: npmHostsTagged(row.added, tag) })).filter(row => row.added)
|
|
83
|
+
return { id: npmHostFinding(tag), severity: rows.length > 0 ? npmHostSeverity(tag, rows[0].added, null) : HOST_TAGS.get(tag), files: rows }
|
|
84
|
+
}),
|
|
85
|
+
{ id: 'large-code', severity: 'critical', files: [...files.onlyOther, ...woken].filter(row => npmLargeCode(row.path, sizes.get(row.path), npmLoadedScripts(other).has(row.path)))
|
|
86
|
+
.map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
|
|
87
|
+
{ id: 'now-unreadable', ...unreadableFinding(major, [...new Set([...changed.filter(row => npmBecameUnreadable(row.path, before(row)?.readability, after(row).readability, npmLoadedScripts(other).has(row.path))),
|
|
88
|
+
...woken.filter(row => npmUnreadable(row.path, after(row).readability, true) && !npmUnreadable(row.renamedFrom ?? row.path, before(row).readability))])]
|
|
89
|
+
.map(row => ({ path: row.path, was: before(row).readability.longest, now: after(row).readability.longest,
|
|
90
|
+
...rebuiltWithMap(base, other, row.path, row.renamedFrom ?? row.path) ? { rebuilt: true } : {} }))) },
|
|
91
|
+
{ id: 'obfuscated', severity: 'critical', files: touched.filter(row => after(row).readability.obfuscation && !before(row)?.readability.obfuscation)
|
|
92
|
+
.map(row => ({ path: row.path, obfuscation: after(row).readability.obfuscation })) },
|
|
93
|
+
{ id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability, npmLoadedScripts(other).has(row.path))
|
|
94
|
+
&& !npmBinaryCode(row.renamedFrom ?? row.path, before(row)?.readability, npmLoadedScripts(base).has(row.renamedFrom ?? row.path)))
|
|
95
|
+
.map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
|
|
96
|
+
{ id: 'kept-runs-code', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeWithout) },
|
|
97
|
+
{ id: 'kept-credentials', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeTagged) },
|
|
98
|
+
].filter(finding => finding.files.length > 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Hosts whose naming anew calls for no look: where packages keep their code
|
|
102
|
+
// and docs, as their links and error messages name them, the most a version
|
|
103
|
+
// names anew of popular packages a year apart (lit.dev, Lit's), and none
|
|
104
|
+
// whose content anyone may publish (a wiki's raw pages) but github.com's
|
|
105
|
+
// pages, as hosts.js githubNamed names them: a repository's own URL, its
|
|
106
|
+
// files and releases are named by it. Each as it is written, not a host
|
|
107
|
+
// under it, so one an attack sends to or fetches from there
|
|
108
|
+
// (`api.github.com`, `raw.githubusercontent.com`) is still named; and no link
|
|
109
|
+
// shortener (`git.io`, `git.new`), which can lead anywhere.
|
|
110
|
+
const ALLOWED_HOSTS = new Set(['github.com', 'lit.dev', 'developer.mozilla.org', 'json-schema.org', 'nodejs.org', 'babeljs.io', 'tseslint.com',
|
|
111
|
+
'devblogs.microsoft.com', 'semver.org'])
|
|
112
|
+
|
|
113
|
+
// A severity one level lower (stamps.js SEVERITIES).
|
|
114
|
+
const lower = severity => SEVERITIES[Math.min(SEVERITIES.indexOf(severity) + 1, SEVERITIES.length - 1)]
|
|
115
|
+
|
|
116
|
+
// Whether the open version (`now`) is a major release past the one compared
|
|
117
|
+
// with (`was`): its major number higher, or its minor for 0.x. A rewrite
|
|
118
|
+
// changes how a package's files read and who publishes it, and npm's `^`
|
|
119
|
+
// ranges don't take a major release by themselves, as a hijacked release
|
|
120
|
+
// wants them to.
|
|
121
|
+
function npmMajorRelease(was, now) {
|
|
122
|
+
const [a, b] = [was, now].map(version => /^v?(\d+)\.(\d+)\./u.exec(version ?? '')?.slice(1).map(Number))
|
|
123
|
+
return Boolean(a && b) && (b[0] > a[0] || (a[0] === 0 && b[0] === 0 && b[1] > a[1]))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Whether the open version's file at `path` (`basePath` in the one compared
|
|
127
|
+
// with) had its source map rebuilt with it: its last line, a
|
|
128
|
+
// `sourceMappingURL` comment and no more, names a map file of the package by
|
|
129
|
+
// a plain path from the file's own (MAP_NAME), a source map
|
|
130
|
+
// (its `mappings`, or each of an indexed map's `sections`, told), new or
|
|
131
|
+
// changed from the one at its path, or beside the file's own before it was
|
|
132
|
+
// renamed, as a build writes them together. An attack editing a built file
|
|
133
|
+
// leaves its map as it was.
|
|
134
|
+
const SOURCE_MAP_FILE = /^\s*(?:\/\/[#@] sourceMappingURL=(\S+)|\/\*[#@] sourceMappingURL=(\S+?)\s*\*\/)\s*$/u
|
|
135
|
+
// A map name a build writes (`a.js.map`, `../maps/a.map`): segments of
|
|
136
|
+
// letters, digits and `._~@+-`, as a browser reads them alike. One at a URL,
|
|
137
|
+
// from a site's root, or with an escape or a backslash names none.
|
|
138
|
+
const MAP_NAME = /^(?:[\w.~@+-]+\/)*[\w.~@+-]+\.map$/u
|
|
139
|
+
function besideFile(file, name) {
|
|
140
|
+
const parts = file.split('/').slice(0, -1)
|
|
141
|
+
for (const part of name.split('/')) {
|
|
142
|
+
if (part === '..' && parts.length === 0) return null
|
|
143
|
+
if (part === '..') parts.pop()
|
|
144
|
+
else if (part !== '.') parts.push(part)
|
|
145
|
+
}
|
|
146
|
+
return parts.join('/')
|
|
147
|
+
}
|
|
148
|
+
function rebuiltWithMap(base, other, path, basePath) {
|
|
149
|
+
const text = bundleFilesAsMap(other).get(path)
|
|
150
|
+
const directive = typeof text === 'string' ? SOURCE_MAP_FILE.exec(text.trimEnd().split('\n').at(-1)) : null
|
|
151
|
+
const name = directive?.[1] ?? directive?.[2]
|
|
152
|
+
if (!name || !MAP_NAME.test(name)) return false
|
|
153
|
+
const [map, was] = [bundleFilesAsMap(other).get(besideFile(path, name)), bundleFilesAsMap(base)]
|
|
154
|
+
return isSourceMap(map) && map !== was.get(besideFile(path, name)) && map !== was.get(besideFile(basePath, name))
|
|
155
|
+
}
|
|
156
|
+
const isSourceMap = text => {
|
|
157
|
+
try {
|
|
158
|
+
const json = JSON.parse(text)
|
|
159
|
+
return typeof json?.mappings === 'string'
|
|
160
|
+
|| (Array.isArray(json?.sections) && json.sections.length > 0 && json.sections.every(section => typeof section?.map?.mappings === 'string'))
|
|
161
|
+
} catch { return false }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The files become unreadable as a finding says them: red, but orange in a
|
|
165
|
+
// major release, or where each had its source map rebuilt with it.
|
|
166
|
+
const unreadableFinding = (major, files) => ({ severity: major || files.every(row => row.rebuilt) ? 'high' : 'critical', ...major ? { major } : {}, files })
|
|
167
|
+
|
|
168
|
+
// The hosts the open version's code names in its URLs that the one compared
|
|
169
|
+
// with's named in none of its, ALLOWED_HOSTS aside, each `{ host, path, line,
|
|
170
|
+
// places }`: the file it is first named in and its line, and `places` every
|
|
171
|
+
// file that names it, `{ path, line }` each, the line it is first named on
|
|
172
|
+
// there. A file unchanged names what it named, so the files the open version
|
|
173
|
+
// adds or changes hold all it names anew.
|
|
174
|
+
function npmNewHosts(base, other, files) {
|
|
175
|
+
const found = new Map(), known = npmVersionHosts(base)
|
|
176
|
+
for (const path of new Set([...files.onlyOther, ...files.changed, ...npmWokenFiles(base, other, files)].map(row => row.path))) {
|
|
177
|
+
for (const [host, line] of npmVersionFileRisk(other, path).hosts ?? []) {
|
|
178
|
+
if (known.has(host) || ALLOWED_HOSTS.has(host)) continue
|
|
179
|
+
const place = { path, line }
|
|
180
|
+
if (found.has(host)) found.get(host).places.push(place)
|
|
181
|
+
else found.set(host, { host, ...place, places: [place] })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...found.values()]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Whether the files of the version compared with could change what
|
|
188
|
+
// npmCompareFindings finds of the open version (`other`), told before they
|
|
189
|
+
// are read, by the open version's files and the manifest npm's document of
|
|
190
|
+
// the one compared with gives (`manifest`): what its files call for
|
|
191
|
+
// (npmFileFindings, npmNewHosts) is only ever what the open version's own
|
|
192
|
+
// files hold that the one compared with's didn't, so where none of them runs
|
|
193
|
+
// code beyond itself, names a service under one of the hosts' tags or a host
|
|
194
|
+
// past ALLOWED_HOSTS, bears an obfuscator's fingerprint, has a line no one
|
|
195
|
+
// writes (npmUnreadable), is large code or is code that isn't text
|
|
196
|
+
// (npmBinaryCode), nothing is found of them whatever
|
|
197
|
+
// the one compared with holds. Nor do the one compared with's install scripts
|
|
198
|
+
// differ by a binding.gyp among its files, but where its manifest names
|
|
199
|
+
// neither `preinstall` nor `install`, and the open version has some.
|
|
200
|
+
export function npmBaseFilesTell(other, manifest) {
|
|
201
|
+
if (npmInstallScripts(other.npm.manifest, other.json.sources).length > 0
|
|
202
|
+
&& npmInstallScripts(manifest).length !== npmInstallScripts(manifest, ['binding.gyp']).length) return true
|
|
203
|
+
const sizes = bundleFileSizes(other)
|
|
204
|
+
return [...bundleFilesAsMap(other).keys()].some(path => {
|
|
205
|
+
const { runsCode, exfiltration, readability, hosts } = npmVersionFileRisk(other, path)
|
|
206
|
+
return Boolean(runsCode || exfiltration || readability.obfuscation) || npmUnreadable(path, readability, npmLoadedScripts(other).has(path)) || npmLargeCode(path, sizes.get(path), npmLoadedScripts(other).has(path))
|
|
207
|
+
|| npmBinaryCode(path, readability, npmLoadedScripts(other).has(path))
|
|
208
|
+
|| [...hosts?.keys() ?? []].some(host => !ALLOWED_HOSTS.has(host))
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Of the dependencies the open version adds and changes against `base`, or
|
|
213
|
+
// alone, none given, each with its flags (dependencies.js
|
|
214
|
+
// npmFlagDependencyChanges): those it takes from elsewhere than npm's
|
|
215
|
+
// registry, where the one compared with didn't, or by another spec
|
|
216
|
+
// (`non-registry-deps`, each `{ name, was, now }`, its specs); and those it
|
|
217
|
+
// adds that nothing of it imports, which npm installs all the same, its
|
|
218
|
+
// install scripts and all, as a dependency added to run them would be
|
|
219
|
+
// (`unused-deps`, each `{ name, kind, range }`).
|
|
220
|
+
export function npmDependencyFindings({ added, changed }, base) {
|
|
221
|
+
const flagged = check => [...added, ...changed].filter(row => row.flags.some(flag => flag.check === check))
|
|
222
|
+
const was = base ? npmDependencySpecs(base.npm.manifest) : null
|
|
223
|
+
const fetched = flagged('source').map(row => ({ name: row.name, was: was?.get(row.name) ?? null, now: row.to ?? row.range }))
|
|
224
|
+
const unimported = flagged('unimported').map(({ name, kind, range }) => ({ name, kind, range }))
|
|
225
|
+
return [
|
|
226
|
+
...fetched.length > 0 ? [{ id: 'non-registry-deps', severity: 'high', dependencies: fetched }] : [],
|
|
227
|
+
...unimported.length > 0 ? [{ id: 'unused-deps', severity: 'high', dependencies: unimported }] : [],
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Everything that calls for a look at the open version (`other`) against the
|
|
232
|
+
// one compared with (`base`), whatever the time, in the order a reviewer reads
|
|
233
|
+
// them: a publisher that changed (release.js npmPublisherChange,
|
|
234
|
+
// `publisher`, each account's standing on the package told where `standing`
|
|
235
|
+
// is given), a level lower in a major release (npmMajorRelease, `major`),
|
|
236
|
+
// as a package changing hands with a rewrite does; provenance lost (`provenance-lost`); install scripts added or
|
|
237
|
+
// changed (`install-scripts`); its `dependencies` added and changed, as
|
|
238
|
+
// flagged (npmDependencyFindings); its files, by how they differ (diff.js
|
|
239
|
+
// npmFilesDiff, npmFileFindings); and the hosts its code names anew
|
|
240
|
+
// (npmNewHosts, `new-hosts`). A `base` without its files (`json` null),
|
|
241
|
+
// where npmBaseFilesTell says they would change nothing, is found nothing of
|
|
242
|
+
// by them.
|
|
243
|
+
export function npmCompareFindings(base, other, dependencies, standing = null) {
|
|
244
|
+
const major = npmMajorRelease(base.npm.version, other.npm.version)
|
|
245
|
+
const publisher = npmPublisherChange(base.npm.manifest, other.npm.manifest, standing)
|
|
246
|
+
const scripts = npmInstallScriptsChange(...[base, other].map(version => ({ manifest: version.npm.manifest, paths: version.json?.sources })))
|
|
247
|
+
const files = base.json ? npmFilesDiff(base, other) : null
|
|
248
|
+
const hosts = files ? npmNewHosts(base, other, files) : []
|
|
249
|
+
return [
|
|
250
|
+
...publisher ? [{ id: 'publisher', ...publisher, ...major ? { severity: lower(publisher.severity), major } : {} }] : [],
|
|
251
|
+
...npmProvenanceLost(base.npm.manifest, other.npm.manifest) ? [{ id: 'provenance-lost', severity: 'critical' }] : [],
|
|
252
|
+
...scripts ? [{ id: 'install-scripts', severity: 'high', ...scripts }] : [],
|
|
253
|
+
...npmDependencyFindings(dependencies, base),
|
|
254
|
+
...files ? npmFileFindings(base, other, files, major) : [],
|
|
255
|
+
...hosts.length > 0 ? [{ id: 'new-hosts', severity: 'high', hosts }] : [],
|
|
256
|
+
]
|
|
257
|
+
}
|