@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +44 -13
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +3 -3
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { ManagedDb } from './db-methods.ts'
|
|
3
|
+
import type { ManagedComment } from '../common/managed/comments.ts'
|
|
4
|
+
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
5
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
6
|
+
|
|
7
|
+
export interface ImportTriageSnapshot {
|
|
8
|
+
entry: TriageEntryPatch | null
|
|
9
|
+
comments: ManagedComment[]
|
|
10
|
+
version: string
|
|
11
|
+
}
|
|
12
|
+
export interface ImportTriageStore {
|
|
13
|
+
getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
|
|
14
|
+
importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Raw store methods share one transaction for comparison, triage, and comments.
|
|
18
|
+
export function importTriageMethods(store: Pick<ManagedDb, 'getReport' | 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
|
|
19
|
+
async function snapshot(ids: readonly string[]) {
|
|
20
|
+
const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
|
|
21
|
+
const comments = await store.listComments(ids)
|
|
22
|
+
return Object.fromEntries(ids.map(id => {
|
|
23
|
+
const row = rows.get(id)
|
|
24
|
+
const entry = row ? triageWireEntry(row) : null
|
|
25
|
+
const own = comments.filter(comment => comment.findingId === id)
|
|
26
|
+
const version = createHash('sha256').update(JSON.stringify([entry, row?.updatedAt, own])).digest('hex')
|
|
27
|
+
return [id, { entry, comments: own, version }]
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
getImportTriage: snapshot,
|
|
32
|
+
async importTriage(entries, expected, actor, reportId, now) {
|
|
33
|
+
// A report deleted after the HTTP membership check must not leave newly
|
|
34
|
+
// imported annotations behind. Check existence in the write transaction.
|
|
35
|
+
if (!reportId || !(await store.getReport(reportId))) return false
|
|
36
|
+
const current = await snapshot(entries.map(([id]) => id))
|
|
37
|
+
if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
|
|
38
|
+
const changes: [string, TriageEntryPatch | null][] = []
|
|
39
|
+
for (const [id, value] of entries) {
|
|
40
|
+
const { comment, ...entry } = value ?? {}
|
|
41
|
+
changes.push([id, Object.keys(entry).length > 0 ? entry : null])
|
|
42
|
+
if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
|
|
43
|
+
await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
|
|
44
|
+
actor, createdAt: null, updatedAt: null, reportId }, now)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
|
|
48
|
+
return true
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Managed HTTP app and standalone boot. The combined launcher mounts this
|
|
2
|
+
// same app on e2e's listener; storage, routing and cleanup stay here.
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { type Server, createServer } from 'node:http'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { createOriginGate } from '../server-common/origin.ts'
|
|
7
|
+
import { managedStorageLines } from '../server-common/storage-log.ts'
|
|
8
|
+
import { runReapers, withReap } from '../server-common/reap.ts'
|
|
9
|
+
import { startServer } from '../server-common/standalone.ts'
|
|
10
|
+
import { initializeApp } from '../server-common/initialize.ts'
|
|
11
|
+
import { type ManagedConfig, loadManagedConfig } from './config.ts'
|
|
12
|
+
import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
13
|
+
import { setNpmTarballCache } from './npm-loads.ts'
|
|
14
|
+
import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
|
|
15
|
+
import { setNpmDocumentCache } from './npm-packages.ts'
|
|
16
|
+
import { loadManagedStatic } from './static.ts'
|
|
17
|
+
import { openManagedStorage } from './storage.ts'
|
|
18
|
+
import { setCacheDir } from '@preventive/upstream/npm.js'
|
|
19
|
+
|
|
20
|
+
// Housekeeping runs on ordinary traffic too, including serverless instances
|
|
21
|
+
// without a timer or cron. Failed sweeps retry on later traffic after a minute.
|
|
22
|
+
const REAP_INTERVAL_MS = 3_600_000
|
|
23
|
+
const REAP_RETRY_MS = 60_000
|
|
24
|
+
|
|
25
|
+
// `ui: false` serves only the API: `out/` is never read.
|
|
26
|
+
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
|
|
27
|
+
|
|
28
|
+
export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
|
|
29
|
+
// Audits keep upstream's records on disk where the config has a cache
|
|
30
|
+
// directory; bundle builds set it in their own worker. The npm viewer
|
|
31
|
+
// keeps the tarballs it downloads there too, beside theirs, and public
|
|
32
|
+
// packages' whole documents (npm-packages.ts).
|
|
33
|
+
setCacheDir(config.upstreamCacheDir ?? false)
|
|
34
|
+
setNpmTarballCache(config.upstreamCacheDir ?? null)
|
|
35
|
+
setNpmDocumentCache(config.upstreamCacheDir ?? null)
|
|
36
|
+
// Load the optional peer only for serverless managed deployments. Embedding
|
|
37
|
+
// hosts can discard listener promises, so retain maintenance inside the app.
|
|
38
|
+
const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
|
|
39
|
+
// The host's reviewed dependencies, registered before; none after.
|
|
40
|
+
const reviewedDependencies = assembledReviewedDependencies()
|
|
41
|
+
const assembled = { ...options, ...reviewedDependencies ? { reviewedDependencies } : {} }
|
|
42
|
+
return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
46
|
+
const storage = await openManagedStorage(config)
|
|
47
|
+
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage } = storage
|
|
48
|
+
rollback.defer(() => db.close())
|
|
49
|
+
const originGate = createOriginGate(config.host, config.trustProxyEnv)
|
|
50
|
+
|
|
51
|
+
let shuttingDown = false
|
|
52
|
+
const isShuttingDown = () => shuttingDown || options.isShuttingDown?.() === true
|
|
53
|
+
const inFlight = new Set<Promise<unknown>>()
|
|
54
|
+
function track(p: Promise<unknown>): void {
|
|
55
|
+
inFlight.add(p)
|
|
56
|
+
p.finally(() => inFlight.delete(p)).catch(() => {})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const serveStatic = ui ? loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
|
|
60
|
+
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
61
|
+
}) : () => false
|
|
62
|
+
const routeRequest = createManagedRequestHandler({
|
|
63
|
+
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, repositoryCodeStorage, originGate, serveStatic,
|
|
64
|
+
...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
|
|
65
|
+
isShuttingDown, track,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
let cleanup: Promise<void> | undefined, nextCleanupAt = 0
|
|
69
|
+
const maintenance = new AbortController()
|
|
70
|
+
function reap(automatic = false): Promise<void> {
|
|
71
|
+
if (cleanup) return cleanup
|
|
72
|
+
if (isShuttingDown()) return Promise.resolve()
|
|
73
|
+
const startedAt = Date.now()
|
|
74
|
+
nextCleanupAt = startedAt + REAP_INTERVAL_MS
|
|
75
|
+
let sessions = 0, uploads = 0
|
|
76
|
+
cleanup = (async () => {
|
|
77
|
+
const owner = automatic && config.serverless ? randomUUID() : null
|
|
78
|
+
if (owner && !await db.claimMaintenanceLease(owner, startedAt, startedAt + Math.max(300_000, (config.storageEncryptionMigrateMaxMs ?? 0) + 60_000), config.storageEncryptionMigrate)) return false
|
|
79
|
+
let succeeded = false
|
|
80
|
+
try {
|
|
81
|
+
await runReapers({
|
|
82
|
+
sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
|
|
83
|
+
...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
|
|
84
|
+
...('reapPrettyCopies' in storage ? { prettyCopies: () => storage.reapPrettyCopies(maintenance.signal) } : {}),
|
|
85
|
+
...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
|
|
86
|
+
})
|
|
87
|
+
succeeded = true
|
|
88
|
+
return true
|
|
89
|
+
} finally {
|
|
90
|
+
if (owner) await db.finishMaintenanceLease(owner, Date.now() + (succeeded ? REAP_INTERVAL_MS : REAP_RETRY_MS))
|
|
91
|
+
}
|
|
92
|
+
})().then(ran => {
|
|
93
|
+
nextCleanupAt = Date.now() + REAP_INTERVAL_MS
|
|
94
|
+
if (!ran) return
|
|
95
|
+
return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
|
|
96
|
+
}, err => {
|
|
97
|
+
nextCleanupAt = Date.now() + REAP_RETRY_MS
|
|
98
|
+
throw err
|
|
99
|
+
}).finally(() => { cleanup = undefined })
|
|
100
|
+
track(cleanup)
|
|
101
|
+
return cleanup
|
|
102
|
+
}
|
|
103
|
+
function automaticReap(): Promise<void> {
|
|
104
|
+
if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
|
|
105
|
+
return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
106
|
+
}
|
|
107
|
+
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
108
|
+
// Register before routing can end the response, including when init()'s
|
|
109
|
+
// listener is mounted in Fastify rather than using api/managed.ts.
|
|
110
|
+
const pendingMaintenance = automaticReap(); waitUntil?.(pendingMaintenance)
|
|
111
|
+
try { await routeRequest(req, res) }
|
|
112
|
+
finally { await pendingMaintenance }
|
|
113
|
+
}
|
|
114
|
+
const gcTimer = config.serverless ? null : setInterval(() => {
|
|
115
|
+
void automaticReap()
|
|
116
|
+
}, REAP_INTERVAL_MS)
|
|
117
|
+
rollback.defer(stop)
|
|
118
|
+
|
|
119
|
+
function stop(): void {
|
|
120
|
+
shuttingDown = true
|
|
121
|
+
maintenance.abort()
|
|
122
|
+
if (gcTimer) clearInterval(gcTimer)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function close(): Promise<void> {
|
|
126
|
+
stop()
|
|
127
|
+
if (inFlight.size > 0) await Promise.allSettled([...inFlight])
|
|
128
|
+
await db.close()
|
|
129
|
+
}
|
|
130
|
+
return { handleRequest, reap, isShuttingDown, stop, close }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function logManagedStartup(config: ManagedConfig, port: number, mode = 'managed'): void {
|
|
134
|
+
console.log([
|
|
135
|
+
`DeepView managed server (${mode}):`,
|
|
136
|
+
` HTTP: http://${config.host}:${port}/`,
|
|
137
|
+
...managedStorageLines(config),
|
|
138
|
+
].join('\n'))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function init(config = loadManagedConfig(), options: Pick<ManagedAppOptions, 'ui'> = {}): Promise<Server> {
|
|
142
|
+
const app = await createManagedApp(config, options)
|
|
143
|
+
const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
|
|
144
|
+
let disposal: Promise<void> | undefined
|
|
145
|
+
function dispose(): Promise<void> {
|
|
146
|
+
if (!disposal) {
|
|
147
|
+
app.stop()
|
|
148
|
+
disposal = (async () => {
|
|
149
|
+
try {
|
|
150
|
+
if (server.listening) {
|
|
151
|
+
await new Promise<void>((resolve, reject) => {
|
|
152
|
+
server.close(err => { if (err) reject(err); else resolve() })
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
try { await app.close() }
|
|
157
|
+
finally {
|
|
158
|
+
server.off('error', onError)
|
|
159
|
+
server.off('close', onClose)
|
|
160
|
+
server.off('listening', onListening)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
})()
|
|
164
|
+
}
|
|
165
|
+
return disposal
|
|
166
|
+
}
|
|
167
|
+
// Embedding hosts can await full cleanup even when they only copied the
|
|
168
|
+
// request listener and never bound this server. Repeated disposal is safe.
|
|
169
|
+
server[Symbol.asyncDispose] = dispose
|
|
170
|
+
|
|
171
|
+
function onError(): void { onClose() }
|
|
172
|
+
function onClose(): void {
|
|
173
|
+
if (!disposal) void dispose().catch(err => { console.error('Managed server cleanup failed:', err) })
|
|
174
|
+
}
|
|
175
|
+
function onListening(): void {
|
|
176
|
+
const address = server.address()
|
|
177
|
+
const port = typeof address === 'object' && address ? address.port : config.port
|
|
178
|
+
logManagedStartup(config, port)
|
|
179
|
+
}
|
|
180
|
+
server.on('error', onError)
|
|
181
|
+
server.once('close', onClose)
|
|
182
|
+
server.on('listening', onListening)
|
|
183
|
+
return server
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function start(): Promise<void> {
|
|
187
|
+
const config = loadManagedConfig()
|
|
188
|
+
const server = await init(config)
|
|
189
|
+
startServer(server, config)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (import.meta.main) await start()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What was lately worked out, kept a little while in memory for whoever asks
|
|
2
|
+
// for it next, by key: each for `keptMs` from when it was kept, the least
|
|
3
|
+
// lately read going first past `maxEntries` of them or `maxBytes` between
|
|
4
|
+
// them, and one that alone takes more than `maxBytes` not kept at all.
|
|
5
|
+
// Whoever reads one is the caller's to check, as they would one worked out.
|
|
6
|
+
export function keptBriefly<T>({ keptMs, maxBytes, maxEntries = Infinity }: { keptMs: number; maxBytes: number; maxEntries?: number }) {
|
|
7
|
+
const held = new Map<string, { value: T; bytes: number; timer: ReturnType<typeof setTimeout> }>()
|
|
8
|
+
let heldBytes = 0
|
|
9
|
+
const forget = (key: string) => {
|
|
10
|
+
const entry = held.get(key)
|
|
11
|
+
if (!entry) return
|
|
12
|
+
clearTimeout(entry.timer)
|
|
13
|
+
held.delete(key)
|
|
14
|
+
heldBytes -= entry.bytes
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
get(key: string): T | undefined {
|
|
18
|
+
const entry = held.get(key)
|
|
19
|
+
if (!entry) return undefined
|
|
20
|
+
held.delete(key)
|
|
21
|
+
held.set(key, entry)
|
|
22
|
+
return entry.value
|
|
23
|
+
},
|
|
24
|
+
keep(key: string, value: T, bytes: number) {
|
|
25
|
+
if (bytes > maxBytes) return
|
|
26
|
+
forget(key)
|
|
27
|
+
for (const old of held.keys()) {
|
|
28
|
+
if (held.size < maxEntries && heldBytes + bytes <= maxBytes) break
|
|
29
|
+
forget(old)
|
|
30
|
+
}
|
|
31
|
+
const timer = setTimeout(() => { forget(key) }, keptMs)
|
|
32
|
+
timer.unref()
|
|
33
|
+
held.set(key, { value, bytes, timer })
|
|
34
|
+
heldBytes += bytes
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Answers kept for an hour, by what they answer (npm-insights.ts,
|
|
2
|
+
// npm-provenance.ts), at most `max` of them, the oldest asked going first;
|
|
3
|
+
// one still being asked for is shared, and one that failed, or that `whole`
|
|
4
|
+
// says is partial, is dropped once it is answered. `peek` gives one already
|
|
5
|
+
// answered, asking for nothing; undefined where none is.
|
|
6
|
+
const KEPT_MS = 60 * 60_000
|
|
7
|
+
|
|
8
|
+
export function kept<T>(max: number, whole: (value: T) => boolean = () => true) {
|
|
9
|
+
const answers = new Map<string, { at: number; value: Promise<T>; answered?: { value: T } }>()
|
|
10
|
+
const get = (key: string, ask: () => Promise<T>): Promise<T> => {
|
|
11
|
+
const now = Date.now()
|
|
12
|
+
const hit = answers.get(key)
|
|
13
|
+
if (hit && now - hit.at < KEPT_MS) return hit.value
|
|
14
|
+
const value = ask()
|
|
15
|
+
const entry: { at: number; value: Promise<T>; answered?: { value: T } } = { at: now, value }
|
|
16
|
+
answers.delete(key)
|
|
17
|
+
answers.set(key, entry)
|
|
18
|
+
while (answers.size > max) answers.delete(answers.keys().next().value!)
|
|
19
|
+
const drop = () => { if (answers.get(key) === entry) answers.delete(key) }
|
|
20
|
+
value.then(answer => { if (whole(answer)) entry.answered = { value: answer }; else drop(); return answer }, drop)
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
const peek = (key: string): T | undefined => {
|
|
24
|
+
const hit = answers.get(key)
|
|
25
|
+
return hit?.answered && Date.now() - hit.at < KEPT_MS ? hit.answered.value : undefined
|
|
26
|
+
}
|
|
27
|
+
return Object.assign(get, { peek })
|
|
28
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Global deduplication reports live entirely in SQL. The payload is authenticated
|
|
2
|
+
// ciphertext bound to its row, using the deployment's existing storage key.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
|
|
6
|
+
import { parseLinkedFindings } from '../client/linked-findings.js'
|
|
7
|
+
import type { ManagedSql } from './sql.ts'
|
|
8
|
+
import { ManagedMutationError } from './management.ts'
|
|
9
|
+
|
|
10
|
+
export const LINK_REPORT_SCHEMA = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS managed_link_report (
|
|
12
|
+
id TEXT PRIMARY KEY, filename TEXT NOT NULL, sha256 TEXT NOT NULL UNIQUE,
|
|
13
|
+
encrypted_groups TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
|
|
14
|
+
group_count INTEGER NOT NULL, finding_count INTEGER NOT NULL,
|
|
15
|
+
uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
|
|
16
|
+
uploaded_by_login TEXT, uploaded_at INTEGER NOT NULL
|
|
17
|
+
) STRICT;
|
|
18
|
+
`
|
|
19
|
+
export interface LinkReport {
|
|
20
|
+
id: string; filename: string; enabled: boolean; groupCount: number; findingCount: number
|
|
21
|
+
uploadedByLogin: string | null; uploadedAt: number
|
|
22
|
+
}
|
|
23
|
+
export interface LinkReportStore {
|
|
24
|
+
listLegacyLinkReports(): Promise<{ id: string; filename: string }[]>
|
|
25
|
+
migrateLinkReport(id: string, content: string): Promise<boolean>
|
|
26
|
+
listLinkReports(): Promise<LinkReport[]>
|
|
27
|
+
getLinkReport(sessionId: string, id: string): Promise<LinkReport & { groups: string[][] }>
|
|
28
|
+
getLinkRevision(): Promise<string>
|
|
29
|
+
getEnabledLinkGroups(revision: string): Promise<string[][]>
|
|
30
|
+
importLinkReport(sessionId: string, filename: string, content: string): Promise<{ report: LinkReport; reused: boolean }>
|
|
31
|
+
setLinkReportEnabled(sessionId: string, id: string, enabled: boolean): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
const columns = `id, filename, enabled, group_count AS groupCount, finding_count AS findingCount,
|
|
34
|
+
uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt`
|
|
35
|
+
const identity = (id: string) => `managed_link_report.encrypted_groups:${id}`
|
|
36
|
+
function decryptGroups(key: StorageKey | null, id: string, payload: string): string[][] {
|
|
37
|
+
if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
|
|
38
|
+
const plain = unwrapStorageValue(key, identity(id), payload)
|
|
39
|
+
try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
|
|
40
|
+
}
|
|
41
|
+
export async function linkRevision(db: ManagedSql): Promise<string> {
|
|
42
|
+
return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
|
|
43
|
+
}
|
|
44
|
+
// The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
|
|
45
|
+
// however they were read.
|
|
46
|
+
export function linkRevisionOf(rows: unknown[]): string {
|
|
47
|
+
return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
|
|
48
|
+
}
|
|
49
|
+
function parseLinkReport(content: string) {
|
|
50
|
+
const parsed = parseLinkedFindings(content)
|
|
51
|
+
if (parsed) return parsed
|
|
52
|
+
// Older managed exports may carry the normalized links envelope.
|
|
53
|
+
try {
|
|
54
|
+
const data = JSON.parse(content)
|
|
55
|
+
if (data?.source !== 'links' || !Array.isArray(data.links) || !Array.isArray(data.findings) || data.findings.length > 0) return null
|
|
56
|
+
return data.links.length === 0 ? { groups: [], skipped: 0 } : parseLinkedFindings(JSON.stringify(data.links))
|
|
57
|
+
} catch { return null }
|
|
58
|
+
}
|
|
59
|
+
export function linkReportMethods(db: ManagedSql, key: StorageKey | null): LinkReportStore {
|
|
60
|
+
const map = (row: Omit<LinkReport, 'enabled'> & { enabled: number }): LinkReport => ({ ...row, enabled: row.enabled === 1 })
|
|
61
|
+
async function authorize(sessionId: string) {
|
|
62
|
+
const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
63
|
+
WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
|
|
64
|
+
if (!user) throw new ManagedMutationError(401, 'unauthenticated')
|
|
65
|
+
if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
|
|
66
|
+
return user
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
async listLegacyLinkReports() {
|
|
70
|
+
return await db.prepare("SELECT id, filename FROM managed_report WHERE analyzer = 'links' ORDER BY id").all() as { id: string; filename: string }[]
|
|
71
|
+
},
|
|
72
|
+
async migrateLinkReport(id, content) {
|
|
73
|
+
const row = await db.prepare(`SELECT filename, sha256, visible, uploaded_by AS uploadedBy,
|
|
74
|
+
uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt FROM managed_report WHERE id = ? AND analyzer = 'links'`).get(id) as {
|
|
75
|
+
filename: string; sha256: string; visible: number; uploadedBy: string | null; uploadedByLogin: string | null; uploadedAt: number
|
|
76
|
+
} | undefined
|
|
77
|
+
if (!row) return false
|
|
78
|
+
if (!key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
79
|
+
if (createHash('sha256').update(content).digest('base64url') !== row.sha256) throw new Error('Legacy link report content changed')
|
|
80
|
+
const parsed = parseLinkReport(content)
|
|
81
|
+
if (!parsed) throw new Error('Invalid legacy link report')
|
|
82
|
+
const plain = Buffer.from(JSON.stringify(parsed.groups))
|
|
83
|
+
try {
|
|
84
|
+
const sha256 = createHash('sha256').update(plain).digest('base64url')
|
|
85
|
+
await db.prepare(`INSERT INTO managed_link_report
|
|
86
|
+
(id, filename, sha256, encrypted_groups, enabled, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
|
|
87
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sha256) DO NOTHING`).run(
|
|
88
|
+
id, row.filename, sha256, wrapStorageValue(key, identity(id), plain), row.visible,
|
|
89
|
+
parsed.groups.length, new Set(parsed.groups.flat()).size, row.uploadedBy, row.uploadedByLogin, row.uploadedAt)
|
|
90
|
+
await db.prepare('DELETE FROM managed_report WHERE id = ?').run(id)
|
|
91
|
+
} finally { plain.fill(0) }
|
|
92
|
+
return true
|
|
93
|
+
},
|
|
94
|
+
async listLinkReports() {
|
|
95
|
+
return (await db.prepare(`SELECT ${columns} FROM managed_link_report ORDER BY uploaded_at DESC, id`).all() as Parameters<typeof map>[0][]).map(map)
|
|
96
|
+
},
|
|
97
|
+
async getLinkReport(sessionId, id) {
|
|
98
|
+
await authorize(sessionId)
|
|
99
|
+
const row = await db.prepare(`SELECT ${columns}, encrypted_groups AS payload FROM managed_link_report WHERE id = ?`).get(id) as (Parameters<typeof map>[0] & { payload: string }) | undefined
|
|
100
|
+
if (!row) throw new ManagedMutationError(404, 'no-link-report')
|
|
101
|
+
const { payload, ...report } = row
|
|
102
|
+
return { ...map(report), groups: decryptGroups(key, id, payload) }
|
|
103
|
+
},
|
|
104
|
+
getLinkRevision: () => linkRevision(db),
|
|
105
|
+
async getEnabledLinkGroups(revision) {
|
|
106
|
+
if (await linkRevision(db) !== revision) throw new ManagedMutationError(409, 'deduplication-changed')
|
|
107
|
+
const rows = await db.prepare('SELECT id, encrypted_groups AS payload FROM managed_link_report WHERE enabled = 1 ORDER BY id').all() as { id: string; payload: string }[]
|
|
108
|
+
if (rows.length > 0 && !key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
109
|
+
return rows.flatMap(row => decryptGroups(key, row.id, row.payload))
|
|
110
|
+
},
|
|
111
|
+
async importLinkReport(sessionId, filename, content) {
|
|
112
|
+
const user = await authorize(sessionId)
|
|
113
|
+
if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
|
|
114
|
+
const parsed = parseLinkReport(content)
|
|
115
|
+
if (!parsed || parsed.skipped) throw new ManagedMutationError(400, 'invalid-link-report')
|
|
116
|
+
const plain = Buffer.from(JSON.stringify(parsed.groups))
|
|
117
|
+
const sha256 = createHash('sha256').update(plain).digest('base64url')
|
|
118
|
+
try {
|
|
119
|
+
const existing = await db.prepare(`SELECT ${columns} FROM managed_link_report WHERE sha256 = ?`).get(sha256) as Parameters<typeof map>[0] | undefined
|
|
120
|
+
if (existing) return { report: map(existing), reused: true }
|
|
121
|
+
const id = randomUUID(), uploadedAt = Date.now()
|
|
122
|
+
const findingCount = new Set(parsed.groups.flat()).size, groupCount = parsed.groups.length
|
|
123
|
+
await db.prepare(`INSERT INTO managed_link_report
|
|
124
|
+
(id, filename, sha256, encrypted_groups, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
|
|
125
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, filename, sha256, wrapStorageValue(key, identity(id), plain), groupCount, findingCount, user.id, user.login, uploadedAt)
|
|
126
|
+
return { report: { id, filename, enabled: true, groupCount, findingCount, uploadedByLogin: user.login, uploadedAt }, reused: false }
|
|
127
|
+
} finally { plain.fill(0) }
|
|
128
|
+
},
|
|
129
|
+
async setLinkReportEnabled(sessionId, id, enabled) {
|
|
130
|
+
await authorize(sessionId)
|
|
131
|
+
if (!(await db.prepare('UPDATE managed_link_report SET enabled = ? WHERE id = ?').run(+enabled, id)).changes) throw new ManagedMutationError(404, 'no-link-report')
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Merge complete components BEFORE projecting them onto the response. An ID
|
|
137
|
+
// absent from every returned report can still bridge two visible findings.
|
|
138
|
+
export function mergeLinkGroups(groups: readonly string[][], present?: ReadonlySet<string>): string[][] {
|
|
139
|
+
const parents = new Map<string, string>()
|
|
140
|
+
const root = (id: string): string => {
|
|
141
|
+
if (!parents.has(id)) parents.set(id, id)
|
|
142
|
+
let value = id
|
|
143
|
+
while (parents.get(value) !== value) {
|
|
144
|
+
parents.set(value, parents.get(parents.get(value)!)!)
|
|
145
|
+
value = parents.get(value)!
|
|
146
|
+
}
|
|
147
|
+
return value
|
|
148
|
+
}
|
|
149
|
+
for (const ids of groups) {
|
|
150
|
+
if (ids.length === 0) continue
|
|
151
|
+
const first = root(ids[0]!)
|
|
152
|
+
for (const id of ids.slice(1)) { const other = root(id); if (other !== first) parents.set(other, first) }
|
|
153
|
+
}
|
|
154
|
+
const merged = new Map<string, string[]>()
|
|
155
|
+
for (const id of parents.keys()) {
|
|
156
|
+
if (present && !present.has(id)) continue
|
|
157
|
+
const key = root(id)
|
|
158
|
+
const ids = merged.get(key) ?? []
|
|
159
|
+
ids.push(id); merged.set(key, ids)
|
|
160
|
+
}
|
|
161
|
+
return [...merged.values()].filter(ids => ids.length >= 2)
|
|
162
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The packages a lockfile of npm's ecosystem locks, as @preventive/lockfile
|
|
2
|
+
// reads package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
|
|
3
|
+
// pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, each with where it
|
|
4
|
+
// is installed from and whether only devDependencies need it
|
|
5
|
+
// (repository-risk.ts).
|
|
6
|
+
import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
|
|
7
|
+
import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
|
|
8
|
+
import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
|
|
9
|
+
import type { LockfileFormat } from '@preventive/upstream/github.js'
|
|
10
|
+
import type { ReviewedLockfile } from './reviewed-dependencies.ts'
|
|
11
|
+
|
|
12
|
+
export interface LockedPackage {
|
|
13
|
+
name: string
|
|
14
|
+
// None for a git dependency whose package.json has none.
|
|
15
|
+
version: string | null
|
|
16
|
+
// Where it is installed from: npm's registry, another host's tarball with
|
|
17
|
+
// an integrity, such as a company's mirror of npm's, a git repository, a
|
|
18
|
+
// tarball with none, or a file of the project.
|
|
19
|
+
source: 'registry' | 'mirror' | 'git' | 'tarball' | 'file'
|
|
20
|
+
// Where one from elsewhere than npm's registry comes from, without any
|
|
21
|
+
// credentials its URL has.
|
|
22
|
+
from?: string
|
|
23
|
+
// As the lockfile has it: a mirror's tarball's, npm's package of its name
|
|
24
|
+
// and version only where npm's integrity is among it; and, where its
|
|
25
|
+
// reviews ask (`review`), npm's registry's too.
|
|
26
|
+
integrity?: string
|
|
27
|
+
// Where its reviews ask: the sha1 Yarn 1 writes after a tarball's `#`, in
|
|
28
|
+
// hex (`shasum`); the hash of the patch pnpm applies to it
|
|
29
|
+
// (`patchHash`, its patchedDependencies'); and the lockfile's format.
|
|
30
|
+
shasum?: string
|
|
31
|
+
patchHash?: string
|
|
32
|
+
lockfile?: ReviewedLockfile
|
|
33
|
+
// Where only the importers' devDependencies reach it, as npm's `dev` flag
|
|
34
|
+
// says, and an install of no devDependencies leaves it out: in npm's and
|
|
35
|
+
// pnpm's lockfiles. Yarn 1's says nothing of it, which only the
|
|
36
|
+
// package.json files do, and none of its are.
|
|
37
|
+
dev?: true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A package as each reader resolves one, its name the one it is published
|
|
41
|
+
// under, and a registry tarball's URL held to its name and version.
|
|
42
|
+
type Resolution =
|
|
43
|
+
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; sha1?: string | undefined; gitHosted?: boolean }
|
|
44
|
+
| { type: 'git'; repo: string }
|
|
45
|
+
| { type: 'directory' }
|
|
46
|
+
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined }
|
|
47
|
+
|
|
48
|
+
// The packages of a lockfile of `format`, as its reader resolves them.
|
|
49
|
+
// Versions go unchecked against ranges, as only what is locked is asked for.
|
|
50
|
+
function packagesOf(format: LockfileFormat, text: string): ReadPackage[] {
|
|
51
|
+
if (format === 'npm') return Object.values(parseNpmLockfile(text, { checkVersions: false }).packages)
|
|
52
|
+
if (format === 'pnpm') {
|
|
53
|
+
const { lockfile, env } = parsePnpmLockfile(text)
|
|
54
|
+
return [...Object.values(env?.packages ?? {}), ...Object.values(lockfile?.packages ?? {})]
|
|
55
|
+
}
|
|
56
|
+
if (format !== 'yarn') throw new TypeError(`lockedPackages: ${format} lockfiles are not read`)
|
|
57
|
+
// Yarn 2 and later write another format, which only the text tells apart.
|
|
58
|
+
if (/^__metadata:/mu.test(text)) throw new LockfileError('a yarn.lock of Yarn 2 or later, which is not read')
|
|
59
|
+
// Yarn 1 names an alias, `my-q@npm:q@^1.5.1`, by its own name.
|
|
60
|
+
return Object.values(parseYarn1Lockfile(text, { checkVersions: false }).packages)
|
|
61
|
+
.map(pkg => ({ ...pkg, name: /@npm:((?:@[^/@]+\/)?[^/@]+)/u.exec(pkg.patterns[0] ?? '')?.[1] ?? pkg.name }))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// npm's registry, by either of its names, and yarn's mirror of it, as
|
|
65
|
+
// @preventive/lockfile holds their tarballs to a package's name and version:
|
|
66
|
+
// its link to one, as it names it, a hash after it alone (Yarn 1's sha1).
|
|
67
|
+
const REGISTRY_HOSTS = new Set(['registry.npmjs.org', 'registry.npmjs.com', 'registry.yarnpkg.com'])
|
|
68
|
+
const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password && !url.search
|
|
69
|
+
&& REGISTRY_HOSTS.has(url.hostname.replace(/\.+$/u, ''))
|
|
70
|
+
// Where a URL is, as it may be shown: its credentials, query and fragment
|
|
71
|
+
// left out.
|
|
72
|
+
const shown = (url: URL | null) => url ? { from: `${url.protocol}//${url.host}${url.pathname}` } : {}
|
|
73
|
+
|
|
74
|
+
// Where a package read comes from (LockedPackage), with npm's registry's
|
|
75
|
+
// integrity where `review`; null for one a package bundles, with no
|
|
76
|
+
// resolution, or one of the project's own directories.
|
|
77
|
+
function located({ name, version, resolution }: ReadPackage, review: boolean): LockedPackage | null {
|
|
78
|
+
const at = { name, version: version ?? null }
|
|
79
|
+
if (resolution === undefined || resolution.type === 'directory') return null
|
|
80
|
+
if (resolution.type === 'git') return { ...at, source: 'git', ...shown(URL.parse(resolution.repo)) }
|
|
81
|
+
const { tarball, integrity } = resolution
|
|
82
|
+
if (resolution.gitHosted) return { ...at, source: 'git', ...shown(URL.parse(tarball ?? '')) }
|
|
83
|
+
const registry = { ...at, source: 'registry' as const, ...review && integrity ? { integrity } : {} }
|
|
84
|
+
if (tarball === undefined) return registry
|
|
85
|
+
if (tarball.startsWith('file:')) return { ...at, source: 'file' }
|
|
86
|
+
const url = URL.parse(tarball)
|
|
87
|
+
if (url && isRegistry(url)) return registry
|
|
88
|
+
return integrity ? { ...at, ...shown(url), source: 'mirror', integrity } : { ...at, ...shown(url), source: 'tarball' }
|
|
89
|
+
}
|
|
90
|
+
// As above, and with what its reviews ask (LockedPackage `shasum`,
|
|
91
|
+
// `patchHash`, `lockfile`) where `review`.
|
|
92
|
+
function locked(pkg: ReadPackage, format: LockfileFormat, review: boolean): LockedPackage | null {
|
|
93
|
+
const one = located(pkg, review)
|
|
94
|
+
if (!one || !review) return one
|
|
95
|
+
const sha1 = pkg.resolution?.type === 'tarball' ? pkg.resolution.sha1 : undefined
|
|
96
|
+
return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {}, lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The packages a lockfile of `format` (npm, pnpm or yarn, as
|
|
100
|
+
// findRepoLockfiles names them) locks, each once, in the lockfile's order:
|
|
101
|
+
// `dev` only where it is wherever it is installed, at each path of npm's
|
|
102
|
+
// tree, and with each of pnpm's peers; with `review`, each with what its
|
|
103
|
+
// reviews hold it to (LockedPackage `integrity`, `shasum`, `patchHash`),
|
|
104
|
+
// each patch of it apart. Throws the reader's LockfileError or YamlError for
|
|
105
|
+
// one it refuses.
|
|
106
|
+
export function lockedPackages(format: LockfileFormat, text: string, { review = false } = {}): LockedPackage[] {
|
|
107
|
+
const found = new Map<string, LockedPackage>()
|
|
108
|
+
for (const pkg of packagesOf(format, text)) {
|
|
109
|
+
const one = locked(pkg, format, review)
|
|
110
|
+
if (!one) continue
|
|
111
|
+
if (pkg.dev === true) one.dev = true
|
|
112
|
+
const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}\0${one.shasum ?? ''}\0${one.patchHash ?? ''}`
|
|
113
|
+
const had = found.get(key)
|
|
114
|
+
if (!had) found.set(key, one)
|
|
115
|
+
else if (had.dev && !one.dev) delete had.dev
|
|
116
|
+
}
|
|
117
|
+
return [...found.values()]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Whether a lockfile at `path` is a test's fixture or example: under a
|
|
121
|
+
// directory of tests (`test`, `tests`, `__tests__` and such), in one of
|
|
122
|
+
// fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
|
|
123
|
+
// it, as a test may keep one its readers refuse on purpose.
|
|
124
|
+
const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
|
|
125
|
+
export function isTestFixture(path: string) {
|
|
126
|
+
const dirs = path.split('/').slice(0, -1)
|
|
127
|
+
const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
|
|
128
|
+
return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
|
|
129
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The lockfiles a repository's dependency review holds to, as an admin sets
|
|
2
|
+
// it (repository-dependency-reviews.ts): every one but those it names
|
|
3
|
+
// (`exclude`, naming none for all of them), or those it names alone
|
|
4
|
+
// (`include`). Each is named by its path in the repository, or, ending in
|
|
5
|
+
// `/`, by a directory, every lockfile under it. A directory of a pull
|
|
6
|
+
// request's lockfiles (pull-dependencies.ts) is reviewed where one of its
|
|
7
|
+
// lockfiles is held to; one none of whose are, not at all (`excluded`), nor
|
|
8
|
+
// failing the review where it couldn't be read.
|
|
9
|
+
export interface LockfileScope { mode: 'exclude' | 'include'; paths: string[] }
|
|
10
|
+
export const EVERY_LOCKFILE: LockfileScope = { mode: 'exclude', paths: [] }
|
|
11
|
+
export const MAX_SCOPED_LOCKFILES = 100
|
|
12
|
+
const MAX_PATH = 300
|
|
13
|
+
|
|
14
|
+
// A path as one is named: relative, of `/`-separated names none of which is
|
|
15
|
+
// empty, `.` or `..`, nor holding a backslash or a control character; a
|
|
16
|
+
// directory's ending in `/`.
|
|
17
|
+
function namedPath(path: unknown): path is string {
|
|
18
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PATH || path.startsWith('/')) return false
|
|
19
|
+
if (/[\\\p{Cc}]/u.test(path)) return false
|
|
20
|
+
const names = path.endsWith('/') ? path.slice(0, -1).split('/') : path.split('/')
|
|
21
|
+
return names.every(name => name !== '' && name !== '.' && name !== '..')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A scope as a request sends it, each path once, in order; null for one
|
|
25
|
+
// that isn't one.
|
|
26
|
+
export function scopeOf(value: unknown): LockfileScope | null {
|
|
27
|
+
const { mode, paths } = (value ?? {}) as { mode?: unknown; paths?: unknown }
|
|
28
|
+
if ((mode !== 'exclude' && mode !== 'include') || !Array.isArray(paths) || paths.length > MAX_SCOPED_LOCKFILES || !paths.every(namedPath)) return null
|
|
29
|
+
return { mode, paths: [...new Set(paths as string[])].toSorted() }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A scope as stored (JSON, none for every lockfile), every lockfile where
|
|
33
|
+
// it can't be read.
|
|
34
|
+
export function storedScope(text: string | null): LockfileScope {
|
|
35
|
+
if (!text) return EVERY_LOCKFILE
|
|
36
|
+
try { return scopeOf(JSON.parse(text)) ?? EVERY_LOCKFILE } catch { return EVERY_LOCKFILE }
|
|
37
|
+
}
|
|
38
|
+
export const scopeText = (scope: LockfileScope) => isEvery(scope) ? null : JSON.stringify({ mode: scope.mode, paths: scope.paths })
|
|
39
|
+
export const isEvery = (scope: LockfileScope) => scope.mode === 'exclude' && scope.paths.length === 0
|
|
40
|
+
|
|
41
|
+
const names = (path: string, named: string) => named.endsWith('/') ? path.startsWith(named) : path === named
|
|
42
|
+
// Whether the lockfile at `path` is held to.
|
|
43
|
+
export function heldTo(scope: LockfileScope, path: string) {
|
|
44
|
+
const named = scope.paths.some(one => names(path, one))
|
|
45
|
+
return scope.mode === 'include' ? named : !named
|
|
46
|
+
}
|
|
47
|
+
// Whether a directory of lockfiles at `paths` is reviewed: where one is
|
|
48
|
+
// held to, or it names none.
|
|
49
|
+
export const reviewedIn = (scope: LockfileScope, paths: string[]) => paths.length === 0 || paths.some(path => heldTo(scope, path))
|