@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2AFQKRAM.js +51 -0
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KVEUXIEU.js +1 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QZ3A376W.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TY4RZD3Z.js +226 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-U6OPEXIA.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-UZAZM2SX.js +2 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -0
- package/out/chunk-XL35ZV6R.js +1 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +298 -59
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +595 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +156 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +223 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +420 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// Every connected repository's npm packages, as its lockfiles lock them, each
|
|
2
|
+
// assessed by risk (npm-risk.ts), for admins: GET /api/admin/repositories/risk
|
|
3
|
+
// answers a line at a time as each is found and assessed, and stops once its
|
|
4
|
+
// reader leaves (`signal`).
|
|
5
|
+
//
|
|
6
|
+
// A repository's lockfiles are listed at its default branch's head by
|
|
7
|
+
// @preventive/upstream's findRepoLockfiles; of them, those of npm's ecosystem
|
|
8
|
+
// that @preventive/lockfile reads (package-lock.json and npm-shrinkwrap.json
|
|
9
|
+
// of `lockfileVersion: 3`, pnpm-lock.yaml of pnpm 9 to 12, and Yarn 1's
|
|
10
|
+
// yarn.lock) are read for their packages. A package from npm's registry is
|
|
11
|
+
// assessed once a scan, however many lockfiles lock it; one from another
|
|
12
|
+
// host with an integrity, such as a company's mirror of npm's, as npm's
|
|
13
|
+
// package of its name and version where npm's integrity is among it; one
|
|
14
|
+
// from elsewhere (git, a tarball with no integrity, a file) is listed, not
|
|
15
|
+
// assessed. Repositories are read one at a time, as GitHub's limits are the
|
|
16
|
+
// caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
|
|
17
|
+
// makes spare assessments. A lockfile's packages of npm's registry or a
|
|
18
|
+
// mirror are asked of npm's advisories all at once, as `npm audit` asks, each
|
|
19
|
+
// advisory told with whether it is of malware (risk's npmMalwareAdvisory),
|
|
20
|
+
// which alone is a supply chain's risk; the rest are vulnerabilities, told
|
|
21
|
+
// beside.
|
|
22
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
23
|
+
import type { Advisory } from '@preventive/upstream/advisories.js'
|
|
24
|
+
import type { LockfileFormat, RepoLockfile } from '@preventive/upstream/github.js'
|
|
25
|
+
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
26
|
+
import { NpmRiskError, SEVERITIES, type Severity, npmMalwareAdvisory } from '../risk/index.js'
|
|
27
|
+
import { isNpmPackageName, isNpmPackageSpec } from '../common/managed/npm-packages.js'
|
|
28
|
+
import { GithubApiError } from './github-errors.ts'
|
|
29
|
+
import { NpmPackageError, type NpmVersionDocument } from './npm-packages.ts'
|
|
30
|
+
import { type Lru, lru } from './lru.ts'
|
|
31
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
32
|
+
import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
|
|
33
|
+
import { type LockedPackage, lockedPackages } from './locked-packages.ts'
|
|
34
|
+
|
|
35
|
+
// The formats of npm's ecosystem, as findRepoLockfiles names them, and those
|
|
36
|
+
// of them read here.
|
|
37
|
+
const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
|
|
38
|
+
const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
|
|
39
|
+
|
|
40
|
+
// An assessment refused as busy (`npm-busy`, by npm-risk.ts or the npm reads
|
|
41
|
+
// beneath it), or that npm asked to ask later (risk's `upstream-busy`, as its
|
|
42
|
+
// downloads API answers past a burst of requests), is asked again after a
|
|
43
|
+
// wait, up to this many times.
|
|
44
|
+
const BUSY_RETRIES = 20
|
|
45
|
+
const BUSY_WAIT_MS = 1000
|
|
46
|
+
|
|
47
|
+
// The most severe of `findings`, or null for none.
|
|
48
|
+
export function highestSeverity(findings: readonly { severity: Severity }[]): Severity | null {
|
|
49
|
+
return SEVERITIES.find(severity => findings.some(finding => finding.severity === severity)) ?? null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A repository to read: where `paths` are given, a team's directories in it,
|
|
53
|
+
// only the lockfiles in them or above them, as a workspace's root lockfile
|
|
54
|
+
// locks them too.
|
|
55
|
+
export interface RepositoryRiskRepo { repoId: number; fullName: string; paths?: readonly string[] }
|
|
56
|
+
|
|
57
|
+
// Whether a lockfile at `path` is of `paths` (RepositoryRiskRepo).
|
|
58
|
+
function lockfileOf(path: string, paths: readonly string[] | undefined) {
|
|
59
|
+
const dir = path.slice(0, Math.max(path.lastIndexOf('/'), 0))
|
|
60
|
+
return !paths || paths.some(scope => dir === '' || dir === scope || dir.startsWith(`${scope}/`) || scope.startsWith(`${dir}/`))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
|
|
64
|
+
|
|
65
|
+
// A repository opened at its default branch's head (`commit`): its lockfiles,
|
|
66
|
+
// as findRepoLockfiles lists them, each with its blob's id, and a file's
|
|
67
|
+
// text; `recheck` checks access to it again, throwing where it is gone.
|
|
68
|
+
export interface OpenedRepository {
|
|
69
|
+
commit: string
|
|
70
|
+
lockfiles(): Promise<ListedLockfile[]>
|
|
71
|
+
file(path: string): Promise<string>
|
|
72
|
+
recheck(): Promise<void>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
|
|
76
|
+
|
|
77
|
+
export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
|
|
78
|
+
repos(): Promise<R[]>
|
|
79
|
+
open(repo: R): Promise<OpenedRepository>
|
|
80
|
+
// Whether a team lists a package's scope, its own packages.
|
|
81
|
+
teamScoped(name: string): Promise<boolean>
|
|
82
|
+
// npm's document of a version; an NpmPackageError where npm has none.
|
|
83
|
+
version(name: string, version: string, signal: AbortSignal): Promise<NpmVersionDocument>
|
|
84
|
+
assess(doc: NpmVersionDocument, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
|
|
85
|
+
// A version npm has no document of, as risk tells one npm removed: its
|
|
86
|
+
// malware alone, its package taken down by npm; an NpmRiskError where
|
|
87
|
+
// nothing tells of it (npm-risk.ts npmRiskRemoved).
|
|
88
|
+
removed(name: string, version: string, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
|
|
89
|
+
// npm's registry's advisories of these packages' versions, asked at once.
|
|
90
|
+
advisories(packages: { name: string; versions: string[] }[], signal: AbortSignal): Promise<Advisory[]>
|
|
91
|
+
// Where risk lines are kept beyond this instance, as RiskCaches keeps them
|
|
92
|
+
// in it (npm-kept.ts riskLineStore, the database's).
|
|
93
|
+
risks?: KeptStore<RiskLine>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type AdvisoryRow = Advisory & { malware: boolean }
|
|
97
|
+
|
|
98
|
+
// Why a step failed, as a line says it: an error's code, and for a lockfile
|
|
99
|
+
// refused, what of it its reader refused.
|
|
100
|
+
type RiskFailure = { error: string; detail?: string }
|
|
101
|
+
|
|
102
|
+
// A risk line is of npm's package of its name and version, or, with an
|
|
103
|
+
// `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
|
|
104
|
+
// of it, out of 100 (assessNpm `score`, risk/npm/score.js).
|
|
105
|
+
export type RepositoryRiskLine =
|
|
106
|
+
| { type: 'repos'; repos: { id: number; fullName: string }[] }
|
|
107
|
+
| ({ type: 'repo'; id: number } & ({ commit: string; lockfiles: { path: string; format: LockfileFormat }[] } | RiskFailure))
|
|
108
|
+
| ({ type: 'lockfile'; repo: number; path: string; format: LockfileFormat } & ({ packages: LockedPackage[] } | RiskFailure))
|
|
109
|
+
| ({ type: 'risk'; name: string; version: string; integrity?: string }
|
|
110
|
+
& ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
|
|
111
|
+
| ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
|
|
112
|
+
| { type: 'done' }
|
|
113
|
+
|
|
114
|
+
// What an error is known by: a GitHub or npm failure's code, or `failed`;
|
|
115
|
+
// GitHub not reached with what failed (githubUnreachable), while `doing` what
|
|
116
|
+
// of a repository, and risk's with what it asked of npm and how that failed,
|
|
117
|
+
// where they say.
|
|
118
|
+
function failureOf(error: unknown, doing?: string): RiskFailure {
|
|
119
|
+
if (error instanceof GithubApiError && error.detail) return { error: error.message, detail: doing ? `${doing}: ${error.detail}` : error.detail }
|
|
120
|
+
if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
|
|
121
|
+
if (!(error instanceof NpmRiskError)) return { error: 'failed' }
|
|
122
|
+
return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
|
|
126
|
+
|
|
127
|
+
type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
|
|
128
|
+
type LockfileRead = { packages: LockedPackage[] } | RiskFailure
|
|
129
|
+
|
|
130
|
+
// What scans keep between them, in this instance's memory: a repository's
|
|
131
|
+
// lockfiles as listed at a commit, and a lockfile's packages by its blob,
|
|
132
|
+
// neither of which changes; and a package's risk line and a lockfile's
|
|
133
|
+
// advisories, asked again after RISK_KEPT_MS, as risk tells again what of a
|
|
134
|
+
// package changes in time (a deprecation, its age, its downloads) and npm
|
|
135
|
+
// publishes advisories. Each is told only once access to its repository is
|
|
136
|
+
// checked as when it was read.
|
|
137
|
+
export interface RiskCaches {
|
|
138
|
+
listings: Lru<ListedLockfile[]>
|
|
139
|
+
lockfiles: Lru<LockfileRead>
|
|
140
|
+
risks: Lru<{ at: number; line: RiskLine }>
|
|
141
|
+
advisories: Lru<{ at: number; rows: AdvisoryRow[] }>
|
|
142
|
+
}
|
|
143
|
+
export const riskCaches = (): RiskCaches => ({
|
|
144
|
+
listings: lru(1000),
|
|
145
|
+
// By the packages they hold, a few hundred bytes each.
|
|
146
|
+
lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
|
|
147
|
+
risks: lru(50_000),
|
|
148
|
+
advisories: lru(50_000, kept => kept.rows.length + 1),
|
|
149
|
+
})
|
|
150
|
+
export const RISK_KEPT_MS = 60 * 60_000
|
|
151
|
+
const shared = riskCaches()
|
|
152
|
+
|
|
153
|
+
// Items in the order they are added, each taken once by whichever reader asks
|
|
154
|
+
// first: none once closed and all taken, or once `signal` aborts.
|
|
155
|
+
interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
|
|
156
|
+
function queue<T>(signal: AbortSignal): Queue<T> {
|
|
157
|
+
const items: T[] = []
|
|
158
|
+
const waiting: (() => void)[] = []
|
|
159
|
+
let closed = false
|
|
160
|
+
const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
|
|
161
|
+
signal.addEventListener('abort', wake, { once: true })
|
|
162
|
+
return {
|
|
163
|
+
add(item) { items.push(item); wake() },
|
|
164
|
+
close() { closed = true; wake() },
|
|
165
|
+
async take() {
|
|
166
|
+
for (;;) {
|
|
167
|
+
if (signal.aborted) return undefined
|
|
168
|
+
const item = items.shift()
|
|
169
|
+
if (item !== undefined || closed) return item
|
|
170
|
+
await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A package to assess: npm's of its name and version, or, with an
|
|
177
|
+
// `integrity`, a mirror's held to it.
|
|
178
|
+
interface AssessedPackage { name: string; version: string; integrity?: string }
|
|
179
|
+
|
|
180
|
+
interface Scan {
|
|
181
|
+
source: RepositoryRiskSource<RepositoryRiskRepo>
|
|
182
|
+
caches: RiskCaches
|
|
183
|
+
signal: AbortSignal
|
|
184
|
+
lines: Queue<RepositoryRiskLine>
|
|
185
|
+
// Packages to assess, and those already asked for, by name, version and a
|
|
186
|
+
// mirror's integrity.
|
|
187
|
+
pending: Queue<AssessedPackage>
|
|
188
|
+
seen: Set<string>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A package's risk line, its version's document kept and the assessment asked
|
|
192
|
+
// again after a wait while the server is busy; null once the scan stops. A
|
|
193
|
+
// version npm has no document of is told as risk tells one npm removed
|
|
194
|
+
// (removedOf), its malware keeping whether its package was taken down.
|
|
195
|
+
async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
|
|
196
|
+
const at = { type: 'risk', ...pkg } as const
|
|
197
|
+
let doc: NpmVersionDocument | NpmPackageError | undefined
|
|
198
|
+
for (let attempt = 0; ; attempt++) {
|
|
199
|
+
try {
|
|
200
|
+
const read = doc ??= await documentOf(source, pkg, signal)
|
|
201
|
+
if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
|
|
202
|
+
const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
|
|
203
|
+
const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
|
|
204
|
+
return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (signal.aborted) return null
|
|
207
|
+
if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
|
|
208
|
+
try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// npm's document of a version, or its answer that it has none (a 404).
|
|
214
|
+
async function documentOf(source: Scan['source'], pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
|
|
215
|
+
try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
|
|
216
|
+
if (error instanceof NpmPackageError && error.status === 404) return error
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A version's assessment; where risk finds npm no longer has it, as a document
|
|
222
|
+
// read from where versions' are kept doesn't tell, as one npm removed.
|
|
223
|
+
async function assessedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
|
|
224
|
+
try { return await source.assess(doc, teamScoped, signal) } catch (error) {
|
|
225
|
+
if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
|
|
226
|
+
return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A version npm has no document of, as risk tells one npm removed, as it
|
|
231
|
+
// removes every one it finds malware in (`removed`): `gone`, npm's answer of
|
|
232
|
+
// none, thrown where nothing tells of it.
|
|
233
|
+
async function removedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
|
|
234
|
+
try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
|
|
235
|
+
throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A package's risk line as kept (RiskCaches, else the source's `risks`), else
|
|
240
|
+
// assessed and kept, in both, where it tells what risk found, or that a
|
|
241
|
+
// mirror's copy isn't npm's.
|
|
242
|
+
async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | null> {
|
|
243
|
+
let teamScoped
|
|
244
|
+
try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
|
|
245
|
+
const key = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
|
|
246
|
+
const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
|
|
247
|
+
// One kept before risk told a score (assessNpm `score`) is asked again.
|
|
248
|
+
if (kept && Date.now() - kept.at < RISK_KEPT_MS && ('error' in kept.line || typeof kept.line.score === 'number')) return kept.line
|
|
249
|
+
const line = await assess(scan, pkg, teamScoped)
|
|
250
|
+
if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
|
|
251
|
+
const at = Date.now()
|
|
252
|
+
scan.caches.risks.set(key, { at, line })
|
|
253
|
+
await scan.source.risks?.write(key, line, at)
|
|
254
|
+
}
|
|
255
|
+
return line
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function keptLine({ source, caches }: Scan, key: string) {
|
|
259
|
+
const hit = await source.risks?.read(key)
|
|
260
|
+
if (!hit) return null
|
|
261
|
+
caches.risks.set(key, { at: hit.at, line: hit.value })
|
|
262
|
+
return { at: hit.at, line: hit.value }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function assessor(scan: Scan) {
|
|
266
|
+
for (let next = await scan.pending.take(); next !== undefined; next = await scan.pending.take()) {
|
|
267
|
+
const line = await assessed(scan, next)
|
|
268
|
+
if (line) scan.lines.add(line)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
|
|
273
|
+
|
|
274
|
+
// A lockfile's line, its packages or why they couldn't be had: as kept by its
|
|
275
|
+
// blob, else read from GitHub and kept.
|
|
276
|
+
async function readLockfile({ caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
|
|
277
|
+
const at = { type: 'lockfile', repo, path, format } as const
|
|
278
|
+
if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
|
|
279
|
+
const key = `${format}\0${sha}`
|
|
280
|
+
let read = caches.lockfiles.get(key)
|
|
281
|
+
if (!read) {
|
|
282
|
+
let text
|
|
283
|
+
try { text = await opened.file(path) } catch (error) { return { ...at, ...failureOf(error) } }
|
|
284
|
+
try { read = { packages: lockedPackages(format, text) } } catch (error) {
|
|
285
|
+
read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
|
|
286
|
+
}
|
|
287
|
+
caches.lockfiles.set(key, read)
|
|
288
|
+
}
|
|
289
|
+
return { ...at, ...read }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A lockfile's packages of npm's registry or a mirror not yet seen this scan,
|
|
293
|
+
// given to the assessors.
|
|
294
|
+
function assessLater(scan: Scan, packages: LockedPackage[]) {
|
|
295
|
+
for (const { name, version, source, integrity } of packages) {
|
|
296
|
+
const key = `${name}@${version}\0${integrity ?? ''}`
|
|
297
|
+
if ((source !== 'registry' && source !== 'mirror') || version === null || scan.seen.has(key) || !isNpmPackageName(name) || !isNpmPackageSpec(version)) continue
|
|
298
|
+
scan.seen.add(key)
|
|
299
|
+
scan.pending.add({ name, version, ...integrity === undefined ? {} : { integrity } })
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A repository opened and its lockfiles of npm's ecosystem listed, as kept
|
|
304
|
+
// for its commit or else from GitHub, those of its `paths` alone; null, with
|
|
305
|
+
// its line telling why, where either failed, or where the scan stopped.
|
|
306
|
+
async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
|
|
307
|
+
let doing = 'opening it'
|
|
308
|
+
try {
|
|
309
|
+
const opened = await scan.source.open(repo)
|
|
310
|
+
if (scan.signal.aborted) return null
|
|
311
|
+
const key = `${repo.repoId}@${opened.commit}`
|
|
312
|
+
let listed = scan.caches.listings.get(key)
|
|
313
|
+
doing = 'listing its lockfiles'
|
|
314
|
+
if (!listed) scan.caches.listings.set(key, listed = await opened.lockfiles())
|
|
315
|
+
const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
|
|
316
|
+
return { opened, lockfiles }
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, doing) })
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
|
|
324
|
+
|
|
325
|
+
// A lockfile's advisories line: npm's registry's advisories of its packages
|
|
326
|
+
// of the registry or a mirror, by its blob as kept, else asked at once and
|
|
327
|
+
// kept; null where it locks none, or once the scan stops.
|
|
328
|
+
async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
|
|
329
|
+
const at = { type: 'advisories', repo, path } as const
|
|
330
|
+
const key = `${format}\0${sha}`
|
|
331
|
+
const kept = scan.caches.advisories.get(key)
|
|
332
|
+
if (kept && Date.now() - kept.at < RISK_KEPT_MS) return { ...at, advisories: kept.rows }
|
|
333
|
+
const asked = new Map<string, Set<string>>()
|
|
334
|
+
for (const { name, version, source } of packages) {
|
|
335
|
+
if ((source !== 'registry' && source !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
|
|
336
|
+
if (!asked.has(name)) asked.set(name, new Set())
|
|
337
|
+
asked.get(name)!.add(version)
|
|
338
|
+
}
|
|
339
|
+
if (asked.size === 0) return null
|
|
340
|
+
try {
|
|
341
|
+
const rows = (await scan.source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), scan.signal))
|
|
342
|
+
.map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
|
|
343
|
+
scan.caches.advisories.set(key, { at: Date.now(), rows })
|
|
344
|
+
return { ...at, advisories: rows }
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return scan.signal.aborted ? null : { ...at, ...failureOf(error) }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// A repository's line, its lockfiles or why they couldn't be listed, and
|
|
351
|
+
// then its lockfiles', and their advisories: told once they are all read
|
|
352
|
+
// and access to the repository is checked again, as GitHub may have
|
|
353
|
+
// withdrawn it meanwhile.
|
|
354
|
+
async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
|
|
355
|
+
const listed = await listLockfiles(scan, repo)
|
|
356
|
+
if (!listed) return
|
|
357
|
+
const lockfiles: LockfileLine[] = []
|
|
358
|
+
for (const lockfile of listed.lockfiles) {
|
|
359
|
+
if (scan.signal.aborted) return
|
|
360
|
+
lockfiles.push(await readLockfile(scan, repo.repoId, listed.opened, lockfile))
|
|
361
|
+
}
|
|
362
|
+
try { await listed.opened.recheck() } catch (error) {
|
|
363
|
+
if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (scan.signal.aborted) return
|
|
367
|
+
scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: listed.lockfiles.map(({ path, format }) => ({ path, format })) })
|
|
368
|
+
for (const line of lockfiles) {
|
|
369
|
+
scan.lines.add(line)
|
|
370
|
+
if ('packages' in line) assessLater(scan, line.packages)
|
|
371
|
+
}
|
|
372
|
+
for (const [at, line] of lockfiles.entries()) {
|
|
373
|
+
if (scan.signal.aborted) return
|
|
374
|
+
const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
|
|
375
|
+
if (advisories) scan.lines.add(advisories)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Each repository, a repository at a time.
|
|
380
|
+
async function readRepositories(scan: Scan) {
|
|
381
|
+
const repos = await scan.source.repos()
|
|
382
|
+
scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
|
|
383
|
+
for (const repo of repos) {
|
|
384
|
+
if (scan.signal.aborted) return
|
|
385
|
+
await readRepository(scan, repo)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The scan's lines, in the order they are found: the repositories first,
|
|
390
|
+
// then each repository's lockfiles and their packages once read and access
|
|
391
|
+
// is checked again, and each package's risk once assessed, while the
|
|
392
|
+
// repositories are still being read; `done` last. Ends at once, the work
|
|
393
|
+
// under way given up on, where `signal` aborts or the lines are no longer read. A failure of
|
|
394
|
+
// a repository, a lockfile or a package is told in its line; one of the scan
|
|
395
|
+
// itself is thrown, the lines before it told. What it reads is kept in
|
|
396
|
+
// `caches` (RiskCaches), the instance's own unless given.
|
|
397
|
+
export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared): AsyncGenerator<RepositoryRiskLine> {
|
|
398
|
+
const stopped = new AbortController()
|
|
399
|
+
const running = AbortSignal.any([signal, stopped.signal])
|
|
400
|
+
const scan: Scan = { source, caches, signal: running, lines: queue(signal), pending: queue(running), seen: new Set() }
|
|
401
|
+
const failed: unknown[] = []
|
|
402
|
+
void (async () => {
|
|
403
|
+
const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan))
|
|
404
|
+
try {
|
|
405
|
+
try { await readRepositories(scan) } finally { scan.pending.close() }
|
|
406
|
+
await Promise.all(assessors)
|
|
407
|
+
if (!running.aborted) scan.lines.add({ type: 'done' })
|
|
408
|
+
} catch (error) {
|
|
409
|
+
failed.push(error)
|
|
410
|
+
stopped.abort()
|
|
411
|
+
} finally { scan.lines.close() }
|
|
412
|
+
})()
|
|
413
|
+
try {
|
|
414
|
+
for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) yield line
|
|
415
|
+
if (failed.length > 0 && !signal.aborted) throw failed[0]
|
|
416
|
+
} finally {
|
|
417
|
+
// A GitHub request under way isn't waited on: it settles on its own.
|
|
418
|
+
stopped.abort()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Internal invalidation counters. Never expose these global values to clients:
|
|
2
|
+
// feeds still publish only the existing user/visibility-scoped revisions.
|
|
3
|
+
const catalogTables = ['managed_link_report', 'managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
+
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment', 'managed_finding_issue']
|
|
5
|
+
export function revisionSchema(postgres = false): string {
|
|
6
|
+
let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
|
|
7
|
+
id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
8
|
+
); CREATE TABLE IF NOT EXISTS managed_change_revision (
|
|
9
|
+
id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
|
|
10
|
+
); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
|
|
11
|
+
for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
|
|
12
|
+
const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
|
|
13
|
+
if (postgres) {
|
|
14
|
+
sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
15
|
+
BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
|
|
16
|
+
}
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
if (postgres) {
|
|
19
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
|
|
20
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
21
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
|
|
22
|
+
CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
|
|
23
|
+
REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
|
|
24
|
+
FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
28
|
+
sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
|
|
29
|
+
AFTER ${event} ON ${table} BEGIN ${update} END;`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return sql
|
|
35
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Session + cookie plumbing for the managed auth server. The raw session
|
|
2
|
+
// token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
|
|
3
|
+
// so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
|
|
4
|
+
// minted per session and returned to the client (echoed back on mutations).
|
|
5
|
+
//
|
|
6
|
+
// Async throughout — the store (db.ts) is async so a future non-SQLite
|
|
7
|
+
// backend slots in without touching these helpers.
|
|
8
|
+
import type { ManagedConfig } from './config.ts'
|
|
9
|
+
import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
|
|
10
|
+
import { hashToken, randomToken } from './crypto.ts'
|
|
11
|
+
|
|
12
|
+
// Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
|
|
13
|
+
// `cookieName` when cookies are Secure.
|
|
14
|
+
export const STATE_COOKIE = 'dvstate'
|
|
15
|
+
|
|
16
|
+
// Base names for GitHub issue authorization state (github-issue-oauth.ts) and
|
|
17
|
+
// an administrator's read-only view as another user (view-as.ts).
|
|
18
|
+
export const ISSUE_STATE_COOKIE = 'dvissuestate'
|
|
19
|
+
export const VIEW_COOKIE = 'dvview'
|
|
20
|
+
// The session cookie cannot share a name with these, or one would overwrite
|
|
21
|
+
// or be read as the other.
|
|
22
|
+
export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, VIEW_COOKIE]
|
|
23
|
+
|
|
24
|
+
// `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
|
|
25
|
+
// Secure + Path=/ + no Domain, which `buildCookie` already emits).
|
|
26
|
+
export function cookieName(config: ManagedConfig, base: string): string {
|
|
27
|
+
return config.cookieSecure ? `__Host-${base}` : base
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildCookie(
|
|
31
|
+
name: string, value: string,
|
|
32
|
+
opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
|
|
33
|
+
): string {
|
|
34
|
+
const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
|
|
35
|
+
if (opts.secure) parts.push('Secure')
|
|
36
|
+
return parts.join('; ')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Expire a cookie now (Max-Age=0).
|
|
40
|
+
export function clearCookie(name: string, secure: boolean): string {
|
|
41
|
+
return buildCookie(name, '', { maxAgeS: 0, secure })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseCookies(header: string | undefined): Map<string, string> {
|
|
45
|
+
const out = new Map<string, string>()
|
|
46
|
+
if (!header) return out
|
|
47
|
+
for (const part of header.split(';')) {
|
|
48
|
+
const eq = part.indexOf('=')
|
|
49
|
+
if (eq < 0) continue
|
|
50
|
+
const k = part.slice(0, eq).trim()
|
|
51
|
+
if (k) out.set(k, part.slice(eq + 1).trim())
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Mint a session for a freshly-authenticated user. Upserts the identity, then
|
|
57
|
+
// stores the session keyed by the cookie token's hash. Returns the cookie to
|
|
58
|
+
// set and the CSRF token (also persisted on the row).
|
|
59
|
+
export async function createSession(
|
|
60
|
+
config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
|
|
61
|
+
): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
|
|
62
|
+
const token = randomToken()
|
|
63
|
+
const csrfToken = randomToken()
|
|
64
|
+
const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
|
|
65
|
+
await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
|
|
66
|
+
const setCookie = buildCookie(config.sessionCookieName, token, {
|
|
67
|
+
maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
|
|
68
|
+
})
|
|
69
|
+
return { setCookie, csrfToken, userId }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve the current session (+ user) from the request's cookies, or null.
|
|
73
|
+
// A view cookie resolves only to that admin's view as another user: a view
|
|
74
|
+
// that has ended never falls back to the admin's own session. `own` skips the
|
|
75
|
+
// view and reads the signed-in session itself.
|
|
76
|
+
export function readSession(
|
|
77
|
+
config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
|
|
78
|
+
): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
79
|
+
const cookies = parseCookies(cookieHeader)
|
|
80
|
+
const token = cookies.get(config.sessionCookieName)
|
|
81
|
+
if (token == null || token === '') return Promise.resolve(null)
|
|
82
|
+
const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
|
|
83
|
+
if (view != null && view !== '') return db.viewSessionWithUser(hashToken(view), hashToken(token), now)
|
|
84
|
+
return db.sessionWithUser(hashToken(token), now)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Drop the session named by the request's cookie (logout). The caller also
|
|
88
|
+
// clears the cookie client-side.
|
|
89
|
+
export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
|
|
90
|
+
const token = parseCookies(cookieHeader).get(config.sessionCookieName)
|
|
91
|
+
if (token != null && token !== '') await db.deleteSession(hashToken(token))
|
|
92
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
2
|
+
|
|
3
|
+
// Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
|
|
4
|
+
// component is proposed as a slug, never an arbitrary prefix or abbreviation.
|
|
5
|
+
export function preferredSlug(id: string): string {
|
|
6
|
+
return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
|
|
10
|
+
// Reserve legacy IDs before UUID suffixes can claim them.
|
|
11
|
+
const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
|
|
12
|
+
const allocated = []
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
if (row.slug != null) continue
|
|
15
|
+
const candidate = preferredSlug(row.id)
|
|
16
|
+
const slug = used.has(candidate) ? row.id : candidate
|
|
17
|
+
allocated.push({ id: row.id, slug })
|
|
18
|
+
used.add(slug)
|
|
19
|
+
}
|
|
20
|
+
return allocated
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Allocate once, across the entire namespace rather than a viewer's teams.
|
|
24
|
+
// Keep existing slugs stable when names, memberships, or collisions change.
|
|
25
|
+
export function migrateSlugs(db: DatabaseSync): void {
|
|
26
|
+
db.exec('BEGIN IMMEDIATE')
|
|
27
|
+
try {
|
|
28
|
+
for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
|
|
29
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
30
|
+
if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
|
|
31
|
+
const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
|
|
32
|
+
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
|
|
33
|
+
for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
|
|
34
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
|
|
35
|
+
}
|
|
36
|
+
db.exec('COMMIT')
|
|
37
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { normalizeTeamPath } from './repo-path.ts'
|
|
2
|
+
|
|
3
|
+
const MAX_SUGGESTIONS = 100
|
|
4
|
+
const EXCLUDED_DIRECTORIES = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'node_modules', 'vendor', 'out', 'artifacts', 'cache', 'build', 'dist', 'broadcast'])
|
|
5
|
+
|
|
6
|
+
function sourcePath(value: unknown): value is string {
|
|
7
|
+
if (typeof value !== 'string' || !value.endsWith('.sol') || /\.(?:t|s|test|spec)\.sol$/iu.test(value)) return false
|
|
8
|
+
const normalized = normalizeTeamPath(value)
|
|
9
|
+
const directories = value.split('/').slice(0, -1).map(part => part.toLowerCase())
|
|
10
|
+
const sourceRoot = directories.findIndex(part => part === 'src' || part === 'contracts')
|
|
11
|
+
return normalized.ok && normalized.path === value && !directories.some((part, index) =>
|
|
12
|
+
EXCLUDED_DIRECTORIES.has(part) || (part === 'lib' && (sourceRoot < 0 || index < sourceRoot)))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ListingEntry = { name?: unknown; path?: unknown; type?: unknown; sha?: unknown; submodule_git_url?: unknown }
|
|
16
|
+
|
|
17
|
+
// Maintain the alphabetically first 100 unique candidates even when GitHub's
|
|
18
|
+
// input is unordered. Return whether a unique candidate exceeds the capacity.
|
|
19
|
+
export function addSoliditySuggestion(paths: string[], path: string): boolean {
|
|
20
|
+
let start = 0
|
|
21
|
+
let end = paths.length
|
|
22
|
+
while (start < end) {
|
|
23
|
+
const middle = Math.floor((start + end) / 2)
|
|
24
|
+
if (paths[middle]! < path) start = middle + 1
|
|
25
|
+
else end = middle
|
|
26
|
+
}
|
|
27
|
+
if (paths[start] === path) return false
|
|
28
|
+
const full = paths.length === MAX_SUGGESTIONS
|
|
29
|
+
if (start < MAX_SUGGESTIONS) {
|
|
30
|
+
// Evict before inserting, so retained candidates never exceed the cap.
|
|
31
|
+
if (full) paths.pop()
|
|
32
|
+
paths.splice(start, 0, path)
|
|
33
|
+
}
|
|
34
|
+
return full
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Suggestions from filenames, not deployability or import analysis. Read at
|
|
38
|
+
// most two source trees, using SHAs from the authorized pinned directory.
|
|
39
|
+
export async function readSolidityEntryPoints(directory: string, listing: unknown[], read: (suffix: string) => Promise<unknown>) {
|
|
40
|
+
const prefix = directory ? directory + '/' : ''
|
|
41
|
+
const entries = listing.filter((entry): entry is ListingEntry => !!entry && typeof entry === 'object').filter(entry =>
|
|
42
|
+
typeof entry.name === 'string' && !entry.name.includes('/') && entry.path === prefix + entry.name && !entry.submodule_git_url)
|
|
43
|
+
const hasFramework = entries.some(entry => entry.type === 'file' && typeof entry.name === 'string'
|
|
44
|
+
&& (entry.name === 'foundry.toml' || /^hardhat\.config\.(?:[cm]?js|ts)$/u.test(entry.name)))
|
|
45
|
+
const roots = entries.filter(entry => entry.type === 'dir' && (entry.name === 'contracts' || (hasFramework && entry.name === 'src'))
|
|
46
|
+
&& typeof entry.sha === 'string' && /^[a-f\d]{40}$/iu.test(entry.sha)).slice(0, 2)
|
|
47
|
+
const paths: string[] = []
|
|
48
|
+
let limited = false
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
|
|
51
|
+
}
|
|
52
|
+
for (const root of roots) {
|
|
53
|
+
if (!sourcePath(`${root.path}/Contract.sol`)) continue
|
|
54
|
+
try {
|
|
55
|
+
const data = await read(`/git/trees/${root.sha}?recursive=1`) as { tree?: unknown; truncated?: unknown }
|
|
56
|
+
if (!Array.isArray(data?.tree)) continue
|
|
57
|
+
limited ||= data.truncated === true
|
|
58
|
+
for (const entry of data.tree) {
|
|
59
|
+
if (entry?.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || typeof entry.path !== 'string') continue
|
|
60
|
+
const path = `${root.path}/${entry.path}`
|
|
61
|
+
if (sourcePath(path) && addSoliditySuggestion(paths, path)) limited = true
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Optional suggestions must not block browsing. The request handler
|
|
65
|
+
// rechecks GitHub access and local path grants after all reads.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { paths, limited }
|
|
69
|
+
}
|