@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40
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-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-AQ75QITE.js +1 -0
- package/out/chunk-B2RQY6OP.js +2 -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-IISEF2BZ.js +1 -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-KYCNJVB7.js +1 -0
- package/out/chunk-MYDDTA7W.js +225 -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-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TGANXVTA.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UN5ZSYLE.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-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-XYDGU6BM.js +51 -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 +297 -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 +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -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/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 +418 -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,45 @@
|
|
|
1
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
2
|
+
import { type RepoScope, contentAccess } from './content-access.ts'
|
|
3
|
+
import type { AdminBundle, AdminReport, ManagedDb } from './db-methods.ts'
|
|
4
|
+
import { ManagedMutationError } from './management.ts'
|
|
5
|
+
|
|
6
|
+
type CatalogAccess = { repos: { repoId: number; fullName: string }[]; repoScopes: RepoScope[] | null }
|
|
7
|
+
export interface ManagementCatalogStore {
|
|
8
|
+
getReportCatalog(sessionId: string, now: number): Promise<CatalogAccess & { reports: (AdminReport & { canChangeRepo: boolean })[] }>
|
|
9
|
+
getBundleCatalog(sessionId: string, now: number): Promise<CatalogAccess & { bundles: (AdminBundle & { canChangeRepo: boolean })[] }>
|
|
10
|
+
}
|
|
11
|
+
type CatalogDb = Pick<ManagedDb, 'getReportAccessSnapshot' | 'listRepoScopesForUser' | 'listSelectedRepos'
|
|
12
|
+
| 'listReports' | 'listBundles' | 'listReadableBundleIds'>
|
|
13
|
+
|
|
14
|
+
// The outer store method owns one read snapshot. Use raw methods here so the
|
|
15
|
+
// session, grants, rows and action flags agree without per-row transactions.
|
|
16
|
+
export function managementCatalogMethods(db: CatalogDb): ManagementCatalogStore {
|
|
17
|
+
async function access(sessionId: string, now: number) {
|
|
18
|
+
const snapshot = await db.getReportAccessSnapshot(sessionId, now, [])
|
|
19
|
+
if (!snapshot) throw new ManagedMutationError(401, 'unauthenticated')
|
|
20
|
+
if (!roleAtLeast(snapshot.user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
|
|
21
|
+
const scope = await contentAccess(db, snapshot.user)
|
|
22
|
+
const repos = (await db.listSelectedRepos()).filter(scope.bundle).map(({ repoId, fullName }) => ({ repoId, fullName }))
|
|
23
|
+
return { userId: snapshot.user.role === 'admin' ? undefined : snapshot.user.id, scope, repos }
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async getReportCatalog(sessionId, now) {
|
|
27
|
+
const { userId, scope, repos } = await access(sessionId, now)
|
|
28
|
+
const reports = await db.listReports(userId)
|
|
29
|
+
const linked = reports.flatMap(report => report.bundleId ? [report.bundleId] : [])
|
|
30
|
+
const readable = userId === undefined ? new Set(linked) : new Set(await db.listReadableBundleIds(userId, linked))
|
|
31
|
+
return { repos, repoScopes: scope.scopes, reports: reports.map(report => ({ ...report,
|
|
32
|
+
canChangeRepo: report.repoId === null || scope.report(report),
|
|
33
|
+
bundleId: report.bundleId && readable.has(report.bundleId) ? report.bundleId : null,
|
|
34
|
+
bundleFilename: report.bundleId && readable.has(report.bundleId) ? report.bundleFilename : null,
|
|
35
|
+
})) }
|
|
36
|
+
},
|
|
37
|
+
async getBundleCatalog(sessionId, now) {
|
|
38
|
+
const { userId, scope, repos } = await access(sessionId, now)
|
|
39
|
+
const bundles = await db.listBundles(userId)
|
|
40
|
+
return { repos, repoScopes: scope.scopes, bundles: bundles.map(bundle => ({ ...bundle,
|
|
41
|
+
canChangeRepo: bundle.repoId === null || scope.report(bundle),
|
|
42
|
+
})) }
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { AdminReport, BundleReuse, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
|
|
2
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
3
|
+
|
|
4
|
+
export class ManagedMutationError extends Error {
|
|
5
|
+
status: number
|
|
6
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string } | { type: 'visibility'; visible: boolean }
|
|
10
|
+
export type ReportMutation = ContentMutation
|
|
11
|
+
type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
|
|
12
|
+
type RemovalAnnotations = { reports: string; ids: string[] }
|
|
13
|
+
export interface ManagementStore {
|
|
14
|
+
mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
|
|
15
|
+
mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
|
|
16
|
+
// A repeated upload/build of stored bytes (see reuseBundle). Renames need the
|
|
17
|
+
// access other bundle mutations need; a server build labels the row anyway.
|
|
18
|
+
reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>): Promise<boolean>
|
|
19
|
+
removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
|
|
20
|
+
reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
|
|
24
|
+
| 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
|
|
25
|
+
| 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo' | 'setBundleVisible' | 'reuseBundle'
|
|
26
|
+
| 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
|
|
27
|
+
|
|
28
|
+
// Only immutable bytes/format and repository membership affect the overlap
|
|
29
|
+
// scan. Ignore publication, user display names, and unrelated bundle links.
|
|
30
|
+
export function reportReferenceSnapshot(reports: readonly Pick<AdminReport, 'id' | 'sha256' | 'filename' | 'repoId'>[]): string {
|
|
31
|
+
return JSON.stringify(reports.map(r => [r.id, r.sha256, r.filename, r.repoId]).toSorted((a, b) => String(a[0]).localeCompare(String(b[0]))))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// These methods are wrapped by scopeManagedMethods: authorization, comparison,
|
|
35
|
+
// metadata changes (and repository-removal audit) share the writer transaction. Blob reads
|
|
36
|
+
// and deletes stay outside it. Use the raw store here, never nested scopes.
|
|
37
|
+
export function managementMethods(db: ManagementDb): ManagementStore {
|
|
38
|
+
async function authorize(sessionId: string, role: 'admin' | 'manage') {
|
|
39
|
+
const s = await db.sessionWithUser(sessionId, Date.now())
|
|
40
|
+
if (!s) throw new ManagedMutationError(401, 'unauthenticated')
|
|
41
|
+
if (!roleAtLeast(s.user.role, role)) throw new ManagedMutationError(403, 'forbidden')
|
|
42
|
+
return s.user
|
|
43
|
+
}
|
|
44
|
+
async function destination(user: { id: string; role: string }, change: ContentMutation) {
|
|
45
|
+
if (change.type !== 'repo' || change.repoId === null) return
|
|
46
|
+
if (!(await db.listSelectedRepos()).some(repo => repo.repoId === change.repoId)) throw new ManagedMutationError(400, 'bad-repo')
|
|
47
|
+
if (user.role !== 'admin' && !await db.userCanReadRepoPath(user.id, change.repoId, change.directory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
async mutateReport(sessionId: string, id: string, change: ReportMutation) {
|
|
51
|
+
const user = await authorize(sessionId, 'manage')
|
|
52
|
+
const report = await db.getReport(id)
|
|
53
|
+
if (!report || (user.role !== 'admin' && report.uploadedBy !== user.id && !await db.userCanReadReport(user.id, id))) throw new ManagedMutationError(404, 'no-report')
|
|
54
|
+
if (user.role !== 'admin' && report.repoId !== null && !await db.userCanReadReport(user.id, id)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
55
|
+
if (change.type === 'repo' && report.repoEmbedded) throw new ManagedMutationError(409, 'repo-in-report')
|
|
56
|
+
if (change.type !== 'visibility') await destination(user, change)
|
|
57
|
+
if (change.type === 'delete') await db.deleteReport(id)
|
|
58
|
+
else if (change.type === 'visibility') await db.setReportVisible(id, change.visible)
|
|
59
|
+
else await db.setReportRepo(id, change.repoId, change.directory)
|
|
60
|
+
return { report, user }
|
|
61
|
+
},
|
|
62
|
+
async mutateBundle(sessionId: string, id: string, change: ContentMutation) {
|
|
63
|
+
const user = await authorize(sessionId, 'manage')
|
|
64
|
+
const bundle = await db.getBundle(id)
|
|
65
|
+
if (!bundle || (user.role !== 'admin' && !await db.userCanReadBundle(user.id, id))) throw new ManagedMutationError(404, 'no-bundle')
|
|
66
|
+
if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
67
|
+
await destination(user, change)
|
|
68
|
+
if (change.type === 'delete') await db.deleteBundle(id)
|
|
69
|
+
else if (change.type === 'visibility') await db.setBundleVisible(id, change.visible)
|
|
70
|
+
else await db.setBundleRepo(id, change.repoId, change.directory)
|
|
71
|
+
return { bundle, user }
|
|
72
|
+
},
|
|
73
|
+
async reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>) {
|
|
74
|
+
const user = await authorize(sessionId, 'manage')
|
|
75
|
+
const bundle = await db.getBundle(id)
|
|
76
|
+
if (!bundle) return false
|
|
77
|
+
const rename = user.role === 'admin' || (await db.userCanReadBundle(user.id, id)
|
|
78
|
+
&& (bundle.repoId === null || await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)))
|
|
79
|
+
if (!rename && reuse.provenance !== 'build') return false
|
|
80
|
+
return db.reuseBundle(id, { ...reuse, rename })
|
|
81
|
+
},
|
|
82
|
+
async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
|
|
83
|
+
const user = await authorize(sessionId, 'admin')
|
|
84
|
+
const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
|
|
85
|
+
if (!repo) throw new ManagedMutationError(404, 'no-repo')
|
|
86
|
+
if (repo.fullName !== expected.fullName || repo.addedAt !== expected.addedAt) throw new ManagedMutationError(409, 'repository-changed')
|
|
87
|
+
if (annotations && reportReferenceSnapshot(await db.listReports()) !== annotations.reports) throw new ManagedMutationError(409, 'repository-changed')
|
|
88
|
+
const reports = await db.listReportsForRepo(repo.repoId)
|
|
89
|
+
const bundles = await db.listBundlesForRepo(repo.repoId)
|
|
90
|
+
const deletedReports = await db.deleteReportsForRepo(repo.repoId)
|
|
91
|
+
const deletedBundles = await db.deleteBundlesForRepo(repo.repoId)
|
|
92
|
+
const deletedTriage = await db.deleteTriage(annotations?.ids ?? [])
|
|
93
|
+
await db.deleteRepo(repo.repoId)
|
|
94
|
+
await db.recordActivity({ kind: 'delete', actor: user.login, actorId: user.id, repo: repo.fullName,
|
|
95
|
+
action: `removed a repository (${deletedReports} reports, ${deletedBundles} bundles, ${deletedTriage} triage entries)` }, Date.now())
|
|
96
|
+
return { reports, bundles, deletedReports, deletedBundles, deletedTriage }
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// Figures and advisories for the npm package viewer's Overview, beside a
|
|
2
|
+
// version's files (npm-packages.ts, npm-loads.ts): the package's downloads
|
|
3
|
+
// over the last year, from npm's public downloads API; its GitHub
|
|
4
|
+
// repository's stars, forks and open issues; and its advisories across every
|
|
5
|
+
// published version, as `npm audit` has them and as its repository publishes
|
|
6
|
+
// them on GitHub, asked as bundle advisories ask (bundle-advisories.ts); and
|
|
7
|
+
// Socket's scores and alerts for a version. All of it is public: npm is asked
|
|
8
|
+
// without the server's token, Socket only about a version npm answers for
|
|
9
|
+
// without one (http.ts), and a repository's figures, tags and advisories are
|
|
10
|
+
// asked for, and kept, only where GitHub says it is public. Each
|
|
11
|
+
// answer is kept for an hour, a failure not at all; the reader's access to
|
|
12
|
+
// the package is checked on every request before any of it is answered
|
|
13
|
+
// (http.ts handleNpm).
|
|
14
|
+
import type { Advisory, CacheStore } from '@preventive/upstream/advisories.js'
|
|
15
|
+
import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
|
|
16
|
+
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
17
|
+
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
18
|
+
import { kept } from './kept.ts'
|
|
19
|
+
import { type NpmProvenance, keptNpmProvenance } from './npm-provenance.ts'
|
|
20
|
+
import { NpmPackageError, askUpstreamJson, plainObject, readLimited } from './npm-packages.ts'
|
|
21
|
+
|
|
22
|
+
const DOWNLOADS_API = 'https://api.npmjs.org/downloads/range'
|
|
23
|
+
// What npm's range API may answer for each package asked: a year of days is
|
|
24
|
+
// some 16 KiB.
|
|
25
|
+
const DOWNLOADS_BYTES = 64 * 1024
|
|
26
|
+
// Socket's answer for a package version, by its purl, as Socket Firewall
|
|
27
|
+
// asks it: no key needed. Its scores are cut to two decimals; its badge's,
|
|
28
|
+
// the supply chain score, is rounded (askSocketBadge).
|
|
29
|
+
const SOCKET_API = 'https://firewall-api.socket.dev/purl'
|
|
30
|
+
const SOCKET_BADGE = 'https://badge.socket.dev/npm/package'
|
|
31
|
+
const BADGE_BYTES = 16 * 1024
|
|
32
|
+
const API_TIMEOUT_MS = 30_000
|
|
33
|
+
const API_BYTES = 1024 * 1024
|
|
34
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
35
|
+
const SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, moderate: 2, low: 3 }
|
|
36
|
+
|
|
37
|
+
const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
|
|
38
|
+
const isDay = (value: unknown): value is string => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value) && !Number.isNaN(Date.parse(value))
|
|
39
|
+
|
|
40
|
+
// A package's downloads, one count a day from `start` to `end` (UTC dates),
|
|
41
|
+
// none for a day npm leaves out; null where npm has none for it.
|
|
42
|
+
export interface NpmDownloads { start: string; end: string; days: number[] }
|
|
43
|
+
|
|
44
|
+
const isoDay = (time: number) => new Date(time).toISOString().slice(0, 10)
|
|
45
|
+
|
|
46
|
+
// Its last year, as npm counts it (to the last day it has counted), from
|
|
47
|
+
// the first of the month that year starts in, so that month is whole too.
|
|
48
|
+
async function askDownloads(name: string): Promise<NpmDownloads | null> {
|
|
49
|
+
const year = await askDownloadRange('last-year', name)
|
|
50
|
+
const start = new Date(year?.start ?? 0)
|
|
51
|
+
if (year === null || start.getUTCDate() === 1) return year
|
|
52
|
+
const first = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)
|
|
53
|
+
const before = await askDownloadRange(`${isoDay(first)}:${isoDay(start.getTime() - DAY_MS)}`, name).catch(() => null)
|
|
54
|
+
return before?.start === isoDay(first) && before.days.length === (start.getTime() - first) / DAY_MS
|
|
55
|
+
? { start: before.start, end: year.end, days: [...before.days, ...year.days] } : year
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// npm's downloads of `name` over `range`: null where npm has none.
|
|
59
|
+
async function askDownloadRange(range: string, name: string): Promise<NpmDownloads | null> {
|
|
60
|
+
const json = await askUpstreamJson(`${DOWNLOADS_API}/${range}/${name.replace('/', '%2f')}`, { headers: { accept: 'application/json' }, limit: DOWNLOADS_BYTES })
|
|
61
|
+
return json === null ? null : readDownloads(json)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One package's downloads as npm's range API gives them.
|
|
65
|
+
function readDownloads(json: unknown): NpmDownloads {
|
|
66
|
+
if (!plainObject(json) || !isDay(json['start']) || !isDay(json['end']) || !Array.isArray(json['downloads'])) throw new NpmPackageError(502, 'upstream-invalid')
|
|
67
|
+
const start = Date.parse(json['start'])
|
|
68
|
+
const length = (Date.parse(json['end']) - start) / DAY_MS + 1
|
|
69
|
+
if (!Number.isSafeInteger(length) || length < 1 || length > 400) throw new NpmPackageError(502, 'upstream-invalid')
|
|
70
|
+
const days = Array.from({ length }, () => 0)
|
|
71
|
+
for (const row of json['downloads']) {
|
|
72
|
+
if (!plainObject(row) || !isDay(row['day'])) continue
|
|
73
|
+
const at = (Date.parse(row['day']) - start) / DAY_MS
|
|
74
|
+
if (Number.isSafeInteger(at) && at >= 0 && at < length) days[at] = count(row['downloads'])
|
|
75
|
+
}
|
|
76
|
+
return { start: json['start'], end: json['end'], days }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const downloads = kept<NpmDownloads | null>(500)
|
|
80
|
+
// The repository each package's latest version names (owner/name, null where
|
|
81
|
+
// it names none), as its figures were first asked with within the hour: what
|
|
82
|
+
// npmKnown finds them by.
|
|
83
|
+
const latestRepos = kept<string | null>(500)
|
|
84
|
+
|
|
85
|
+
export const npmLatestRepo = (name: string, repo: string | null): void => void latestRepos(name, () => Promise.resolve(repo))
|
|
86
|
+
|
|
87
|
+
export function npmDownloads(name: string): Promise<NpmDownloads | null> {
|
|
88
|
+
return downloads(name, () => askDownloads(name))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const SOCKET_SCORES = ['overall', 'supplyChain', 'vulnerability', 'quality', 'maintenance', 'license'] as const
|
|
92
|
+
const SOCKET_SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, middle: 2, low: 3 }
|
|
93
|
+
const SOCKET_ALERTS = 50
|
|
94
|
+
const SOCKET_NOTE_CHARS = 4000
|
|
95
|
+
|
|
96
|
+
// Socket's report on a version: its scores, each 0 to 1 (null where Socket
|
|
97
|
+
// gave none), and the alerts it raises, such as malware, most severe first,
|
|
98
|
+
// each with the file it was found in, by its path in the package (the
|
|
99
|
+
// tarball's root left out, as the package's files have it), where it names
|
|
100
|
+
// one, and Socket's note on it.
|
|
101
|
+
export interface NpmSocketAlert { type: string; severity: string; category: string | null; file: string | null; note: string | null }
|
|
102
|
+
export interface NpmSocketReport { scores: Record<typeof SOCKET_SCORES[number], number> | null; alerts: NpmSocketAlert[] }
|
|
103
|
+
|
|
104
|
+
const text = (value: unknown) => typeof value === 'string' && value !== '' ? value : null
|
|
105
|
+
|
|
106
|
+
// The supply chain score, out of 100, that the version's badge shows; null
|
|
107
|
+
// where it shows none, and undefined where it can't be had, as while Socket
|
|
108
|
+
// limits how often it's asked.
|
|
109
|
+
async function askSocketBadge(name: string, version: string): Promise<number | null | undefined> {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`${SOCKET_BADGE}/${name}/${encodeURIComponent(version)}`,
|
|
112
|
+
{ headers: { accept: 'image/svg+xml' }, redirect: 'error', signal: AbortSignal.timeout(API_TIMEOUT_MS) })
|
|
113
|
+
if (!res.ok) { await res.body?.cancel(); return res.status === 429 || res.status >= 500 ? undefined : null }
|
|
114
|
+
const shown = /<title>Socket: (\d{1,3})<\/title>/u.exec((await readLimited(res, BADGE_BYTES)).toString('utf8'))
|
|
115
|
+
return shown ? Number(shown[1]) : null
|
|
116
|
+
} catch { return undefined }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Scores the badge couldn't round: Socket's own, a point low at most, their
|
|
120
|
+
// report answered but not kept, so the next view asks again.
|
|
121
|
+
const unbadged = new WeakSet<object>()
|
|
122
|
+
|
|
123
|
+
async function askSocket(name: string, version: string): Promise<NpmSocketReport | null> {
|
|
124
|
+
const badge = askSocketBadge(name, version)
|
|
125
|
+
// One line, as one purl was asked.
|
|
126
|
+
const json = await askUpstreamJson(`${SOCKET_API}/${encodeURIComponent(`pkg:npm/${name}@${version}`)}`,
|
|
127
|
+
{ headers: { accept: 'application/x-ndjson' }, limit: API_BYTES }, body => JSON.parse(body.split('\n', 1)[0]!))
|
|
128
|
+
if (json === null) return null
|
|
129
|
+
if (!plainObject(json) || json['version'] !== version || [json['namespace'], json['name']].filter(Boolean).join('/') !== name) throw new NpmPackageError(502, 'upstream-invalid')
|
|
130
|
+
// A version Socket hasn't seen is answered with a made-up id and nothing else.
|
|
131
|
+
if (String(json['id']).startsWith('synthetic:')) return null
|
|
132
|
+
const score = json['score']
|
|
133
|
+
const scores = plainObject(score) && SOCKET_SCORES.every(key => typeof score[key] === 'number' && score[key] >= 0 && score[key] <= 1)
|
|
134
|
+
? Object.fromEntries(SOCKET_SCORES.map(key => [key, score[key]])) as NpmSocketReport['scores'] : null
|
|
135
|
+
// The supply chain score as the badge rounds it, at most one more than the
|
|
136
|
+
// score cut to two decimals; lower where the badge is, as when the score
|
|
137
|
+
// has fallen since.
|
|
138
|
+
const shown = await badge
|
|
139
|
+
if (scores && typeof shown === 'number') scores.supplyChain = Math.min(Math.round(scores.supplyChain * 100) + 1, shown) / 100
|
|
140
|
+
else if (scores && shown === undefined) unbadged.add(scores)
|
|
141
|
+
const alerts = (Array.isArray(json['alerts']) ? json['alerts'] : [])
|
|
142
|
+
.filter((alert): alert is Record<string, unknown> => plainObject(alert) && text(alert['type']) !== null && typeof alert['severity'] === 'string' && alert['severity'] in SOCKET_SEVERITY_RANK)
|
|
143
|
+
.slice(0, SOCKET_ALERTS)
|
|
144
|
+
.map(alert => ({
|
|
145
|
+
type: alert['type'] as string, severity: alert['severity'] as string, category: text(alert['category']),
|
|
146
|
+
file: text(text(alert['file'])?.replace(/^[^/]*\//u, '')),
|
|
147
|
+
note: text(plainObject(alert['props']) ? alert['props']['note'] : null)?.slice(0, SOCKET_NOTE_CHARS) ?? null,
|
|
148
|
+
}))
|
|
149
|
+
.toSorted((a, b) => SOCKET_SEVERITY_RANK[a.severity]! - SOCKET_SEVERITY_RANK[b.severity]!)
|
|
150
|
+
return scores === null && alerts.length === 0 ? null : { scores, alerts }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const socketReports = kept<NpmSocketReport | null>(1000, report => !report?.scores || !unbadged.has(report.scores))
|
|
154
|
+
|
|
155
|
+
// Socket's report on a version of a public package; null where Socket has
|
|
156
|
+
// none. Its name goes to Socket: never ask it about a private one.
|
|
157
|
+
export function npmSocketReport(name: string, version: string): Promise<NpmSocketReport | null> {
|
|
158
|
+
return socketReports(`${name}@${version}`, () => askSocket(name, version))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A public repository's figures: GitHub's `openIssues` takes in its open pull
|
|
162
|
+
// requests, which `openPulls` counts on their own, null where GitHub didn't
|
|
163
|
+
// say.
|
|
164
|
+
export interface NpmGithubStats {
|
|
165
|
+
repo: string; stars: number; forks: number; openIssues: number; openPulls: number | null; archived: boolean; pushedAt: string | null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// How many pull requests a repository has open: the last page of their list,
|
|
169
|
+
// one a page, as its Link header names it, or the one page's length.
|
|
170
|
+
async function countOpenPulls(repo: string, token: string | null): Promise<number | null> {
|
|
171
|
+
const path = repo.split('/').map(encodeURIComponent).join('/')
|
|
172
|
+
let res: Response
|
|
173
|
+
try {
|
|
174
|
+
res = await fetch(`https://api.github.com/repos/${path}/pulls?state=open&per_page=1`, {
|
|
175
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'deepview-triage', ...token && { authorization: `Bearer ${token}` } },
|
|
176
|
+
redirect: 'error', signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
177
|
+
})
|
|
178
|
+
} catch { return null }
|
|
179
|
+
if (!res.ok) { await res.body?.cancel(); return null }
|
|
180
|
+
const last = /[?&]page=(\d+)>;\s*rel="last"/u.exec(res.headers.get('link') ?? '')?.[1]
|
|
181
|
+
if (last !== undefined) { await res.body?.cancel(); return Number(last) }
|
|
182
|
+
try {
|
|
183
|
+
const json = JSON.parse((await readLimited(res, API_BYTES)).toString('utf8')) as unknown
|
|
184
|
+
return Array.isArray(json) ? json.length : null
|
|
185
|
+
} catch { return null }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// GitHub has no repository to show: none by that name, or a redirect upstream
|
|
189
|
+
// won't follow, anywhere but to the repository by its id, or a second.
|
|
190
|
+
const NONE = new Set([301, 302, 307, 308, 404])
|
|
191
|
+
|
|
192
|
+
// What GitHub answers for `repo`, or where it was renamed or transferred, for
|
|
193
|
+
// the repository it is now (its `full_name`), where it says it is public,
|
|
194
|
+
// with the token it was asked with: `token`, or none where that is revoked.
|
|
195
|
+
// Upstream follows GitHub's redirect for such a repository to it by its id
|
|
196
|
+
// (`followRedirect`), once, with the same token. Null where GitHub has no such
|
|
197
|
+
// repository to show, or it is not public; anything else fails, to be asked
|
|
198
|
+
// again next time. Public is `visibility: 'public'`, as github-app.ts
|
|
199
|
+
// requires: an internal repository answers `private: false`.
|
|
200
|
+
async function publicRepo(repo: string, token: string | null): Promise<{ json: Record<string, unknown> & { full_name: string }; auth: string | null } | null> {
|
|
201
|
+
const ask = (auth: string | null) => createClient({ token: auth, userAgent: 'deepview-triage' }).getRepo({ repo, followRedirect: true }) as Promise<unknown>
|
|
202
|
+
let auth = token, json: unknown
|
|
203
|
+
try { json = await ask(token) }
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!(error instanceof HttpError) || !(error.status === 401 || NONE.has(error.status))) throw error
|
|
206
|
+
if (error.status !== 401 || token === null) return null
|
|
207
|
+
auth = null
|
|
208
|
+
try { json = await ask(null) }
|
|
209
|
+
catch (retry) { if (retry instanceof HttpError && NONE.has(retry.status)) return null; throw retry }
|
|
210
|
+
}
|
|
211
|
+
if (!plainObject(json) || json['private'] !== false || json['visibility'] !== 'public' || typeof json['full_name'] !== 'string') return null
|
|
212
|
+
return { json: json as Record<string, unknown> & { full_name: string }, auth }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function askGithub(repo: string, token: string | null): Promise<NpmGithubStats | null> {
|
|
216
|
+
const found = await publicRepo(repo, token)
|
|
217
|
+
if (found === null) return null
|
|
218
|
+
const { json, auth } = found
|
|
219
|
+
return {
|
|
220
|
+
repo: json['full_name'], stars: count(json['stargazers_count']), forks: count(json['forks_count']), openIssues: count(json['open_issues_count']),
|
|
221
|
+
openPulls: await countOpenPulls(json['full_name'], auth),
|
|
222
|
+
archived: json['archived'] === true, pushedAt: typeof json['pushed_at'] === 'string' ? json['pushed_at'] : null,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const repositories = kept<NpmGithubStats | null>(1000)
|
|
227
|
+
|
|
228
|
+
// `repo` as a package's manifest names it (owner/name); `githubToken` the
|
|
229
|
+
// reader's own, where they have one, for GitHub's rate limits, never for what
|
|
230
|
+
// it shows, asked for only where the figures aren't kept.
|
|
231
|
+
export function npmGithubStats(repo: string, githubToken: () => Promise<string | null>): Promise<NpmGithubStats | null> {
|
|
232
|
+
return repositories(repo.toLowerCase(), async () => askGithub(repo, await githubToken()))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const GITHUB_GRAPHQL = 'https://api.github.com/graphql'
|
|
236
|
+
// A ref's target is a commit, or for an annotated tag a tag object, which may
|
|
237
|
+
// itself name another tag before the commit.
|
|
238
|
+
const TAGS_QUERY = `query($owner: String!, $name: String!, $query: String!) {
|
|
239
|
+
repository(owner: $owner, name: $name) {
|
|
240
|
+
visibility
|
|
241
|
+
refs(refPrefix: "refs/tags/", query: $query, first: 100) {
|
|
242
|
+
nodes { name target { __typename oid ... on Tag { target { __typename oid ... on Tag { target { __typename oid } } } } } }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}`
|
|
246
|
+
|
|
247
|
+
function taggedCommit(target: unknown): unknown {
|
|
248
|
+
let at = target
|
|
249
|
+
for (let depth = 0; depth < 3 && plainObject(at) && at['__typename'] === 'Tag'; depth++) at = at['target']
|
|
250
|
+
return plainObject(at) && at['__typename'] === 'Commit' ? at['oid'] : null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function askTags(repo: string, sha: string, version: string, token: string): Promise<string[]> {
|
|
254
|
+
const [owner, name] = repo.split('/')
|
|
255
|
+
const json = await askUpstreamJson(GITHUB_GRAPHQL, {
|
|
256
|
+
method: 'POST', limit: API_BYTES, absent: [],
|
|
257
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'user-agent': 'deepview-triage' },
|
|
258
|
+
body: JSON.stringify({ query: TAGS_QUERY, variables: { owner, name, query: version } }),
|
|
259
|
+
})
|
|
260
|
+
const data = plainObject(json) ? json['data'] : null
|
|
261
|
+
const repository = plainObject(data) ? data['repository'] : null
|
|
262
|
+
// A repository gone, or not public (private, or internal to an enterprise),
|
|
263
|
+
// has none to show anyone.
|
|
264
|
+
if (!plainObject(repository) || repository['visibility'] !== 'PUBLIC') return []
|
|
265
|
+
const refs = repository['refs']
|
|
266
|
+
const nodes = plainObject(refs) && Array.isArray(refs['nodes']) ? refs['nodes'] as unknown[] : []
|
|
267
|
+
return nodes.filter((node): node is { name: string; target: unknown } => plainObject(node) && typeof node['name'] === 'string' && node['name'] !== '')
|
|
268
|
+
.filter(node => taggedCommit(node.target) === sha).map(node => node.name).toSorted()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Null where no token asked: nothing to keep for a reader who has one.
|
|
272
|
+
const commitTags = kept<string[] | null>(500, tags => tags !== null)
|
|
273
|
+
|
|
274
|
+
// The tags of a public repository (owner/name) that point to `sha`, a
|
|
275
|
+
// version's publish commit, among those naming its `version` (`v1.2.3`,
|
|
276
|
+
// `pkg@1.2.3`, …), as GitHub's GraphQL API finds them by name. GraphQL needs
|
|
277
|
+
// a token: the reader's own (`githubToken`, asked for only where the tags
|
|
278
|
+
// aren't kept), where they have one; without, none are asked.
|
|
279
|
+
export async function npmCommitTags(repo: string, sha: string, version: string, githubToken: () => Promise<string | null>): Promise<string[]> {
|
|
280
|
+
return await commitTags(`${repo.toLowerCase()}\0${sha}\0${version}`, async () => {
|
|
281
|
+
const token = await githubToken()
|
|
282
|
+
return token === null ? null : askTags(repo, sha, version, token)
|
|
283
|
+
}) ?? []
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// An advisory on the package, with the versions it covers as their indexes in
|
|
287
|
+
// the list it was asked with: npm's (`registry`), or one its repository
|
|
288
|
+
// publishes on GitHub before GitHub reviews it into npm's (`repository`); its
|
|
289
|
+
// text (markdown) where its source has one.
|
|
290
|
+
export interface NpmAdvisory {
|
|
291
|
+
id: string; source: Advisory['source']; ghsa?: string; url?: string; title?: string; severity?: string; cvss?: number; cwe: string[]; range?: string
|
|
292
|
+
details?: string; affected: number[]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// `versions` those `affected` indexes; `repository` false where the
|
|
296
|
+
// repository's advisories could not be had (GitHub refusing, its rate limit
|
|
297
|
+
// spent), leaving npm's alone; `keptAt`, where GitHub couldn't give them but
|
|
298
|
+
// their listing was kept before, however long ago, when it was.
|
|
299
|
+
export interface NpmAdvisoryList { versions: string[]; advisories: NpmAdvisory[]; repository: boolean; keptAt?: string }
|
|
300
|
+
|
|
301
|
+
export interface NpmAdvisoryOptions {
|
|
302
|
+
// The reader's GitHub token, and the repository the package names (owner/
|
|
303
|
+
// name, null where it names none), each asked for only where the list is
|
|
304
|
+
// not kept.
|
|
305
|
+
githubToken: () => Promise<string | null>
|
|
306
|
+
repo: () => Promise<string | null>
|
|
307
|
+
// Where repositories' listings are kept, as bundle advisories keep them
|
|
308
|
+
// (upstream-cache.ts auditCache), for as long as `signal` asks; and a
|
|
309
|
+
// repository's as last kept there, however old (lastListing).
|
|
310
|
+
cache: (signal: AbortSignal) => CacheStore | undefined
|
|
311
|
+
lastListing: (repo: string) => Promise<{ at: number; cache: CacheStore } | null>
|
|
312
|
+
debug?: boolean
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function askAdvisories(name: string, versions: string[], { githubToken, repo, cache, lastListing, debug = false }: NpmAdvisoryOptions): Promise<NpmAdvisoryList> {
|
|
316
|
+
// Every version upstream takes, as it checks each one it is asked about.
|
|
317
|
+
const asked = versions.filter(version => isExactVersion(version))
|
|
318
|
+
if (asked.length === 0) return { versions, advisories: [], repository: true }
|
|
319
|
+
// Its repository's own advisories are asked for only where GitHub says, now
|
|
320
|
+
// and not from an answer kept, that it is public, and that repository is the
|
|
321
|
+
// one asked, by the name GitHub gives it now where it was renamed or
|
|
322
|
+
// transferred: the list is kept for every reader of the package, and the
|
|
323
|
+
// reader's token could read a private repository's, or a listing of one
|
|
324
|
+
// kept for another reader.
|
|
325
|
+
let github: string | null = null, named: string | null = null, repository = true
|
|
326
|
+
try {
|
|
327
|
+
named = await repo()
|
|
328
|
+
github = named === null ? null : (await publicRepo(named, await githubToken()))?.json.full_name ?? null
|
|
329
|
+
} catch { repository = false }
|
|
330
|
+
// With the advisories `listed` publishes, where it isn't null, GitHub asked
|
|
331
|
+
// with the reader's token or by a client of the caller's.
|
|
332
|
+
const ask = (listed: string | null, store: (signal: AbortSignal) => CacheStore | undefined, client: { githubToken: string | null } | { github: Client }) => {
|
|
333
|
+
const signal = AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)
|
|
334
|
+
const packages = [{ ecosystem: 'npm' as const, name, versions: asked, ...listed !== null && { github: listed } }]
|
|
335
|
+
return fetchBundleAdvisories(packages, signal, { debug, repoAdvisories: listed !== null, details: true, ...client, cache: store(signal) })
|
|
336
|
+
}
|
|
337
|
+
let result = repository ? await ask(github, cache, { githubToken: github === null ? null : await githubToken() }) : null
|
|
338
|
+
// Where GitHub can't say whether it is public, or refuses its listing, its
|
|
339
|
+
// listing as last kept is taken instead, however old (lastListing), as
|
|
340
|
+
// audits take a kept listing for any reader: only public repositories
|
|
341
|
+
// publish advisories (upstream-cache.ts). GitHub is asked nothing for it:
|
|
342
|
+
// upstream asks for a listing only once npm has answered, and where it
|
|
343
|
+
// won't take the kept one, which leaves npm's alone, as where none is kept.
|
|
344
|
+
// An audit failing otherwise is npm's, not asked a third time. Neither list
|
|
345
|
+
// is kept, so GitHub is asked again next time.
|
|
346
|
+
let keptAt: string | undefined
|
|
347
|
+
if (result === null || (result.status !== 200 && github !== null)) {
|
|
348
|
+
const unreached = github ?? named
|
|
349
|
+
const last = unreached === null ? null : await lastListing(unreached)
|
|
350
|
+
let untaken = false
|
|
351
|
+
const offline: Client = { ...createClient({ token: null, userAgent: 'deepview-triage' }),
|
|
352
|
+
listRepoAdvisories: () => { untaken = true; return Promise.reject(new Error('GitHub can\'t be reached')) } }
|
|
353
|
+
const listed = last && await ask(unreached, () => last.cache, { github: offline })
|
|
354
|
+
if (last && listed?.status === 200) {
|
|
355
|
+
result = listed
|
|
356
|
+
repository = true
|
|
357
|
+
keptAt = new Date(last.at).toISOString()
|
|
358
|
+
} else {
|
|
359
|
+
result = listed && !untaken ? listed : await ask(null, cache, { githubToken: null })
|
|
360
|
+
repository = false
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (result.status !== 200) throw new NpmPackageError(502, 'upstream-unavailable')
|
|
364
|
+
const index = new Map(versions.map((version, i) => [version, i]))
|
|
365
|
+
// npm's registry answers an advisory once a range it covers: here it is one
|
|
366
|
+
// row, its ranges joined as semver joins them, the versions and CWEs they
|
|
367
|
+
// cover together, and the highest score.
|
|
368
|
+
const merged = new Map<string, NpmAdvisory>()
|
|
369
|
+
for (const row of result.body) {
|
|
370
|
+
const affected = row.versions.map(version => index.get(version)).filter(at => at !== undefined)
|
|
371
|
+
const key = `${row.source}\0${row.id}`
|
|
372
|
+
const known = merged.get(key)
|
|
373
|
+
if (!known) {
|
|
374
|
+
merged.set(key, {
|
|
375
|
+
id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
|
|
376
|
+
...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
|
|
377
|
+
...row.details && { details: row.details }, affected,
|
|
378
|
+
})
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
const range = [known.range, row.range].filter(Boolean).join(' || ')
|
|
382
|
+
const cvss = Math.max(known.cvss ?? -1, row.cvss ?? -1)
|
|
383
|
+
merged.set(key, { ...known, ...range && { range }, ...cvss >= 0 && { cvss }, cwe: [...new Set([...known.cwe, ...row.cwe])], affected: [...known.affected, ...affected] })
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
versions, repository, ...keptAt && { keptAt },
|
|
387
|
+
advisories: [...merged.values()].map(row => ({ ...row, affected: [...new Set(row.affected)].toSorted((a, b) => a - b) })).filter(row => row.affected.length > 0)
|
|
388
|
+
.toSorted((a, b) => (SEVERITY_RANK[a.severity ?? ''] ?? 4) - (SEVERITY_RANK[b.severity ?? ''] ?? 4) || a.id.localeCompare(b.id)),
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const ADVISORY_LISTS = 100
|
|
393
|
+
const advisoryLists = kept<NpmAdvisoryList>(ADVISORY_LISTS, list => list.repository && !list.keptAt)
|
|
394
|
+
// What each package's list was last asked by, for npmKnown.
|
|
395
|
+
const advisoryKeys = new Map<string, string>()
|
|
396
|
+
|
|
397
|
+
// The package's advisories, `versions` its published versions as the version
|
|
398
|
+
// list has them; asked again once a version is published, and a list without
|
|
399
|
+
// its repository's, next time.
|
|
400
|
+
export function npmAdvisories(name: string, versions: string[], options: NpmAdvisoryOptions): Promise<NpmAdvisoryList> {
|
|
401
|
+
const key = `${name}\0${versions.length}\0${versions[0] ?? ''}`
|
|
402
|
+
advisoryKeys.delete(name)
|
|
403
|
+
advisoryKeys.set(name, key)
|
|
404
|
+
while (advisoryKeys.size > ADVISORY_LISTS) advisoryKeys.delete(advisoryKeys.keys().next().value!)
|
|
405
|
+
return advisoryLists(key, () => askAdvisories(name, versions, options))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// What is kept about a version's package, each part as its own route answers
|
|
409
|
+
// it (http.ts handleNpm), for the version's response to carry: its figures,
|
|
410
|
+
// where those of the repository its latest version names are kept too; its
|
|
411
|
+
// advisories, where kept for a version list that has this version; the tags
|
|
412
|
+
// of its publish commit (`sha`, in `repo`, the repository it names);
|
|
413
|
+
// Socket's report on it; and npm's provenance for it (npm-provenance.ts).
|
|
414
|
+
// Nothing is asked for: what isn't kept is left out, for the viewer to ask
|
|
415
|
+
// for. A private package's name never leaves for Socket, so its report is
|
|
416
|
+
// none, nor is it asked for provenance.
|
|
417
|
+
export interface NpmKnown {
|
|
418
|
+
stats?: { name: string; downloads: NpmDownloads | null; github: NpmGithubStats | null }
|
|
419
|
+
advisories?: { name: string } & NpmAdvisoryList
|
|
420
|
+
tags?: { name: string; version: string; tags: string[] }
|
|
421
|
+
socket?: { name: string; version: string; socket: NpmSocketReport | null }
|
|
422
|
+
provenance?: { name: string; version: string; provenance: NpmProvenance | null }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function npmKnown(name: string, version: string, isPrivate: boolean, repo: string | null, sha: string | null): NpmKnown {
|
|
426
|
+
const known: NpmKnown = {}
|
|
427
|
+
const days = downloads.peek(name), latest = latestRepos.peek(name)
|
|
428
|
+
const github = latest === null ? null : latest === undefined ? undefined : repositories.peek(latest.toLowerCase())
|
|
429
|
+
if (days !== undefined && github !== undefined) known.stats = { name, downloads: days, github }
|
|
430
|
+
const key = advisoryKeys.get(name)
|
|
431
|
+
const list = key === undefined ? undefined : advisoryLists.peek(key)
|
|
432
|
+
if (list?.versions.includes(version)) known.advisories = { name, ...list }
|
|
433
|
+
const tags = repo === null || sha === null ? [] : commitTags.peek(`${repo.toLowerCase()}\0${sha}\0${version}`)
|
|
434
|
+
if (tags) known.tags = { name, version, tags }
|
|
435
|
+
const socket = isPrivate ? null : socketReports.peek(`${name}@${version}`)
|
|
436
|
+
if (socket !== undefined) known.socket = { name, version, socket }
|
|
437
|
+
const provenance = isPrivate ? null : keptNpmProvenance(name, version)
|
|
438
|
+
if (provenance !== undefined) known.provenance = { name, version, provenance }
|
|
439
|
+
return known
|
|
440
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// What the npm viewer and repository risk scans keep in the database
|
|
2
|
+
// (managed_upstream_cache, upstream-cache.ts), beyond the instance, so that
|
|
3
|
+
// neither asks for it again on every read, nor after a restart or on another
|
|
4
|
+
// instance: public versions' documents, of the fields read of them
|
|
5
|
+
// (npm-manifest.ts npmVersionFields), for a day (readNpmVersion asks npm again
|
|
6
|
+
// then, as a version can be deprecated, its package made private, or itself
|
|
7
|
+
// removed); and, for scans, each package's risk line, for as long as a scan
|
|
8
|
+
// keeps it (repository-risk.ts). Each store removes its own rows kept longer
|
|
9
|
+
// than that as it writes, at most once an hour a database, so that rows no
|
|
10
|
+
// read will take again don't pile up.
|
|
11
|
+
// The database is an optimization: a failure reads as a miss, and writes
|
|
12
|
+
// nothing.
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { plainObject } from './npm-manifest.ts'
|
|
15
|
+
import { type NpmVersionStore, VERSION_KEPT_MS } from './npm-packages.ts'
|
|
16
|
+
import type { UpstreamCacheStore } from './upstream-cache.ts'
|
|
17
|
+
|
|
18
|
+
// A value kept by key, as JSON, `at` when it was.
|
|
19
|
+
export interface KeptStore<T> {
|
|
20
|
+
read(key: string): Promise<{ at: number; value: T } | null>
|
|
21
|
+
write(key: string, value: T, at: number): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// When each database last had each type's old rows removed.
|
|
25
|
+
const PRUNE_EVERY_MS = 60 * 60_000
|
|
26
|
+
const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
|
|
27
|
+
|
|
28
|
+
// Values of `type`, each read for `keptMs` after it was kept.
|
|
29
|
+
function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debug: boolean): KeptStore<T> {
|
|
30
|
+
const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: kept ${type} ${what} failed:`, error) }
|
|
31
|
+
const prune = async (at: number) => {
|
|
32
|
+
const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
|
|
33
|
+
if (at - (times.get(type) ?? -Infinity) < PRUNE_EVERY_MS) return
|
|
34
|
+
times.set(type, at)
|
|
35
|
+
try { await db.deleteUpstreamCacheEntries(`${type}/`, at - keptMs) } catch (error) { warn('prune', error) }
|
|
36
|
+
}
|
|
37
|
+
// Keys of any length or character, as the database's: hashed.
|
|
38
|
+
const keyOf = (key: string) => `${type}/${createHash('sha256').update(key).digest('hex')}`
|
|
39
|
+
return {
|
|
40
|
+
async read(key) {
|
|
41
|
+
try {
|
|
42
|
+
const kept: unknown = JSON.parse(await db.getUpstreamCacheEntry(keyOf(key)) ?? 'null')
|
|
43
|
+
return plainObject(kept) && typeof kept['at'] === 'number' && 'value' in kept ? { at: kept['at'], value: kept['value'] as T } : null
|
|
44
|
+
} catch (error) { warn('read', error); return null }
|
|
45
|
+
},
|
|
46
|
+
async write(key, value, at) {
|
|
47
|
+
try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
|
|
48
|
+
await prune(at)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersionStore {
|
|
54
|
+
const kept = keptStore<Record<string, unknown>>(db, 'npm/version-documents', VERSION_KEPT_MS, debug)
|
|
55
|
+
return {
|
|
56
|
+
async read(name, version) {
|
|
57
|
+
const hit = await kept.read(`${name}@${version}`)
|
|
58
|
+
return hit && plainObject(hit.value) ? { at: hit.at, json: hit.value } : null
|
|
59
|
+
},
|
|
60
|
+
write: (name, version, json) => kept.write(`${name}@${version}`, json, Date.now()),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Risk lines, each read for `keptMs`, as long as a scan keeps them.
|
|
65
|
+
export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
|