@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,245 @@
|
|
|
1
|
+
// Persistent workspace activity. Uploads are recorded in the same transaction
|
|
2
|
+
// as their metadata; triage reuses its existing immutable trail, including
|
|
3
|
+
// legacy entries. Never copy annotation bodies or credentials into this feed.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
6
|
+
import type { ManagedSql } from './sql.ts'
|
|
7
|
+
|
|
8
|
+
export interface ActivityInput {
|
|
9
|
+
kind: 'access' | 'repository' | 'visibility' | 'delete'
|
|
10
|
+
actor: string
|
|
11
|
+
actorId?: string | null
|
|
12
|
+
action: string
|
|
13
|
+
repo?: string | null
|
|
14
|
+
reportId?: string | null
|
|
15
|
+
bundleId?: string | null
|
|
16
|
+
repoId?: number | null
|
|
17
|
+
repoDirectory?: string | null
|
|
18
|
+
report?: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ActivityContext {
|
|
22
|
+
finding: string
|
|
23
|
+
reportId: string
|
|
24
|
+
report: string
|
|
25
|
+
repo: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ActivityEntry = {
|
|
29
|
+
id: string; kind: string; actor: string | null; action: string
|
|
30
|
+
repo: string | null; reportId: string | null; report: string | null
|
|
31
|
+
finding: string | null; at: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActivityQuery {
|
|
35
|
+
page: number
|
|
36
|
+
limit: number
|
|
37
|
+
kind: string
|
|
38
|
+
query: string
|
|
39
|
+
repo?: string
|
|
40
|
+
actor?: string
|
|
41
|
+
// null = admin; [] = no accessible findings. Context is supplied by the
|
|
42
|
+
// server, never by the client. It also replaces potentially private context
|
|
43
|
+
// from an old report carrying the same globally shared finding.
|
|
44
|
+
contexts: ActivityContext[] | null
|
|
45
|
+
userId?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ActivityFilters {
|
|
49
|
+
repos: string[]
|
|
50
|
+
users: { id: string; login: string; detail: string | null }[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ActivityStore {
|
|
54
|
+
recordActivity(entry: ActivityInput, at: number): Promise<void>
|
|
55
|
+
listActivityReports(userId: string): Promise<Omit<ActivityContext, 'finding'>[]>
|
|
56
|
+
listActivity(query: ActivityQuery): Promise<{ history: ActivityEntry[]; total: number; page: number; limit: number; filters: ActivityFilters }>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const triageFields = `'triage:' || e.seq AS id, 'triage' AS kind,
|
|
60
|
+
COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId,
|
|
61
|
+
CASE WHEN e.color IS NULL AND e.triage IS NULL AND e.comment IS NULL AND e.fix IS NULL AND e.flagged IS NULL
|
|
62
|
+
THEN 'cleared triage' ELSE 'updated triage' END AS action`
|
|
63
|
+
const triage = `SELECT ${triageFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
|
|
64
|
+
FROM managed_finding_triage_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
|
|
65
|
+
const commentFields = `'comment:' || e.seq AS id, 'triage' AS kind,
|
|
66
|
+
COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId, e.action`
|
|
67
|
+
const comments = `SELECT ${commentFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
|
|
68
|
+
FROM managed_finding_comment_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
|
|
69
|
+
const adminSource = `${triage} UNION ALL ${comments} UNION ALL
|
|
70
|
+
SELECT id, kind, actor, actor_id AS actorId, action, repo, report_id AS reportId, report, NULL AS finding, at FROM managed_activity`
|
|
71
|
+
// Keep the finite set of accessible findings outside the indexed event
|
|
72
|
+
// lookup. Scanning all events against json_each for every row is quadratic.
|
|
73
|
+
const managerSource = `SELECT ${triageFields},
|
|
74
|
+
json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
|
|
75
|
+
json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
|
|
76
|
+
FROM json_each(:contexts) c
|
|
77
|
+
CROSS JOIN managed_finding_triage_event e ON e.finding_id = json_extract(c.value, '$.finding')
|
|
78
|
+
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
79
|
+
UNION ALL SELECT ${commentFields},
|
|
80
|
+
json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
|
|
81
|
+
json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
|
|
82
|
+
FROM json_each(:contexts) c
|
|
83
|
+
CROSS JOIN managed_finding_comment_event e ON e.finding_id = json_extract(c.value, '$.finding')
|
|
84
|
+
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
85
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
|
|
86
|
+
CASE WHEN a.kind = 'repository' THEN 'changed a report repository assignment' ELSE a.action END AS action,
|
|
87
|
+
p.full_name AS repo, r.id AS reportId, r.filename AS report, NULL AS finding, a.at
|
|
88
|
+
FROM managed_activity a JOIN managed_report r ON r.id = a.report_id
|
|
89
|
+
LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id
|
|
90
|
+
WHERE EXISTS (
|
|
91
|
+
SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
92
|
+
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
93
|
+
WHERE tu.user_id = :userId AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')}
|
|
94
|
+
)
|
|
95
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
|
|
96
|
+
CASE WHEN a.kind = 'repository' THEN 'changed a bundle repository assignment' ELSE a.action END AS action,
|
|
97
|
+
p.full_name AS repo, NULL AS reportId, b.filename AS report, NULL AS finding, a.at
|
|
98
|
+
FROM managed_activity a JOIN managed_bundle b ON b.id = a.bundle_id
|
|
99
|
+
LEFT JOIN managed_selected_repo p ON p.repo_id = b.repo_id
|
|
100
|
+
WHERE a.report_id IS NULL AND EXISTS (
|
|
101
|
+
SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
102
|
+
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
103
|
+
WHERE tu.user_id = :userId AND tr.repo_id = b.repo_id AND ${withinTeamPath('b.repo_directory')}
|
|
104
|
+
)
|
|
105
|
+
UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId, a.action, a.repo, a.report_id AS reportId,
|
|
106
|
+
a.report, NULL AS finding, a.at FROM managed_activity a
|
|
107
|
+
WHERE a.kind = 'delete' AND (a.report_id IS NOT NULL OR a.bundle_id IS NOT NULL)
|
|
108
|
+
AND NOT EXISTS (SELECT 1 FROM managed_report WHERE id = a.report_id)
|
|
109
|
+
AND NOT EXISTS (SELECT 1 FROM managed_bundle WHERE id = a.bundle_id)
|
|
110
|
+
AND EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
111
|
+
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
112
|
+
WHERE tu.user_id = :userId AND tr.repo_id = a.repo_id
|
|
113
|
+
AND ${withinTeamPath('a.repo_directory')})`
|
|
114
|
+
|
|
115
|
+
export const ACTIVITY_SCHEMA = `
|
|
116
|
+
CREATE TABLE IF NOT EXISTS managed_activity (
|
|
117
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, actor TEXT, action TEXT NOT NULL,
|
|
118
|
+
repo TEXT, report_id TEXT, report TEXT, at INTEGER NOT NULL,
|
|
119
|
+
bundle_id TEXT, repo_id INTEGER, repo_directory TEXT, actor_id TEXT
|
|
120
|
+
) STRICT;
|
|
121
|
+
CREATE INDEX IF NOT EXISTS managed_activity_at_idx ON managed_activity(at, id);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS finding_triage_event_at_idx ON managed_finding_triage_event(at, seq);
|
|
123
|
+
`
|
|
124
|
+
|
|
125
|
+
export function initActivityMethods(db: DatabaseSync): void {
|
|
126
|
+
db.exec(ACTIVITY_SCHEMA)
|
|
127
|
+
migrateActivityScope(db)
|
|
128
|
+
db.exec('CREATE INDEX IF NOT EXISTS managed_activity_actor_at_idx ON managed_activity(actor_id, at)')
|
|
129
|
+
// Stable upload IDs make backfill idempotent, including after restarts.
|
|
130
|
+
// Snapshots deliberately have no cascading FKs: deletion is itself activity.
|
|
131
|
+
// CREATE TRIGGER IF NOT EXISTS keeps an older body; replace the one that
|
|
132
|
+
// predates server-built bundles so builds are not recorded as uploads.
|
|
133
|
+
const bundleTrigger = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'managed_bundle_activity'").get() as { sql: string } | undefined
|
|
134
|
+
if (bundleTrigger && !bundleTrigger.sql.includes('provenance')) db.exec('DROP TRIGGER managed_bundle_activity')
|
|
135
|
+
for (const type of ['report', 'bundle']) {
|
|
136
|
+
const columns = `id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id`
|
|
137
|
+
const values = (alias: string) => `'${type}-upload:' || ${alias}.id, 'upload',
|
|
138
|
+
COALESCE(${alias}.uploaded_by_login, (SELECT login FROM managed_user WHERE id = ${alias}.uploaded_by)), ${uploadAction(type, alias)},
|
|
139
|
+
(SELECT full_name FROM managed_selected_repo WHERE repo_id = ${alias}.repo_id),
|
|
140
|
+
${type === 'report' ? `${alias}.id` : 'NULL'}, ${alias}.filename, ${alias}.uploaded_at,
|
|
141
|
+
${type === 'bundle' ? `${alias}.id` : 'NULL'}, ${alias}.uploaded_by`
|
|
142
|
+
db.exec(`
|
|
143
|
+
CREATE TRIGGER IF NOT EXISTS managed_${type}_activity AFTER INSERT ON managed_${type}
|
|
144
|
+
BEGIN INSERT INTO managed_activity (${columns}) SELECT ${values('NEW')}; END;
|
|
145
|
+
INSERT OR IGNORE INTO managed_activity (${columns}) SELECT ${values('source')} FROM managed_${type} source;
|
|
146
|
+
`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The creation entry for a stored report or bundle. Shared with the Postgres
|
|
151
|
+
// trigger function; `alias` is the trigger row (NEW) or a backfill alias.
|
|
152
|
+
export function uploadAction(type: string, alias: string): string {
|
|
153
|
+
return type === 'bundle' ? `CASE WHEN ${alias}.provenance = 'build' THEN 'built a bundle' ELSE 'uploaded a bundle' END` : `'uploaded a ${type}'`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function activityMethods(db: ManagedSql): ActivityStore {
|
|
157
|
+
const insert = db.prepare(`INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, repo_id, repo_directory, actor_id)
|
|
158
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
159
|
+
const admin = activityStatements(db, adminSource)
|
|
160
|
+
const manager = activityStatements(db, managerSource)
|
|
161
|
+
const reports = db.prepare(`SELECT r.id AS reportId, r.filename AS report, p.full_name AS repo
|
|
162
|
+
FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id
|
|
163
|
+
WHERE EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
|
|
164
|
+
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
165
|
+
WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')})
|
|
166
|
+
ORDER BY r.uploaded_at DESC, r.id`)
|
|
167
|
+
return {
|
|
168
|
+
async recordActivity(entry, at) {
|
|
169
|
+
await insert.run(randomUUID(), entry.kind, entry.actor, entry.action, entry.repo ?? null, entry.reportId ?? null, entry.report ?? null, at,
|
|
170
|
+
entry.bundleId ?? null, entry.repoId ?? null, entry.repoDirectory ?? null, entry.actorId ?? null)
|
|
171
|
+
},
|
|
172
|
+
async listActivityReports(userId) {
|
|
173
|
+
return (await reports.all(userId)) as Omit<ActivityContext, 'finding'>[]
|
|
174
|
+
},
|
|
175
|
+
async listActivity({ page, limit, kind, query, repo = '', actor = '', contexts, userId }) {
|
|
176
|
+
const stmts = contexts == null ? admin : manager
|
|
177
|
+
const scope = contexts == null ? {} : { contexts: JSON.stringify(contexts), userId: userId ?? '' }
|
|
178
|
+
const params = { ...scope, kind, query, repo, actor }
|
|
179
|
+
const { total } = (await stmts.count.get(params)) as { total: number }
|
|
180
|
+
const currentPage = Math.min(page, Math.max(1, Math.ceil(total / limit)))
|
|
181
|
+
const history = (await stmts.rows.all({ ...params, limit, offset: (currentPage - 1) * limit })) as ActivityEntry[]
|
|
182
|
+
const filters: ActivityFilters = {
|
|
183
|
+
repos: ((await stmts.repos.all(scope)) as { repo: string }[]).map(row => row.repo),
|
|
184
|
+
users: ((await stmts.users.all(scope)) as ActivityFilters['users']).map(row => ({ id: row.id, login: row.login, detail: row.detail })),
|
|
185
|
+
}
|
|
186
|
+
return { history, total, page: currentPage, limit, filters }
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Both rows and selector choices come from the authorized source. Keep legacy
|
|
192
|
+
// login-only actors separate: a historical login is not proof of user identity.
|
|
193
|
+
function activityStatements(db: ManagedSql, source: string) {
|
|
194
|
+
const activity = `WITH events AS (${source}), activity AS (
|
|
195
|
+
SELECT events.*, COALESCE('user:' || actorId, 'legacy:' || actor) AS actorKey FROM events
|
|
196
|
+
)`
|
|
197
|
+
const filtered = `${activity} SELECT id, kind, actor, action, repo, reportId, report, finding, at FROM activity
|
|
198
|
+
WHERE (:kind = 'all' OR kind = :kind)
|
|
199
|
+
AND (:repo = '' OR repo = :repo) AND (:actor = '' OR actorKey = :actor)
|
|
200
|
+
AND (:query = '' OR instr(lower(coalesce(actor, '') || ' ' || action || ' ' ||
|
|
201
|
+
coalesce(repo, '') || ' ' || coalesce(report, '') || ' ' || coalesce(finding, '')), lower(:query)) > 0)`
|
|
202
|
+
return {
|
|
203
|
+
count: db.prepare(`SELECT count(*) AS total FROM (${filtered}) AS filtered`),
|
|
204
|
+
rows: db.prepare(`${filtered} ORDER BY at DESC,
|
|
205
|
+
CASE WHEN kind = 'triage' THEN CAST(substr(id, instr(id, ':') + 1) AS INTEGER) ELSE 0 END DESC,
|
|
206
|
+
id DESC LIMIT :limit OFFSET :offset`),
|
|
207
|
+
repos: db.prepare(`${activity} SELECT DISTINCT repo FROM activity WHERE repo IS NOT NULL AND repo <> '' ORDER BY repo`),
|
|
208
|
+
users: db.prepare(`${activity} SELECT a.actorKey AS id, COALESCE(u.login, a.actor, a.actorId) AS login,
|
|
209
|
+
CASE WHEN a.actorId IS NULL THEN 'Legacy actor' ELSE NULL END AS detail FROM (
|
|
210
|
+
SELECT actorKey, actorId, actor, ROW_NUMBER() OVER (PARTITION BY actorKey ORDER BY at DESC, id DESC) AS position
|
|
211
|
+
FROM activity WHERE actorKey IS NOT NULL AND actorKey <> 'legacy:'
|
|
212
|
+
) a LEFT JOIN managed_user u ON u.id = a.actorId WHERE a.position = 1 ORDER BY login, a.actorKey`),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Literal path comparison preserves separators, case and SQL wildcard chars.
|
|
217
|
+
function withinTeamPath(path: string): string {
|
|
218
|
+
return `(tr.path IS NULL OR tr.path = '' OR ${path} = tr.path OR substr(${path}, 1, length(tr.path) + 1) = tr.path || '/')`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function migrateActivityScope(db: DatabaseSync): void {
|
|
222
|
+
const columns = new Set((db.prepare('PRAGMA table_info(managed_activity)').all() as { name: string }[]).map(column => column.name))
|
|
223
|
+
const missing = [['bundle_id', 'TEXT'], ['repo_id', 'INTEGER'], ['repo_directory', 'TEXT'], ['actor_id', 'TEXT']].filter(([name]) => !columns.has(name!))
|
|
224
|
+
if (missing.length === 0) return
|
|
225
|
+
db.exec('BEGIN')
|
|
226
|
+
try {
|
|
227
|
+
for (const [name, type] of missing) db.exec(`ALTER TABLE managed_activity ADD COLUMN ${name} ${type}`)
|
|
228
|
+
if (!columns.has('bundle_id') || !columns.has('actor_id')) {
|
|
229
|
+
db.exec('DROP TRIGGER IF EXISTS managed_report_activity; DROP TRIGGER IF EXISTS managed_bundle_activity;')
|
|
230
|
+
}
|
|
231
|
+
if (!columns.has('bundle_id')) db.exec("UPDATE managed_activity SET bundle_id = substr(id, 15) WHERE id LIKE 'bundle-upload:%'")
|
|
232
|
+
if (!columns.has('actor_id')) {
|
|
233
|
+
// Existing upload targets retain a reliable uploader identity. Do not
|
|
234
|
+
// guess identities from historical logins: names can change or be reused.
|
|
235
|
+
for (const type of ['report', 'bundle']) {
|
|
236
|
+
db.exec(`UPDATE managed_activity SET actor_id = (
|
|
237
|
+
SELECT uploaded_by FROM managed_${type} WHERE id = substr(managed_activity.id, 15)
|
|
238
|
+
) WHERE id LIKE '${type}-upload:%'`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Legacy deletions/assignments without a durable target/scope remain
|
|
242
|
+
// admin-only; neither filenames nor repository names authorize access.
|
|
243
|
+
db.exec('COMMIT')
|
|
244
|
+
} catch (err) { db.exec('ROLLBACK'); throw err }
|
|
245
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Shared avatar cache interface, implemented by storage-stores.ts.
|
|
2
|
+
import type { Buffer } from 'node:buffer'
|
|
3
|
+
|
|
4
|
+
export interface CachedAvatar {
|
|
5
|
+
contentType: string
|
|
6
|
+
bytes: Buffer
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AvatarStore {
|
|
10
|
+
put(id: string, contentType: string, bytes: Buffer): Promise<void>
|
|
11
|
+
get(id: string): Promise<CachedAvatar | null>
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Logical upload-store interfaces shared by disk and Vercel storage.
|
|
2
|
+
import type { Buffer } from 'node:buffer'
|
|
3
|
+
import type { Readable } from 'node:stream'
|
|
4
|
+
|
|
5
|
+
export interface OpenedBlob {
|
|
6
|
+
// Remote streams may omit their size; callers then omit Content-Length.
|
|
7
|
+
size: number | null
|
|
8
|
+
stream: Readable
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BlobStore {
|
|
12
|
+
// Encrypted upload stores return the wrapped data key for the SQL insert.
|
|
13
|
+
put(id: string, bytes: Buffer): Promise<string | null>
|
|
14
|
+
get(id: string): Promise<Buffer | null>
|
|
15
|
+
open(id: string): Promise<OpenedBlob | null>
|
|
16
|
+
delete(id: string): Promise<void>
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { brotliCompress, constants } from 'node:zlib'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import type { Buffer } from 'node:buffer'
|
|
4
|
+
|
|
5
|
+
const compress = promisify(brotliCompress)
|
|
6
|
+
|
|
7
|
+
// Bundles and cold caches use quality 4; stored reports use quality 9.
|
|
8
|
+
export function encodeBrotli(bytes: Uint8Array, quality = 4): Promise<Buffer> {
|
|
9
|
+
return compress(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: quality } })
|
|
10
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type Advisory, type CacheStore, type Package, advisories } from '@preventive/upstream/advisories.js'
|
|
2
|
+
import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
|
|
3
|
+
import { isNpmPackageName, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
4
|
+
import { NPM_REGISTRY, askUpstream } from './npm-packages.ts'
|
|
5
|
+
|
|
6
|
+
export const ADVISORIES_TIMEOUT_MS = 30_000
|
|
7
|
+
const PUBLIC_AT_ONCE = 16
|
|
8
|
+
const order = (a: string, b: string) => Number(a > b) - Number(a < b)
|
|
9
|
+
|
|
10
|
+
// Whether anyone may read a package, so that its name may go to a service
|
|
11
|
+
// other than npm (OSV): an unscoped one always, npm's private packages
|
|
12
|
+
// always being scoped; a scoped one where npm answers it anonymously, asked
|
|
13
|
+
// by a HEAD, which reads nothing of it. One npm can't be asked of is taken
|
|
14
|
+
// as private, and not kept; the rest are, for PUBLIC_MS, PUBLIC_NAMES at
|
|
15
|
+
// most, as a package may be made private, or public. A name npm can't have
|
|
16
|
+
// is asked of no one, upstream refusing it.
|
|
17
|
+
const PUBLIC_MS = 60 * 60_000
|
|
18
|
+
const PUBLIC_NAMES = 10_000
|
|
19
|
+
const publicNames = new Map<string, { at: number; open: boolean }>()
|
|
20
|
+
async function isPublicNpmPackage(name: string, signal: AbortSignal): Promise<boolean> {
|
|
21
|
+
if (!isNpmPackageName(name)) return false
|
|
22
|
+
if (npmPackageScope(name) === null) return true
|
|
23
|
+
const kept = publicNames.get(name)
|
|
24
|
+
if (kept && Date.now() - kept.at < PUBLIC_MS) return kept.open
|
|
25
|
+
let open
|
|
26
|
+
try { open = await askUpstream(`${NPM_REGISTRY}/${name}`, { method: 'HEAD', limit: Number.MAX_SAFE_INTEGER, signal, absent: [401, 403, 404] }) !== null } catch { return false }
|
|
27
|
+
publicNames.delete(name)
|
|
28
|
+
publicNames.set(name, { at: Date.now(), open })
|
|
29
|
+
while (publicNames.size > PUBLIC_NAMES) publicNames.delete(publicNames.keys().next().value!)
|
|
30
|
+
return open
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The npm packages anyone may read (isPublicNpmPackage), a few asked at a
|
|
34
|
+
// time, within PUBLIC_BUDGET_MS, a third of the audit's: a scoped one npm
|
|
35
|
+
// hasn't told of by then is taken as private, asked of npm alone, for the
|
|
36
|
+
// audit to keep the rest of its time.
|
|
37
|
+
const PUBLIC_BUDGET_MS = 10_000
|
|
38
|
+
async function publicNpmNames(packages: Package[], signal: AbortSignal): Promise<Set<string>> {
|
|
39
|
+
const names = [...new Set(packages.filter(pkg => pkg.ecosystem === 'npm').map(pkg => pkg.name))]
|
|
40
|
+
const open = new Set<string>()
|
|
41
|
+
const budget = new AbortController()
|
|
42
|
+
const timer = setTimeout(() => { budget.abort(new Error('public-check-budget')) }, PUBLIC_BUDGET_MS)
|
|
43
|
+
const asking = AbortSignal.any([signal, budget.signal])
|
|
44
|
+
try {
|
|
45
|
+
for (let at = 0; at < names.length; at += PUBLIC_AT_ONCE) {
|
|
46
|
+
const chunk = names.slice(at, at + PUBLIC_AT_ONCE)
|
|
47
|
+
const checked = await Promise.all(chunk.map(name => isPublicNpmPackage(name, asking)))
|
|
48
|
+
for (const [i, name] of chunk.entries()) if (checked[i]) open.add(name)
|
|
49
|
+
}
|
|
50
|
+
} finally { clearTimeout(timer) }
|
|
51
|
+
return open
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
|
|
55
|
+
const authenticated = createClient({ token, userAgent: 'deepview-triage' })
|
|
56
|
+
let client = authenticated
|
|
57
|
+
return {
|
|
58
|
+
...authenticated,
|
|
59
|
+
async listRepoAdvisories(options) {
|
|
60
|
+
const current = client
|
|
61
|
+
try { return await current.listRepoAdvisories(options) } catch (error) {
|
|
62
|
+
if (token === null || current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
|
|
63
|
+
signal.throwIfAborted()
|
|
64
|
+
// Published advisories remain readable after a token is revoked. Reuse
|
|
65
|
+
// anonymous access for subsequent repositories in this audit as well.
|
|
66
|
+
if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
|
|
67
|
+
return await client.listRepoAdvisories(options)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The managed caller supplies its user's token when available; public shares
|
|
74
|
+
// remain anonymous. Upstream sends this credential only to GitHub and returns
|
|
75
|
+
// published advisories, matching versions across npm, OSV and repository rows:
|
|
76
|
+
// OSV's for npm packages anyone may read too, the malicious packages' records
|
|
77
|
+
// among them, which npm's registry hasn't.
|
|
78
|
+
// `cache` keeps each repository's listing (upstream-cache.ts) across audits;
|
|
79
|
+
// `github`, a client of the caller's, asks GitHub in place of one with
|
|
80
|
+
// `githubToken`.
|
|
81
|
+
export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
|
|
82
|
+
{ debug = false, repoAdvisories = false, details = false, githubToken = null, github, cache }: {
|
|
83
|
+
debug?: boolean; repoAdvisories?: boolean; details?: boolean; githubToken?: string | null; github?: Client; cache?: CacheStore | undefined
|
|
84
|
+
} = {}): Promise<
|
|
85
|
+
{ status: 200; body: Advisory[] } | { status: 502; body: { error: string } }
|
|
86
|
+
> {
|
|
87
|
+
let onAbort: (() => void) | undefined
|
|
88
|
+
try {
|
|
89
|
+
signal.throwIfAborted()
|
|
90
|
+
if (packages.length === 0) return { status: 200, body: [] }
|
|
91
|
+
// Upstream owns each transport's timeout and exposes no cancellation
|
|
92
|
+
// option. Bound the caller's wait for the multi-request audit as well.
|
|
93
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
94
|
+
onAbort = () => reject(signal.reason)
|
|
95
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
96
|
+
})
|
|
97
|
+
// npm packages anyone may read are asked of OSV too (upstream's `osv`),
|
|
98
|
+
// for the malicious packages' records npm's registry hasn't; a private
|
|
99
|
+
// one's name never leaves for OSV, as it never does for Socket. A package
|
|
100
|
+
// is wholly in one ask or the other, so upstream's order holds.
|
|
101
|
+
const open = await Promise.race([publicNpmNames(packages, signal), deadline])
|
|
102
|
+
const toOsv = (pkg: Package) => pkg.ecosystem === 'npm' && open.has(pkg.name)
|
|
103
|
+
// Kept in the caller's store, or without one in upstream's disk cache,
|
|
104
|
+
// where the server set one (auditCache).
|
|
105
|
+
const options = { github: github ?? advisoryGithubClient(githubToken, signal), repoAdvisories, details, ...(cache && { cache }) }
|
|
106
|
+
const asked = [{ part: packages.filter(toOsv), osv: true }, { part: packages.filter(pkg => !toOsv(pkg)), osv: false }]
|
|
107
|
+
const result = await Promise.race([Promise.all(asked.map(({ part, osv }) => part.length === 0 ? Promise.resolve([]) : advisories(part, { ...options, osv }))), deadline])
|
|
108
|
+
return { status: 200, body: result.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name)) }
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Answered as upstream's failure alone: logged, its message, or with
|
|
111
|
+
// `debug` the whole error, but for a request its client gave up on. One
|
|
112
|
+
// past its deadline (aborted with a TimeoutError, as AbortSignal.timeout
|
|
113
|
+
// and the bundle route's timer abort) is the server giving up: logged.
|
|
114
|
+
const cancelled = signal.aborted && (signal.reason as { name?: unknown } | undefined)?.name !== 'TimeoutError'
|
|
115
|
+
if (!cancelled) console.warn('managed: advisories failed:', debug || !(error instanceof Error) ? error : error.message)
|
|
116
|
+
return { status: 502, body: { error: 'upstream-unavailable' } }
|
|
117
|
+
} finally {
|
|
118
|
+
if (onAbort) signal.removeEventListener('abort', onAbort)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isEvidenceFile } from '@exodus/stasis/audit-corrections'
|
|
2
|
+
import { packageRepo } from '@exodus/stasis-core/bundle-util'
|
|
3
|
+
import type { Package } from '@preventive/upstream/advisories.js'
|
|
4
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
5
|
+
|
|
6
|
+
export interface SkippedAdvisoryPackage {
|
|
7
|
+
ecosystem: string
|
|
8
|
+
name: string
|
|
9
|
+
version: string
|
|
10
|
+
because: string
|
|
11
|
+
}
|
|
12
|
+
export interface BundleAdvisoryInventory { packages: Package[]; skipped: SkippedAdvisoryPackage[] }
|
|
13
|
+
interface BundleModule { ecosystem?: string; name?: string; version?: string; repo?: { github?: Package['github'] }; files: Record<string, unknown> }
|
|
14
|
+
|
|
15
|
+
// The GitHub repository a bundled package.json's text names, read as Stasis
|
|
16
|
+
// records a dependency's (packageRepo) and as Node reads the file, past a byte
|
|
17
|
+
// order mark the bundle keeps. Undefined where it names none or doesn't parse.
|
|
18
|
+
function manifestRepo(text: unknown): Package['github'] {
|
|
19
|
+
if (typeof text !== 'string') return undefined
|
|
20
|
+
try { return packageRepo(JSON.parse(text.replace(/^\uFEFF/u, '')))?.github } catch { return undefined }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function supported(ecosystem: string): ecosystem is Package['ecosystem'] {
|
|
24
|
+
return ['npm', 'cargo', 'composer', 'soldeer', 'github'].includes(ecosystem)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function skipReason(ecosystem: string, version: string): string | undefined {
|
|
28
|
+
if (ecosystem === 'cargo-git') return 'Crate vendored from git; its identity is not a crates.io package.'
|
|
29
|
+
if (ecosystem === 'cargo-unknown') return 'Crate source is unknown because .cargo-checksum.json is missing.'
|
|
30
|
+
if (ecosystem === 'composer' && /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))) return 'Composer dev versions cannot be matched against release advisories.'
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Package and reason presence both require a recorded evidence file, by Stasis's
|
|
35
|
+
// audit rule (server-only, as its semver uses Node's npm). Entry and manually
|
|
36
|
+
// added files count even when they have no incoming import edge.
|
|
37
|
+
export function bundleAdvisoryInventory(details: BundleDetails, paths: Iterable<string> | null = null): BundleAdvisoryInventory {
|
|
38
|
+
const modules = (details.bundle as { modules?: ReadonlyMap<string, BundleModule> } | undefined)?.modules
|
|
39
|
+
if (details.kind !== 'stasis' || !modules) return { packages: [], skipped: [] }
|
|
40
|
+
const selected = paths === null ? null : new Set(paths)
|
|
41
|
+
const packages = new Map<string, { ecosystem: Package['ecosystem']; name: string; versions: Set<string>; github?: Package['github'] | null }>()
|
|
42
|
+
const skipped = new Map<string, SkippedAdvisoryPackage>()
|
|
43
|
+
for (const [dir, info] of modules) {
|
|
44
|
+
if (dir === '.') continue
|
|
45
|
+
const ecosystem = info.ecosystem ?? (/(?:^|\/)node_modules\//u.test(dir) ? 'npm' : undefined)
|
|
46
|
+
const { name, version } = info
|
|
47
|
+
if (ecosystem === undefined || typeof name !== 'string' || !name || typeof version !== 'string' || !version) continue
|
|
48
|
+
if (!Object.keys(info.files).some(file => (!selected || selected.has(`${dir}/${file}`)) && isEvidenceFile(name, version, file, ecosystem))) continue
|
|
49
|
+
const because = skipReason(ecosystem, version)
|
|
50
|
+
if (!supported(ecosystem) || because !== undefined) {
|
|
51
|
+
skipped.set(JSON.stringify([ecosystem, name, version]), { ecosystem, name, version, because: because ?? `No advisory source is supported for ${ecosystem}.` })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
// Foundry's branch '.' means the superproject's branch. As in Stasis,
|
|
55
|
+
// 0.0.0 tells upstream its actual version is unknown, so every range covers it.
|
|
56
|
+
const auditedVersion = ecosystem === 'github' && version === '.' ? '0.0.0' : version
|
|
57
|
+
const key = JSON.stringify([ecosystem, ecosystem === 'github' ? name.toLowerCase() : name])
|
|
58
|
+
if (!packages.has(key)) packages.set(key, { ecosystem, name, versions: new Set() })
|
|
59
|
+
const pkg = packages.get(key)!
|
|
60
|
+
pkg.versions.add(auditedVersion)
|
|
61
|
+
// A bundle from before Stasis recorded `repo` may still carry the npm
|
|
62
|
+
// package's package.json; ask the repository it names, as Stasis does.
|
|
63
|
+
let github = ecosystem === 'github' ? undefined : info.repo?.github
|
|
64
|
+
if (github === undefined && ecosystem === 'npm') github = manifestRepo(info.files['package.json'])
|
|
65
|
+
if (github !== undefined) {
|
|
66
|
+
// Upstream accepts one repository per package. Conflicting hints (for
|
|
67
|
+
// example, versions from before and after a move) use registry discovery.
|
|
68
|
+
if (pkg.github === undefined) pkg.github = github
|
|
69
|
+
else if (pkg.github !== null && pkg.github.toLowerCase() !== github.toLowerCase()) pkg.github = null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const byPackage = (a: { ecosystem: string; name: string }, b: { ecosystem: string; name: string }) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name)
|
|
73
|
+
return {
|
|
74
|
+
packages: [...packages.values()].map(({ github, ...pkg }) => ({ ...pkg, versions: [...pkg.versions].toSorted(), ...(github ? { github } : {}) })).toSorted(byPackage),
|
|
75
|
+
skipped: [...skipped.values()].toSorted((a, b) => byPackage(a, b) || a.version.localeCompare(b.version)),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface BundleBuildDiagnostic {
|
|
2
|
+
message: string
|
|
3
|
+
name?: string
|
|
4
|
+
code?: string
|
|
5
|
+
status?: number
|
|
6
|
+
stack?: string
|
|
7
|
+
cause?: BundleBuildDiagnostic
|
|
8
|
+
}
|
|
9
|
+
export function bundleBuildDiagnostic(error: unknown, token: string | null): BundleBuildDiagnostic
|
|
10
|
+
export type BundleBuildStage = 'worker-start' | 'build' | 'scope' | 'serialize' | 'compress'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Whitelist diagnostic fields; never inspect/serialize an entire upstream
|
|
2
|
+
// error, request, response, or workerData (which contains the GitHub token).
|
|
3
|
+
export function bundleBuildDiagnostic(error, token, depth = 0) {
|
|
4
|
+
const redact = value => {
|
|
5
|
+
let text = String(value)
|
|
6
|
+
if (token) text = text.replaceAll(token, '[redacted]')
|
|
7
|
+
return text.replaceAll(/\b(?:github_pat_|gh[pousr]_)[\w]+/gu, '[redacted]')
|
|
8
|
+
.replaceAll(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gu, '$1[redacted]@')
|
|
9
|
+
.replaceAll(/([?&](?:access_token|token|key)=)[^\s&#]*/giu, '$1[redacted]')
|
|
10
|
+
}
|
|
11
|
+
if (!error || typeof error !== 'object') return { message: redact(error).slice(0, 2048) }
|
|
12
|
+
// Upstream HttpError messages append the response body after the status.
|
|
13
|
+
// Keep the method, URL and status, but omit that body from message and stack.
|
|
14
|
+
const raw = typeof error.message === 'string' ? error.message : 'Unknown error'
|
|
15
|
+
const message = error.name === 'HttpError' ? raw.replace(/(\s\d{3}):[\s\S]*$/u, '$1') : raw
|
|
16
|
+
const result = { message: redact(message).slice(0, 2048) }
|
|
17
|
+
if (typeof error.name === 'string') result.name = redact(error.name).slice(0, 128)
|
|
18
|
+
if (typeof error.code === 'string') result.code = redact(error.code).slice(0, 128)
|
|
19
|
+
if (typeof error.status === 'number') result.status = error.status
|
|
20
|
+
if (typeof error.stack === 'string') result.stack = redact(error.stack.replace(raw, message)).slice(0, 8192)
|
|
21
|
+
if (error.cause && depth < 3) result.cause = bundleBuildDiagnostic(error.cause, token, depth + 1)
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ManagedSql } from './sql.ts'
|
|
2
|
+
|
|
3
|
+
export const BUNDLE_BUILD_TIMEOUT_MS = 180_000
|
|
4
|
+
// Leave time to terminate a timed-out worker before a crashed owner's slot
|
|
5
|
+
// becomes reusable. Expiration uses the database clock, not an instance's clock.
|
|
6
|
+
const LEASE_MS = BUNDLE_BUILD_TIMEOUT_MS + 60_000
|
|
7
|
+
export const BUNDLE_BUILD_LEASE_SCHEMA = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS managed_bundle_build_lease (
|
|
9
|
+
slot INTEGER PRIMARY KEY CHECK (slot IN (1, 2)),
|
|
10
|
+
owner TEXT NOT NULL UNIQUE,
|
|
11
|
+
user_id TEXT NOT NULL UNIQUE,
|
|
12
|
+
expires_at INTEGER NOT NULL
|
|
13
|
+
) STRICT;
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
export interface BundleBuildLeaseStore {
|
|
17
|
+
claimBundleBuildLease(userId: string, owner: string): Promise<boolean>
|
|
18
|
+
releaseBundleBuildLease(owner: string): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bundleBuildLeaseMethods(db: ManagedSql): BundleBuildLeaseStore {
|
|
22
|
+
return {
|
|
23
|
+
// scopeManagedMethods holds SQLite's write transaction / PostgreSQL's
|
|
24
|
+
// advisory transaction lock across cleanup, inspection, and admission.
|
|
25
|
+
async claimBundleBuildLease(userId, owner) {
|
|
26
|
+
const { now } = await db.prepare("SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now").get() as { now: number }
|
|
27
|
+
await db.prepare('DELETE FROM managed_bundle_build_lease WHERE expires_at <= ?').run(now)
|
|
28
|
+
const leases = await db.prepare('SELECT slot, user_id AS userId FROM managed_bundle_build_lease').all() as { slot: number; userId: string }[]
|
|
29
|
+
if (leases.length >= 2 || leases.some(lease => lease.userId === userId)) return false
|
|
30
|
+
const slot = leases.some(lease => lease.slot === 1) ? 2 : 1
|
|
31
|
+
await db.prepare('INSERT INTO managed_bundle_build_lease (slot, owner, user_id, expires_at) VALUES (?, ?, ?, ?)')
|
|
32
|
+
.run(slot, owner, userId, now + LEASE_MS)
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
// No user FK: deleting an account must not free a still-running worker's
|
|
36
|
+
// slot. A delayed/crashed owner's cleanup cannot release its successor.
|
|
37
|
+
async releaseBundleBuildLease(owner) {
|
|
38
|
+
await db.prepare('DELETE FROM managed_bundle_build_lease WHERE owner = ?').run(owner)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|