@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51
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 +3 -0
- package/common/managed/finding-detail.js +66 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +7 -0
- package/common/managed/npm-packages.js +51 -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 +359 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +6 -0
- package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7JBSSRD7.js +3 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FMXWXDR4.js +36 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HT2SSA3V.js +1 -0
- package/out/chunk-IPAH4LKF.js +232 -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-KOGNQDSJ.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-VDRXHIHN.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-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
- package/server-managed/comparison.ts +52 -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 +468 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +143 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +98 -0
- package/server-managed/dependency-reviews.ts +364 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +376 -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 +136 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5503 -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/listed-activity.ts +39 -0
- package/server-managed/locked-packages.ts +152 -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 +225 -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-patch.ts +325 -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 +351 -0
- package/server-managed/pull-dependency-reviews.ts +334 -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 +448 -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 +166 -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 +212 -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 +149 -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 +319 -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,85 @@
|
|
|
1
|
+
// What calls for a look at a version beside its files: one too new to
|
|
2
|
+
// trust yet, one npm says is deprecated, a publisher that changed, provenance
|
|
3
|
+
// the version before had and this one lost, and install scripts it adds or
|
|
4
|
+
// changes. Each from what npm's version documents say, as manifest.js reads
|
|
5
|
+
// them (`publisher`, `knownAccounts`, `provenance`, `installScripts`,
|
|
6
|
+
// `deprecated`).
|
|
7
|
+
import { DAY_MS } from './downloads.js'
|
|
8
|
+
import { npmInstallScripts } from './install-scripts.js'
|
|
9
|
+
|
|
10
|
+
// How old a version must be to be trusted, by default: two weeks, as a
|
|
11
|
+
// compromised release is mostly found and pulled within days, so one this
|
|
12
|
+
// new hasn't yet had the time to be. A caller's own packages (a team's npm
|
|
13
|
+
// scopes) take 0: trusted however new.
|
|
14
|
+
export const DEFAULT_MINIMUM_RELEASE_AGE = 14 * DAY_MS
|
|
15
|
+
|
|
16
|
+
// Whether a version published at `published` (an ISO time) is too new to
|
|
17
|
+
// trust at `now`: younger than `minimumReleaseAge`, none at 0.
|
|
18
|
+
export const npmTooNew = (published, { minimumReleaseAge = DEFAULT_MINIMUM_RELEASE_AGE, now = Date.now() } = {}) =>
|
|
19
|
+
minimumReleaseAge > 0 && typeof published === 'string' && now - Date.parse(published) < minimumReleaseAge
|
|
20
|
+
|
|
21
|
+
// How long before a version an account has published its package to be one
|
|
22
|
+
// of its maintainers: three months, as an account given a package to take it
|
|
23
|
+
// publishes its attack within weeks.
|
|
24
|
+
const TENURE_MS = 90 * DAY_MS
|
|
25
|
+
|
|
26
|
+
// Each account's standing on a package when a version of it was published
|
|
27
|
+
// (`at`, an ISO time), by the package's versions as npm's document lists them
|
|
28
|
+
// (`releases`, registry.js, `{ publisher, published }` each), as
|
|
29
|
+
// `(account, known) => standing`: `known` where it publishes one of npm's
|
|
30
|
+
// most downloaded packages (`known`, manifest.js `knownAccounts`), green;
|
|
31
|
+
// `maintainer` where it published the package three months or more before,
|
|
32
|
+
// or first, as its sole maintainer has, yellow; else `new`, as an account
|
|
33
|
+
// taking the package from its maintainers is, orange. An account published it
|
|
34
|
+
// first only where its versions tell so: the earliest npm lists a time of
|
|
35
|
+
// was its, none of another's (or of none npm names, as one since removed) as
|
|
36
|
+
// early, and each npm lists no time of was its too, as any could be earlier.
|
|
37
|
+
export function npmAccountStanding(releases, at) {
|
|
38
|
+
const since = new Map()
|
|
39
|
+
let earliest = null
|
|
40
|
+
for (const { publisher, published } of releases) {
|
|
41
|
+
const time = Date.parse(published)
|
|
42
|
+
if (Number.isNaN(time)) continue
|
|
43
|
+
if (publisher && !(since.get(publisher) <= time)) since.set(publisher, time)
|
|
44
|
+
if (earliest === null || time < earliest.time) earliest = { publisher, time }
|
|
45
|
+
else if (time === earliest.time && publisher !== earliest.publisher) earliest.publisher = null
|
|
46
|
+
}
|
|
47
|
+
const first = earliest?.publisher && releases.every(({ publisher, published }) => publisher === earliest.publisher || !Number.isNaN(Date.parse(published)))
|
|
48
|
+
? earliest.publisher : null
|
|
49
|
+
return (account, known) => known ? 'known' : account && (account === first || Date.parse(at) - since.get(account) >= TENURE_MS) ? 'maintainer' : 'new'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The npm account that published each version, where npm says for both and
|
|
53
|
+
// they differ, as when a package changes hands, or is taken over: `{ was,
|
|
54
|
+
// now, wasKnown, nowKnown, severity }`, each account known where it publishes
|
|
55
|
+
// one of npm's most downloaded packages (`knownAccounts`), and with the
|
|
56
|
+
// package's versions told (`standing`, npmAccountStanding's of the open
|
|
57
|
+
// version), each one's standing on it (`wasStanding`, `nowStanding`);
|
|
58
|
+
// `moderate` where the open version's is known, or one of the package's
|
|
59
|
+
// maintainers, `critical` where a known one gave way to one that isn't, else
|
|
60
|
+
// `high`. Null where it didn't change.
|
|
61
|
+
export function npmPublisherChange(from, to, standing = null) {
|
|
62
|
+
const now = to?.publisher, was = from?.publisher
|
|
63
|
+
if (!was || !now || was === now) return null
|
|
64
|
+
const nowKnown = to.knownAccounts?.includes(now) === true, wasKnown = from.knownAccounts?.includes(was) === true
|
|
65
|
+
const told = standing ? { wasStanding: standing(was, wasKnown), nowStanding: standing(now, nowKnown) } : {}
|
|
66
|
+
return { was, now, wasKnown, nowKnown, ...told, severity: nowKnown || told.nowStanding === 'maintainer' ? 'moderate' : wasKnown ? 'critical' : 'high' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Provenance the one compared with has and the open version hasn't, as a
|
|
70
|
+
// version published by hand, with a token taken from its maintainers,
|
|
71
|
+
// wouldn't.
|
|
72
|
+
export const npmProvenanceLost = (from, to) => Boolean(from?.provenance && !to?.provenance)
|
|
73
|
+
|
|
74
|
+
// The scripts npm runs installing the open version (`other`, its manifest
|
|
75
|
+
// and `paths`, its files; install-scripts.js npmInstallScripts, node-gyp's
|
|
76
|
+
// for a binding.gyp among them) that the one compared with (`base`) didn't,
|
|
77
|
+
// or ran another command for: `{ was, now, flagged }`, each version's
|
|
78
|
+
// scripts by name and the names flagged; null for none. One dropped isn't
|
|
79
|
+
// flagged.
|
|
80
|
+
export function npmInstallScriptsChange(base, other) {
|
|
81
|
+
const scripts = ({ manifest, paths }) => new Map(npmInstallScripts(manifest, paths))
|
|
82
|
+
const now = scripts(other), was = scripts(base)
|
|
83
|
+
const flagged = [...now].filter(([script, command]) => was.get(script) !== command).map(([script]) => script)
|
|
84
|
+
return flagged.length === 0 ? null : { was, now, flagged }
|
|
85
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A version's risk as one number, out of 100: what its findings call for,
|
|
2
|
+
// lowered by how long it has been out among how many who would have noticed.
|
|
3
|
+
// Its findings stay what a reviewer reads; the score orders what to read
|
|
4
|
+
// first.
|
|
5
|
+
import { DAY_MS } from './downloads.js'
|
|
6
|
+
|
|
7
|
+
// How much each finding raises the score, by its severity: a version's
|
|
8
|
+
// findings combined as independent chances are (1 − Π(1 − weight)), so that
|
|
9
|
+
// each adds less the more there are, none past 100. One red finding alone is
|
|
10
|
+
// 50; two are 75, and one with two orange 68, as attacks show.
|
|
11
|
+
const WEIGHTS = { critical: 0.5, high: 0.2, moderate: 0.08, low: 0.03, notice: 0 }
|
|
12
|
+
// What the version's age tells instead (`too-new`), as exposure does below.
|
|
13
|
+
const TOLD_BY_AGE = new Set(['too-new'])
|
|
14
|
+
|
|
15
|
+
// How much a version's exposure lowers the score, at most: a version out for
|
|
16
|
+
// years among millions keeps a tenth of what its findings call for, as an
|
|
17
|
+
// attack that long among that many would have been found and pulled.
|
|
18
|
+
const MOST_LOWERED = 0.9
|
|
19
|
+
// From how old a version is lowered at all, and how old fully: two weeks, as
|
|
20
|
+
// risk's minimum release age (release.js), and two years, on a log scale.
|
|
21
|
+
const EXPOSED_FROM_DAYS = 14
|
|
22
|
+
const EXPOSED_DAYS = 730
|
|
23
|
+
// How much who downloads a package lowers it, by its tier (downloads.js): its
|
|
24
|
+
// rank by downloads. The top 200 fully; one few download hardly, as few would
|
|
25
|
+
// notice; one whose downloads aren't known (null, a caller's own package, or
|
|
26
|
+
// one npm counts none of) a little.
|
|
27
|
+
const TIER_EXPOSURE = [[200, 1], [1000, 0.95], [5000, 0.85], [20_000, 0.65], [100_000, 0.35], [Infinity, 0.1]]
|
|
28
|
+
const UNKNOWN_TIER_EXPOSURE = 0.25
|
|
29
|
+
|
|
30
|
+
// The score's levels, as the findings' severities name theirs.
|
|
31
|
+
const LEVELS = [[75, 'critical'], [50, 'high'], [25, 'moderate'], [0, 'low']]
|
|
32
|
+
export const npmRiskLevel = score => LEVELS.find(([from]) => score >= from)[1]
|
|
33
|
+
|
|
34
|
+
// How exposed a version has been, from 0 to 1: how long it has been out
|
|
35
|
+
// (`published` an ISO time, at `now`) times how widely its package is
|
|
36
|
+
// downloaded (`tier`, null where unknown).
|
|
37
|
+
export function npmExposure(published, now, tier) {
|
|
38
|
+
const days = typeof published === 'string' ? (now - Date.parse(published)) / DAY_MS : NaN
|
|
39
|
+
if (!(days > EXPOSED_FROM_DAYS)) return 0
|
|
40
|
+
const age = Math.min(1, Math.log(days / EXPOSED_FROM_DAYS) / Math.log(EXPOSED_DAYS / EXPOSED_FROM_DAYS))
|
|
41
|
+
const wide = tier === null ? UNKNOWN_TIER_EXPOSURE : TIER_EXPOSURE.find(([upTo]) => tier <= upTo)[1]
|
|
42
|
+
return age * wide
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A version's risk score (`score`, 0 to 100) and its `level`, from its
|
|
46
|
+
// assessment's `findings` and its exposure (npmExposure): 100 where malware
|
|
47
|
+
// is told of it, however old; else what its findings call for (`raised`, each
|
|
48
|
+
// `{ id, severity }` it counts), lowered by its exposure (`lowered`: its
|
|
49
|
+
// `days` out, its `tier`, and by how much, a share), none lowered under two
|
|
50
|
+
// weeks.
|
|
51
|
+
export function npmRiskScore(findings, { published = null, now = Date.now(), tier = null } = {}) {
|
|
52
|
+
if (findings.some(({ id }) => id === 'malware')) return { score: 100, level: 'critical', raised: [{ id: 'malware', severity: 'critical' }] }
|
|
53
|
+
const raised = findings.filter(({ id, severity }) => !TOLD_BY_AGE.has(id) && WEIGHTS[severity] > 0).map(({ id, severity }) => ({ id, severity }))
|
|
54
|
+
const called = 1 - raised.reduce((left, { severity }) => left * (1 - WEIGHTS[severity]), 1)
|
|
55
|
+
const exposure = npmExposure(published, now, tier)
|
|
56
|
+
const score = Math.round(100 * called * (1 - MOST_LOWERED * exposure))
|
|
57
|
+
return { score, level: npmRiskLevel(score), raised,
|
|
58
|
+
...exposure > 0 ? { lowered: { days: Math.floor((now - Date.parse(published)) / DAY_MS), tier: Number.isFinite(tier) ? tier : null, by: Math.round(100 * MOST_LOWERED * exposure) / 100 } } : {} }
|
|
59
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Where npm installs a dependency from, read off its spec as npm-package-arg
|
|
2
|
+
// reads one: the registry (a version, a range, a tag, an `npm:` alias), a
|
|
3
|
+
// path, a repository on a host npm knows, a git URL or a tarball's.
|
|
4
|
+
|
|
5
|
+
// The hosts npm takes a repository on (hosted-git-info, which npm-package-arg
|
|
6
|
+
// reads a spec with): by name (`github:owner/repo`), or by its domain over
|
|
7
|
+
// the protocols each takes, where its path names a repository, `[user,
|
|
8
|
+
// project, committish]`, and not a tarball or a file.
|
|
9
|
+
const trim = project => project?.replace(/\.git$/u, '')
|
|
10
|
+
const GIT_HOSTS = new Map([
|
|
11
|
+
['github', { label: 'GitHub', domain: 'github.com', protocols: ['git:', 'http:', 'git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
|
|
12
|
+
const [, user, project, type, committish] = pathname.split('/', 5)
|
|
13
|
+
return (!type || type === 'tree') && user && trim(project) ? [user, trim(project), type ? committish : hash.slice(1)] : null
|
|
14
|
+
} }],
|
|
15
|
+
['bitbucket', { label: 'Bitbucket', domain: 'bitbucket.org', protocols: ['git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
|
|
16
|
+
const [, user, project, aux] = pathname.split('/', 4)
|
|
17
|
+
return aux !== 'get' && user && trim(project) ? [user, trim(project), hash.slice(1)] : null
|
|
18
|
+
} }],
|
|
19
|
+
['gitlab', { label: 'GitLab', domain: 'gitlab.com', protocols: ['git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
|
|
20
|
+
const path = pathname.slice(1)
|
|
21
|
+
const at = path.lastIndexOf('/')
|
|
22
|
+
if (path.includes('/-/') || path.includes('/archive.tar.gz')) return null
|
|
23
|
+
return path.slice(0, Math.max(at, 0)) && trim(path.slice(at + 1)) ? [path.slice(0, at), trim(path.slice(at + 1)), hash.slice(1)] : null
|
|
24
|
+
} }],
|
|
25
|
+
['gist', { label: 'Gist', domain: 'gist.github.com', protocols: ['git:', 'git+ssh:', 'git+https:', 'ssh:', 'https:'], repo({ pathname, hash }) {
|
|
26
|
+
const [, user, project, aux] = pathname.split('/', 4)
|
|
27
|
+
return aux !== 'raw' && (user || project) ? [project ? user : null, trim(project || user), hash.slice(1)] : null
|
|
28
|
+
} }],
|
|
29
|
+
['sourcehut', { label: 'SourceHut', domain: 'git.sr.ht', protocols: ['git+ssh:', 'https:'], repo({ pathname, hash }) {
|
|
30
|
+
const [, user, project, aux] = pathname.split('/', 4)
|
|
31
|
+
return aux !== 'archive' && user && trim(project) ? [user, trim(project), hash.slice(1)] : null
|
|
32
|
+
} }],
|
|
33
|
+
])
|
|
34
|
+
const BY_DOMAIN = new Map([...GIT_HOSTS.values()].map(host => [host.domain, host]))
|
|
35
|
+
// The protocols hosted-git-info reads a URL by as it is, the hosts' names too.
|
|
36
|
+
const PROTOCOLS = new Set(['git+ssh:', 'ssh:', 'git+https:', 'git:', 'http:', 'https:', 'git+http:', ...[...GIT_HOSTS.keys()].map(name => `${name}:`)])
|
|
37
|
+
|
|
38
|
+
// GitHub's `owner/repo` shorthand, before a commit, branch or tag
|
|
39
|
+
// (`#v1.0.0`) or not, as hosted-git-info tells it: a slash, not first, nor
|
|
40
|
+
// last before any `#`; no `.` first; and no space, `@`, `:` or second slash
|
|
41
|
+
// before any `#`.
|
|
42
|
+
function githubShorthand(spec) {
|
|
43
|
+
const hash = spec.indexOf('#'), slash = spec.indexOf('/')
|
|
44
|
+
const beforeHash = at => at > -1 && !(hash > -1 && at > hash)
|
|
45
|
+
return slash > 0 && !spec.startsWith('.') && (hash > -1 ? spec[hash - 1] !== '/' : !spec.endsWith('/'))
|
|
46
|
+
&& ![/\s/u.exec(spec)?.index ?? -1, spec.indexOf('@'), spec.indexOf(':'), spec.indexOf('/', slash + 1)].some(beforeHash)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A URL as hosted-git-info parses one: a protocol it doesn't know put
|
|
50
|
+
// before a `git@host:path` (as `git+ssh://`) or given its `//`, and else
|
|
51
|
+
// `host:path` read as `host/path`.
|
|
52
|
+
const safeUrl = text => { try { return new URL(text) } catch { return null } }
|
|
53
|
+
function hostedUrl(spec) {
|
|
54
|
+
const at = spec.indexOf('@'), colon = spec.indexOf(':')
|
|
55
|
+
const corrected = PROTOCOLS.has(spec.slice(0, colon + 1)) || at > -1 && at < colon ? spec : at > -1 ? `git+ssh://${spec}`
|
|
56
|
+
: spec.indexOf('//') === colon + 1 ? spec : `${spec.slice(0, colon + 1)}//${spec.slice(colon + 1)}`
|
|
57
|
+
if (safeUrl(corrected)) return safeUrl(corrected)
|
|
58
|
+
const before = char => { const hash = corrected.indexOf('#'); return corrected.lastIndexOf(char, hash > -1 ? hash : Infinity) }
|
|
59
|
+
let scp = before(':') > before('@') ? `${corrected.slice(0, before(':'))}/${corrected.slice(before(':') + 1)}` : corrected
|
|
60
|
+
if (scp.lastIndexOf(':', scp.indexOf('#') > -1 ? scp.indexOf('#') : Infinity) === -1 && !scp.includes('//')) scp = `git+ssh://${scp}`
|
|
61
|
+
return safeUrl(scp)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The host npm takes a spec's repository on (hosted-git-info's fromUrl), or
|
|
65
|
+
// null: one by its name, whatever its path, or by its domain over a protocol
|
|
66
|
+
// it takes, where its path names a repository; none whose parts don't
|
|
67
|
+
// decode.
|
|
68
|
+
function gitHost(spec) {
|
|
69
|
+
if (!spec) return null
|
|
70
|
+
const url = hostedUrl(githubShorthand(spec) ? `github:${spec}` : spec)
|
|
71
|
+
if (!url) return null
|
|
72
|
+
const named = GIT_HOSTS.get(url.protocol.slice(0, -1))
|
|
73
|
+
const host = named ?? BY_DOMAIN.get(url.hostname.replace(/^www\./u, ''))
|
|
74
|
+
const parts = named ? [url.pathname.replace(/^\/?[^@]*@|^\//u, ''), url.hash.slice(1)] : host?.protocols.includes(url.protocol) && host.repo(url)
|
|
75
|
+
try { return parts ? (parts.forEach(part => part && decodeURIComponent(part)), host) : null } catch { return null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Where npm installs a dependency's spec from, read once in
|
|
79
|
+
// npm-package-arg's resolve order: `path` for a path (`./x`, `~/x`, `/x`,
|
|
80
|
+
// `c:/x`, `file:`); `registry` for an `npm:` alias of a registry package,
|
|
81
|
+
// or a spec with no `/` or `:` that names no tarball, which no later step
|
|
82
|
+
// takes; a repository on a host npm knows (gitHost), named for it
|
|
83
|
+
// (`GitHub`); for a URL, `git` for a repository's (`git:`, `git+https:`,
|
|
84
|
+
// `git+ssh:`, `git+http:`, `git+rsync:`, `git+ftp:`, `git+file:`, and
|
|
85
|
+
// `git+ssh://` to `user@host:path`), `URL` for a tarball's (`https:`,
|
|
86
|
+
// `http:`), and null for one npm doesn't install (`workspace:`, `ssh:` on
|
|
87
|
+
// another host); then `path` again for one with a `/` or a tarball's name
|
|
88
|
+
// (`a/b/c`, `x.tgz`, `git@example.com:x/y`), and else `registry`: a
|
|
89
|
+
// version, a range or a tag.
|
|
90
|
+
function specFrom(spec) {
|
|
91
|
+
const value = String(spec ?? '').trim()
|
|
92
|
+
if (/^(?:[.]|~\/|\/|[a-z]:|file:)/iu.test(value)) return 'path'
|
|
93
|
+
if (/^npm:/iu.test(value) || !/[/:]|[.](?:tgz|tar\.gz|tar)$/iu.test(value)) return 'registry'
|
|
94
|
+
const host = gitHost(value)
|
|
95
|
+
if (host) return host.label
|
|
96
|
+
if (/^(?:git\+)?[a-z]+:/iu.test(value)) {
|
|
97
|
+
const scp = /^git\+ssh:\/\/([^:#]+:[^#]+(?:\.git)?)(?:#.*)?$/iu.exec(value)
|
|
98
|
+
if (value.startsWith('git+ssh:') && scp && !/:\d+(?:\/|$)/u.test(scp[1])) return 'git'
|
|
99
|
+
const protocol = safeUrl(value)?.protocol
|
|
100
|
+
return /^(?:git|git\+(?:https?|rsync|ftp|file|ssh)):$/u.test(protocol) ? 'git' : /^https?:$/u.test(protocol) ? 'URL' : null
|
|
101
|
+
}
|
|
102
|
+
return /\/|[.](?:tgz|tar\.gz|tar)$/iu.test(value) ? 'path' : 'registry'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Where a dependency's spec has npm fetch it from, where that isn't its
|
|
106
|
+
// registry (specFrom): a host's name (`GitHub`), `git` or `URL`. Null for
|
|
107
|
+
// the registry's ranges and tags, a path, and what npm doesn't install.
|
|
108
|
+
export function npmDependencySource(spec) {
|
|
109
|
+
const from = specFrom(spec)
|
|
110
|
+
return from === 'registry' || from === 'path' ? null : from
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The package and spec an `npm:` alias names (`npm:pkg@^1.2.3`), as
|
|
114
|
+
// specFrom reads one; null for a spec that isn't one.
|
|
115
|
+
export function npmAlias(spec) {
|
|
116
|
+
const [, name, range = ''] = /^npm:((?:@[^/@]+\/)?[^/@]+)(?:@(.*))?$/iu.exec(String(spec ?? '').trim()) ?? []
|
|
117
|
+
return name ? { name, spec: range } : null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Whether npm installs a dependency's spec from its registry (specFrom):
|
|
121
|
+
// not from a path, a repository or a URL, nor a scheme npm doesn't install
|
|
122
|
+
// (`workspace:`), the registry's package of that name being what npm
|
|
123
|
+
// installs for none of those.
|
|
124
|
+
export const npmRegistrySpec = spec => specFrom(spec) === 'registry'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A package's files from its tarball, as it holds them, whatever installs it
|
|
2
|
+
// (npm renames a .gitignore .npmignore, pnpm and yarn keep it): gunzipped and
|
|
3
|
+
// read as a tar archive by @preventive/archive, which refuses one malformed, a
|
|
4
|
+
// name or a symlink that would climb out of it, and a name repeated as another
|
|
5
|
+
// entry; each file by its path there, its first component dropped; a text
|
|
6
|
+
// where it is UTF-8 with no NUL, else null. Held to the npm viewer's bounds
|
|
7
|
+
// (server-managed/npm-packages.ts readNpmTar).
|
|
8
|
+
import { CompressionError, decompress } from '@preventive/archive/compression.js'
|
|
9
|
+
import { ArchiveError, unpack } from '@preventive/archive/tar.js'
|
|
10
|
+
|
|
11
|
+
const MAX_PACKAGE_FILES = 20_000
|
|
12
|
+
export const MAX_PACKAGE_BYTES = 64 * 1024 * 1024
|
|
13
|
+
// The entries its tar stream may hold, a directory's too, read before any is
|
|
14
|
+
// counted as a file, as npm-packages.ts bounds them (MAX_NPM_TAR_ENTRIES).
|
|
15
|
+
export const MAX_TAR_ENTRIES = 2 * MAX_PACKAGE_FILES
|
|
16
|
+
// What its tar stream may unpack to, its headers and padding beside its
|
|
17
|
+
// files, as npm-loads.ts bounds it (MAX_TAR_BYTES).
|
|
18
|
+
export const MAX_TAR_BYTES = 96 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
21
|
+
|
|
22
|
+
// The files of a tar archive, by path: their bytes. A file at its top is not
|
|
23
|
+
// extracted, and one under another top directory at the same path replaces
|
|
24
|
+
// an earlier one, as extraction would. Past MAX_TAR_ENTRIES it is given up on
|
|
25
|
+
// as `package-too-large`, before any more of it is read.
|
|
26
|
+
function npmTarFiles(tar) {
|
|
27
|
+
let entries
|
|
28
|
+
try {
|
|
29
|
+
entries = unpack(tar, { maxEntries: MAX_TAR_ENTRIES })
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ArchiveError && error.limited) throw new Error('package-too-large', { cause: error })
|
|
32
|
+
throw error
|
|
33
|
+
}
|
|
34
|
+
const files = new Map()
|
|
35
|
+
let total = 0
|
|
36
|
+
for (const { name, type, data } of entries) {
|
|
37
|
+
const slash = name.indexOf('/')
|
|
38
|
+
if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
|
|
39
|
+
const path = name.slice(slash + 1)
|
|
40
|
+
total += data.length - (files.get(path)?.length ?? 0)
|
|
41
|
+
files.set(path, data)
|
|
42
|
+
if (files.size > MAX_PACKAGE_FILES || total > MAX_PACKAGE_BYTES) throw new Error('package-too-large')
|
|
43
|
+
}
|
|
44
|
+
return files
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A file's text: UTF-8 with no NUL, else null.
|
|
48
|
+
export function npmFileText(bytes) {
|
|
49
|
+
if (bytes.includes(0)) return null
|
|
50
|
+
try { return utf8.decode(bytes) } catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A gzipped tarball's tar stream, through @preventive/archive's gzip, given up
|
|
54
|
+
// on as `package-too-large` once it unpacks past MAX_TAR_BYTES, before any
|
|
55
|
+
// more of it is held.
|
|
56
|
+
async function gunzip(gzipped) {
|
|
57
|
+
try {
|
|
58
|
+
return await decompress(gzipped, 'gzip', { limit: MAX_TAR_BYTES })
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof CompressionError && error.limited) throw new Error('package-too-large', { cause: error })
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Whether a file that isn't text may run as JavaScript all the same, as node
|
|
66
|
+
// decodes it as UTF-8 (npm/files.js npmLoadedScripts): only where its first
|
|
67
|
+
// character no UTF-8 encodes, or its first NUL, which JavaScript holds only
|
|
68
|
+
// in a string, a template, a regular expression or a comment, may sit in
|
|
69
|
+
// one, after a quote, a backtick, a slash, a `<` or `-` (an HTML-like
|
|
70
|
+
// comment, `<!--`, `-->`) or on a hashbang's line. An image's, a font's or
|
|
71
|
+
// WebAssembly's is in its first bytes, before any.
|
|
72
|
+
const OPENS = [0x27, 0x22, 0x60, 0x2F, 0x3C, 0x2D]
|
|
73
|
+
function npmRunnable(bytes) {
|
|
74
|
+
const start = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
|
75
|
+
if (bytes[start] === 0x23 && bytes[start + 1] === 0x21) return true
|
|
76
|
+
const opens = Math.min(...OPENS.map(byte => bytes.indexOf(byte, start)).filter(at => at !== -1))
|
|
77
|
+
return Number.isFinite(opens) && npmFileText(bytes.subarray(0, opens)) !== null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A file's bytes' digest, as the managed server gives one of a file that
|
|
81
|
+
// isn't text (server-managed/npm-packages.ts).
|
|
82
|
+
const sha256 = async bytes => `sha256-${btoa(String.fromCodePoint(...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))))}`
|
|
83
|
+
|
|
84
|
+
// A gzipped tarball's files as a version holds them for risk, as the npm
|
|
85
|
+
// viewer holds a version (ui/view/npm-package.js npmPackageDetails), a source
|
|
86
|
+
// map listing them (`kind: 'sourcemap'`): `json.sources` their paths, sorted,
|
|
87
|
+
// `json.sourcesContent` their texts, `fileSizes` by path, and `npmBinaries`,
|
|
88
|
+
// those that aren't text, `{ size, digest, runnable }` by path (npmRunnable),
|
|
89
|
+
// which compare.js and
|
|
90
|
+
// diff.js tell them by.
|
|
91
|
+
export async function npmTarballVersion(gzipped) {
|
|
92
|
+
const files = [...npmTarFiles(await gunzip(gzipped))].toSorted(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
93
|
+
const texts = files.map(([, bytes]) => npmFileText(bytes))
|
|
94
|
+
const binaries = await Promise.all(files.filter((_, at) => texts[at] === null).map(async ([path, bytes]) => [path, { size: bytes.length, digest: await sha256(bytes), runnable: npmRunnable(bytes) }]))
|
|
95
|
+
return { kind: 'sourcemap', json: { version: 3, sources: files.map(([path]) => path), sourcesContent: texts }, fileSizes: new Map(files.map(([path, bytes]) => [path, bytes.length])), npmBinaries: new Map(binaries) }
|
|
96
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { env } from 'node:process'
|
|
2
|
+
|
|
3
|
+
// One shared URL or per-mode URLs. Validate before opening either store so
|
|
4
|
+
// an ambiguous configuration cannot send application traffic and GC elsewhere.
|
|
5
|
+
export function databaseUrls({ combined = false } = {}) {
|
|
6
|
+
const shared = env['DATABASE_URL'] || null
|
|
7
|
+
const e2e = env['E2E_DATABASE_URL'] || null
|
|
8
|
+
const managed = env['MANAGED_DATABASE_URL'] || null
|
|
9
|
+
if (shared && (e2e || managed)) {
|
|
10
|
+
throw new Error('DATABASE_URL cannot be combined with E2E_DATABASE_URL or MANAGED_DATABASE_URL. Use the shared URL alone, or only the per-mode URLs.')
|
|
11
|
+
}
|
|
12
|
+
if (combined && Boolean(e2e) !== Boolean(managed)) {
|
|
13
|
+
throw new Error('Combined mode requires both E2E_DATABASE_URL and MANAGED_DATABASE_URL, DATABASE_URL alone, or no database URLs. Mixing Neon and SQLite is not supported.')
|
|
14
|
+
}
|
|
15
|
+
return { e2e: shared ?? e2e, managed: shared ?? managed }
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Register resources as they are acquired. Failed assembly rolls them back in
|
|
2
|
+
// reverse order; successful assembly transfers ownership to the returned app.
|
|
3
|
+
export async function initializeApp<T>(assemble: (rollback: AsyncDisposableStack) => T | Promise<T>): Promise<T> {
|
|
4
|
+
const rollback = new AsyncDisposableStack()
|
|
5
|
+
try {
|
|
6
|
+
const app = await assemble(rollback)
|
|
7
|
+
rollback.move()
|
|
8
|
+
return app
|
|
9
|
+
} catch (error) {
|
|
10
|
+
try { await rollback.disposeAsync() }
|
|
11
|
+
catch (cleanupError) {
|
|
12
|
+
// AsyncDisposableStack runs every cleanup even if one fails. Preserve
|
|
13
|
+
// both failures so cleanup errors cannot hide the initialization error.
|
|
14
|
+
throw new SuppressedError(cleanupError, error, 'App initialization and cleanup failed')
|
|
15
|
+
}
|
|
16
|
+
throw error
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
|
|
3
|
+
const UPSTREAM_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
|
|
4
|
+
const RESPONSE_BODY_LIMIT = 4 * 1024 * 1024
|
|
5
|
+
export const NPM_ADVISORIES_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
export async function fetchNpmAdvisories(body: Buffer, signal: AbortSignal, debug = false): Promise<{ status: number; body: unknown }> {
|
|
8
|
+
let upstream: Response
|
|
9
|
+
try {
|
|
10
|
+
upstream = await fetch(UPSTREAM_URL, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
// Force JSON — the bulk endpoint requires it. Drop every
|
|
13
|
+
// client-supplied header to keep an upstream fingerprint from
|
|
14
|
+
// leaking through (cookies, auth, custom UA, ...). The
|
|
15
|
+
// registry's bulk endpoint doesn't need any of them for a
|
|
16
|
+
// public lookup.
|
|
17
|
+
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
18
|
+
// Re-wrap as a plain Uint8Array — Buffer's underlying
|
|
19
|
+
// ArrayBufferLike type doesn't satisfy fetch's BodyInit
|
|
20
|
+
// narrowing (it can't statically rule out SharedArrayBuffer),
|
|
21
|
+
// but a copy through Uint8Array is zero-cost in practice and
|
|
22
|
+
// unambiguously typed.
|
|
23
|
+
body: new Uint8Array(body),
|
|
24
|
+
signal,
|
|
25
|
+
})
|
|
26
|
+
} catch (err: unknown) {
|
|
27
|
+
if (debug) console.warn('npm-advisories upstream error:', err)
|
|
28
|
+
return { status: 502, body: { error: 'upstream-unreachable' } }
|
|
29
|
+
}
|
|
30
|
+
// Assert JSON on the upstream body. The Content-Type header is
|
|
31
|
+
// unreliable (Cloudflare in front of registry.npmjs.org strips it
|
|
32
|
+
// from some responses; a captive portal / WAF can declare HTML on
|
|
33
|
+
// a body that's actually JSON or vice-versa), so we don't lean on
|
|
34
|
+
// it — instead we buffer the body and parse. A successful
|
|
35
|
+
// JSON.parse is the strongest guarantee we can hand the UI's
|
|
36
|
+
// `await res.json()`. Buffering is bounded by
|
|
37
|
+
// `RESPONSE_BODY_LIMIT`; the advisories endpoint's payloads sit
|
|
38
|
+
// well under that.
|
|
39
|
+
const upstreamContentType = upstream.headers.get('content-type') ?? ''
|
|
40
|
+
let buffered: Buffer | null
|
|
41
|
+
try {
|
|
42
|
+
buffered = await readUpstreamBody(upstream)
|
|
43
|
+
} catch (err: unknown) {
|
|
44
|
+
if (debug) console.warn('npm-advisories upstream body error:', err)
|
|
45
|
+
return { status: 502, body: { error: 'upstream-unreachable' } }
|
|
46
|
+
}
|
|
47
|
+
if (buffered === null) {
|
|
48
|
+
if (debug) console.warn(`npm-advisories upstream too large: status=${upstream.status}`)
|
|
49
|
+
return { status: 502, body: { error: 'upstream-too-large', upstreamStatus: upstream.status } }
|
|
50
|
+
}
|
|
51
|
+
// Treat the body as UTF-8 — `JSON.parse` operates on a string and
|
|
52
|
+
// the registry's responses are always UTF-8 in practice. A
|
|
53
|
+
// non-UTF-8 byte sequence still decodes (with U+FFFD
|
|
54
|
+
// substitution); the subsequent JSON.parse fails and routes
|
|
55
|
+
// through the error branch.
|
|
56
|
+
const text = buffered.toString('utf8')
|
|
57
|
+
let parsed: unknown
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(text)
|
|
60
|
+
} catch {
|
|
61
|
+
if (debug) console.warn(`npm-advisories upstream non-JSON: status=${upstream.status} ct=${upstreamContentType || '<none>'} bytes=${buffered.byteLength}`)
|
|
62
|
+
return { status: 502, body: {
|
|
63
|
+
error: 'upstream-not-json',
|
|
64
|
+
upstreamStatus: upstream.status,
|
|
65
|
+
upstreamContentType: upstreamContentType || null,
|
|
66
|
+
} }
|
|
67
|
+
}
|
|
68
|
+
return { status: upstream.status, body: parsed }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Buffer the upstream response body up to RESPONSE_BODY_LIMIT.
|
|
72
|
+
// Returns null if the cap is exceeded (caller maps to a 502
|
|
73
|
+
// `upstream-too-large`), or the cumulative Buffer otherwise. A
|
|
74
|
+
// transport error mid-read (e.g. AbortSignal fired by the deadline
|
|
75
|
+
// timer or by `req` close) throws — the caller catches it.
|
|
76
|
+
//
|
|
77
|
+
// `finally { reader.cancel() }` is load-bearing on the error and
|
|
78
|
+
// cap-exceeded paths: leaving the reader locked to the body holds
|
|
79
|
+
// the underlying undici TCP socket out of the connection pool until
|
|
80
|
+
// GC, and the cap-exceeded path explicitly needs to tear the
|
|
81
|
+
// transfer down so we don't keep buffering bytes we'll never use.
|
|
82
|
+
// On the clean-drain path (done:true), cancel() is a no-op.
|
|
83
|
+
async function readUpstreamBody(upstream: Response): Promise<Buffer | null> {
|
|
84
|
+
if (!upstream.body) return Buffer.alloc(0)
|
|
85
|
+
const reader = upstream.body.getReader()
|
|
86
|
+
const chunks: Uint8Array[] = []
|
|
87
|
+
let received = 0
|
|
88
|
+
try {
|
|
89
|
+
for (;;) {
|
|
90
|
+
const { done, value } = await reader.read()
|
|
91
|
+
if (done) break
|
|
92
|
+
if (!value) continue
|
|
93
|
+
received += value.byteLength
|
|
94
|
+
if (received > RESPONSE_BODY_LIMIT) return null
|
|
95
|
+
chunks.push(value)
|
|
96
|
+
}
|
|
97
|
+
return Buffer.concat(chunks)
|
|
98
|
+
} finally {
|
|
99
|
+
try { await reader.cancel() } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Same-origin gate
|
|
2
|
-
// support cross-origin browser clients, so any Origin header present
|
|
3
|
-
// on an incoming request MUST match the server's own host (derived
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Same-origin gate, shared by the e2e and managed servers (server-common).
|
|
2
|
+
// We don't support cross-origin browser clients, so any Origin header present
|
|
3
|
+
// on an incoming request MUST match the server's own host (derived from
|
|
4
|
+
// `req.headers.host`, or from `X-Forwarded-Host` / `X-Forwarded-Proto` when a
|
|
5
|
+
// trusted reverse proxy is in front).
|
|
6
6
|
//
|
|
7
7
|
// Why "present-must-match" rather than "always required":
|
|
8
8
|
// - Browser WebSocket handshakes always carry Origin (RFC 6455), so a
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
|
|
4
|
+
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
5
|
+
type Reapers = Record<string, () => Promise<unknown>>
|
|
6
|
+
type Options = { secret?: string; isShuttingDown?: () => boolean }
|
|
7
|
+
|
|
8
|
+
// Start every cleanup and wait for all of them, including when one fails.
|
|
9
|
+
// The caller must not close storage or return success with work still running.
|
|
10
|
+
export async function runReapers(reapers: Reapers): Promise<void> {
|
|
11
|
+
const results = await Promise.allSettled(Object.values(reapers).map(reap => Promise.resolve().then(reap)))
|
|
12
|
+
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
|
|
13
|
+
if (errors.length > 0) throw new AggregateError(errors, 'Cleanup failed')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createReapHandler(reapers: Reapers, { secret = process.env['CRON_SECRET'], isShuttingDown = () => false }: Options = {}) {
|
|
17
|
+
const key = randomBytes(32)
|
|
18
|
+
const hash = (value: string) => createHmac('sha256', key).update(value).digest()
|
|
19
|
+
const expected = secret ? hash(`Bearer ${secret}`) : null
|
|
20
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
21
|
+
const send = (status: number, body: object, headers = {}) => {
|
|
22
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
|
|
23
|
+
res.end(JSON.stringify(body))
|
|
24
|
+
}
|
|
25
|
+
const authorization = req.headers.authorization
|
|
26
|
+
if (req.headers['x-deepview-share'] !== undefined) { send(403, { error: 'share-scope-required' }); return }
|
|
27
|
+
if (!expected || typeof authorization !== 'string' || !timingSafeEqual(hash(authorization), expected)) {
|
|
28
|
+
send(401, { error: 'unauthorized' }); return
|
|
29
|
+
}
|
|
30
|
+
if (req.method !== 'GET') { send(405, { error: 'method-not-allowed' }, { allow: 'GET' }); return }
|
|
31
|
+
if (isShuttingDown()) { send(503, { error: 'shutting-down' }); return }
|
|
32
|
+
const startedAt = Date.now()
|
|
33
|
+
try {
|
|
34
|
+
await runReapers(reapers)
|
|
35
|
+
send(200, { ok: true, reaped: Object.keys(reapers), ms: Date.now() - startedAt })
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('reap failed:', err)
|
|
38
|
+
send(500, { error: 'reap-failed' })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Only top-level servers compose this route. Domain routers stay unaware of
|
|
44
|
+
// other modes, and cleanup uses the stores those servers have already opened.
|
|
45
|
+
export function withReap(next: Handler, reapers: Reapers, options: Options = {}): Handler {
|
|
46
|
+
const reap = createReapHandler(reapers, options)
|
|
47
|
+
return (req, res) => req.url?.split('?', 1)[0] === '/api/reap' ? reap(req, res) : next(req, res)
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { normalizeScanServer } from '../common/scan-server.ts'
|
|
2
|
+
|
|
3
|
+
export function configuredScanServer(value: string | undefined): string | null {
|
|
4
|
+
if (!value?.trim()) return null
|
|
5
|
+
const server = normalizeScanServer(value)
|
|
6
|
+
if (!server) throw new Error('DEEPVIEW_SCAN_SERVER must be an absolute HTTP(S) URL without credentials, query, or fragment')
|
|
7
|
+
return server
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Apply at serve time, before compression and ETag generation. Packaged HTML
|
|
11
|
+
// stays strict and does not bake in the build machine's configuration.
|
|
12
|
+
export function scanServerHtml(html: string, server: string | null, { advertise = false } = {}): string {
|
|
13
|
+
if (!server) return html
|
|
14
|
+
const origin = new URL(server).origin
|
|
15
|
+
const result = html.replace(/(connect-src 'self')(?=[;"])/u, `$1 ${origin}`)
|
|
16
|
+
if (!advertise) return result
|
|
17
|
+
const content = server.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
|
18
|
+
return result.replace('</head>', `<meta name="deepview-scan-server" content="${content}">\n</head>`)
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Server } from 'node:http'
|
|
2
|
+
|
|
3
|
+
// Only CLI entry points own process signals and termination. Embedded servers
|
|
4
|
+
// expose async disposal and error events to their host instead.
|
|
5
|
+
export function startServer(server: Server, config: { port: number; host: string }): void {
|
|
6
|
+
let shuttingDown = false
|
|
7
|
+
let exitCode = 0
|
|
8
|
+
function shutdown(code: number): void {
|
|
9
|
+
if (code !== 0) exitCode = code
|
|
10
|
+
if (shuttingDown) return
|
|
11
|
+
shuttingDown = true
|
|
12
|
+
console.log('Shutting down…')
|
|
13
|
+
void server[Symbol.asyncDispose]().catch(err => {
|
|
14
|
+
console.error('Shutdown failed:', err)
|
|
15
|
+
exitCode = 1
|
|
16
|
+
}).finally(() => process.exit(exitCode))
|
|
17
|
+
}
|
|
18
|
+
function onSignal(): void { shutdown(0) }
|
|
19
|
+
function onError(err: unknown): void {
|
|
20
|
+
console.error('Server error:', err)
|
|
21
|
+
shutdown(1)
|
|
22
|
+
}
|
|
23
|
+
server.on('error', onError)
|
|
24
|
+
process.on('SIGINT', onSignal)
|
|
25
|
+
process.on('SIGTERM', onSignal)
|
|
26
|
+
process.on('unhandledRejection', onError)
|
|
27
|
+
process.on('uncaughtException', onError)
|
|
28
|
+
server.listen(config.port, config.host)
|
|
29
|
+
}
|