@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,254 @@
|
|
|
1
|
+
// Managed client pages only. API paths and E2E share hashes are not routes.
|
|
2
|
+
import { BUNDLE_TABS } from '../bundle-tabs.js'
|
|
3
|
+
import { MAX_FINDING_ID_LENGTH, isLinkableFindingId } from '../finding-id.js'
|
|
4
|
+
import { isNpmPackageName, isNpmPackageSpec } from './npm-packages.js'
|
|
5
|
+
|
|
6
|
+
// How Compare reviews the changes, past its Overview: `code`, beside the
|
|
7
|
+
// tree of changed files. A link names the mode after the bundle or version
|
|
8
|
+
// compared with; an older one's `diff`, a mode since folded into Code,
|
|
9
|
+
// opens Code.
|
|
10
|
+
const COMPARE_MODES = new Set(['code', 'diff'])
|
|
11
|
+
export const compareModeOf = mode => COMPARE_MODES.has(mode) ? 'code' : 'overview'
|
|
12
|
+
// A route's `compareMode` field, where `mode` is one; none for the Overview.
|
|
13
|
+
export const compareModeField = mode => COMPARE_MODES.has(mode) ? { compareMode: 'code' } : {}
|
|
14
|
+
const compareModeSuffix = mode => COMPARE_MODES.has(mode) ? '/code' : ''
|
|
15
|
+
|
|
16
|
+
export const MANAGED_PAGES = Object.freeze({
|
|
17
|
+
manage: '/manage',
|
|
18
|
+
'manage-bundles': '/manage/bundle',
|
|
19
|
+
'manage-scans': '/manage/scans',
|
|
20
|
+
'manage-reports': '/manage/report',
|
|
21
|
+
'manage-repos': '/manage/repositories',
|
|
22
|
+
'manage-repo-risk': '/manage/repositories/risk',
|
|
23
|
+
'admin-users': '/manage/users',
|
|
24
|
+
'manage-teams': '/manage/team',
|
|
25
|
+
'manage-import': '/manage/import',
|
|
26
|
+
'manage-links': '/manage/links',
|
|
27
|
+
'manage-deduplication': '/manage/deduplication',
|
|
28
|
+
'manage-history': '/manage/history',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Dot-only path components are normalised away by URL parsers, even escaped.
|
|
32
|
+
function findingPath(id) {
|
|
33
|
+
if (!isLinkableFindingId(id) || id === '.' || id === '..') return null
|
|
34
|
+
try { return `/finding/${encodeURIComponent(id)}` } catch { return null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function managedRoutePath(route) {
|
|
38
|
+
if (!route) return null
|
|
39
|
+
if (route.view === 'home') return '/'
|
|
40
|
+
if (route.view === 'reviews') return '/my/reviews'
|
|
41
|
+
if (route.view === 'npm') return npmPackagePath(route)
|
|
42
|
+
if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
|
|
43
|
+
return /^[A-Za-z0-9_-]+$/u.test(route.teamSlug ?? '') ? `/team/${route.teamSlug}/${route.view.slice(10)}` : null
|
|
44
|
+
}
|
|
45
|
+
if (route.view === 'bundles') {
|
|
46
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
|
|
47
|
+
|| [route.teamSlug, route.compareSlug].some(slug => slug != null && !/^[A-Za-z0-9_-]+$/u.test(slug))) return null
|
|
48
|
+
const tab = route.bundleTab ?? 'overview'
|
|
49
|
+
if (!BUNDLE_TABS.has(tab)) return null
|
|
50
|
+
const parent = route.teamSlug ? `/team/${route.teamSlug}` : '/manage'
|
|
51
|
+
// Code names its open file by number: 1-based in the bundle's sorted
|
|
52
|
+
// sources, which its content hash fixes. Never by path from the bundle.
|
|
53
|
+
// Its marked lines follow in the fragment, as #L42 or #L42-L69.
|
|
54
|
+
const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
|
|
55
|
+
const lines = file && isLineNumber(route.line)
|
|
56
|
+
? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
|
|
57
|
+
// Compare names the bundle compared with by its slug, then its mode
|
|
58
|
+
// (COMPARE_MODES) past the Overview.
|
|
59
|
+
const compare = tab === 'compare' && route.compareSlug != null
|
|
60
|
+
? `/${route.compareSlug}${compareModeSuffix(route.compareMode)}` : ''
|
|
61
|
+
return `${parent}/bundle/${route.bundleSlug}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
|
|
62
|
+
}
|
|
63
|
+
if (Object.hasOwn(MANAGED_PAGES, route.view)) {
|
|
64
|
+
const path = MANAGED_PAGES[route.view]
|
|
65
|
+
if (route.view === 'manage-deduplication' && route.linkId) return `${path}?report=${encodeURIComponent(route.linkId)}`
|
|
66
|
+
if (route.view === 'manage-bundles' && Number.isSafeInteger(route.createRepoId) && route.createRepoId > 0) return `${path}?createRepo=${route.createRepoId}`
|
|
67
|
+
if (route.view === 'manage-scans' && route.scanMode === 'link') return `${path}?mode=link`
|
|
68
|
+
if (route.view === 'manage-scans' && route.bundleId) {
|
|
69
|
+
return `${path}?bundle=${encodeURIComponent(route.bundleId)}${route.scanMode === 'dependencies' ? '&mode=dependencies' : ''}`
|
|
70
|
+
}
|
|
71
|
+
return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
|
|
72
|
+
}
|
|
73
|
+
if (!['findings', 'files'].includes(route.view) || !route.teamSlug
|
|
74
|
+
|| [route.teamSlug, route.reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
|
|
75
|
+
const team = `/team/${encodeURIComponent(route.teamSlug)}`
|
|
76
|
+
const report = route.reportSlug ? `/report/${encodeURIComponent(route.reportSlug)}` : ''
|
|
77
|
+
if (route.finding) {
|
|
78
|
+
const suffix = findingPath(route.finding.id)
|
|
79
|
+
return route.view === 'findings' && suffix ? `${team}${report}${suffix}` : null
|
|
80
|
+
}
|
|
81
|
+
return `${team}${report}${route.view === 'files' ? '/files' : ''}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The npm viewer: its lookup page, or a package version's Overview, Code,
|
|
85
|
+
// Compare or Treemap tab, `/npm/<name>[@<version or dist-tag>][/<tab>…]`. Code names
|
|
86
|
+
// its file by number and its lines in the fragment, as a bundle's Code
|
|
87
|
+
// does: `/code/<file>#L4`. Compare names the version compared with, then
|
|
88
|
+
// its mode past the Overview: `/compare/<version>/code` or `/diff`.
|
|
89
|
+
export const NPM_TABS = new Set(['overview', 'code', 'compare', 'review', 'treemap'])
|
|
90
|
+
function npmPackagePath(route) {
|
|
91
|
+
if (route.packageName == null) return '/npm'
|
|
92
|
+
const tab = route.bundleTab ?? 'overview'
|
|
93
|
+
if (!isNpmPackageName(route.packageName) || (route.packageSpec != null && !isNpmPackageSpec(route.packageSpec)) || !NPM_TABS.has(tab)
|
|
94
|
+
|| (route.compareSpec != null && !isNpmPackageSpec(route.compareSpec))) return null
|
|
95
|
+
if (tab === 'review') {
|
|
96
|
+
if (route.reviewPackage != null && !isNpmPackageName(route.reviewPackage) || route.reviewSpec != null && !isNpmPackageSpec(route.reviewSpec)) return null
|
|
97
|
+
const base = route.reviewPackage == null ? '' : `/${route.reviewPackage}${route.reviewSpec == null ? '' : `@${route.reviewSpec}`}`
|
|
98
|
+
return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}/review${base}`
|
|
99
|
+
}
|
|
100
|
+
const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
|
|
101
|
+
const lines = file && isLineNumber(route.line)
|
|
102
|
+
? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
|
|
103
|
+
const compare = tab === 'compare' && route.compareSpec != null ? `/${route.compareSpec}${compareModeSuffix(route.compareMode)}` : ''
|
|
104
|
+
return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseNpmRoute(path, hash) {
|
|
108
|
+
const review = /^\/npm\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?\/review(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?)?$/u.exec(path)
|
|
109
|
+
if (review) {
|
|
110
|
+
if (!isNpmPackageName(review[1]) || review[2] != null && !isNpmPackageSpec(review[2])
|
|
111
|
+
|| review[3] != null && !isNpmPackageName(review[3]) || review[4] != null && !isNpmPackageSpec(review[4])) return null
|
|
112
|
+
// The full dependency form is canonical; a bare version is a shortcut.
|
|
113
|
+
const versionOnly = review[3] && /^\d+\.\d+\.\d+(?:[-+].*)?$/u.test(review[3]) && review[4] == null
|
|
114
|
+
return { view: 'npm', packageName: review[1], packageSpec: review[2] ?? null, bundleTab: 'review',
|
|
115
|
+
...(review[3] ? { reviewPackage: versionOnly ? review[1] : review[3], reviewSpec: versionOnly ? review[3] : review[4] ?? null } : {}) }
|
|
116
|
+
}
|
|
117
|
+
const match = /^\/npm(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(code|compare|treemap)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u.exec(path)
|
|
118
|
+
if (!match) return undefined
|
|
119
|
+
if (match[1] == null) return { view: 'npm' }
|
|
120
|
+
if (!isNpmPackageName(match[1]) || (match[2] != null && !isNpmPackageSpec(match[2]))) return null
|
|
121
|
+
const route = { view: 'npm', packageName: match[1], packageSpec: match[2] ?? null, bundleTab: match[3] ?? 'overview' }
|
|
122
|
+
if (match[4] == null) return route
|
|
123
|
+
if (route.bundleTab === 'compare') {
|
|
124
|
+
return isNpmPackageSpec(match[4]) && (match[5] == null || COMPARE_MODES.has(match[5])) ? { ...route, compareSpec: match[4], ...compareModeField(match[5]) } : null
|
|
125
|
+
}
|
|
126
|
+
if (route.bundleTab !== 'code') return null
|
|
127
|
+
const file = Number(match[4])
|
|
128
|
+
return !match[5] && /^[1-9]\d*$/u.test(match[4]) && Number.isSafeInteger(file) ? { ...route, file, ...codeLines(hash) } : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isLineNumber(value) {
|
|
132
|
+
return Number.isSafeInteger(value) && value > 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// `#L42` or `#L42-L69`, in either order; anything else marks no lines.
|
|
136
|
+
function codeLines(hash) {
|
|
137
|
+
const match = /^#L([1-9]\d*)(?:-L([1-9]\d*))?$/u.exec(hash)
|
|
138
|
+
const [first, last] = match ? [Number(match[1]), Number(match[2] ?? match[1])] : []
|
|
139
|
+
if (!isLineNumber(first) || !isLineNumber(last)) return {}
|
|
140
|
+
return first === last ? { line: first } : { line: Math.min(first, last), endLine: Math.max(first, last) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function parseManagedRoute(url) {
|
|
144
|
+
const path = url.pathname.replace(/\/$/u, '') || '/'
|
|
145
|
+
if (path === '/' || path === '/index.html') return { view: 'home' }
|
|
146
|
+
if (path === '/my/reviews') return { view: 'reviews' }
|
|
147
|
+
const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
|
|
148
|
+
if (view) {
|
|
149
|
+
const createRepoId = Number(url.searchParams.get('createRepo'))
|
|
150
|
+
return { view,
|
|
151
|
+
...(view === 'manage-deduplication' && url.searchParams.get('report') ? { linkId: url.searchParams.get('report') } : {}),
|
|
152
|
+
...(view === 'manage-bundles' && Number.isSafeInteger(createRepoId) && createRepoId > 0 ? { createRepoId } : {}),
|
|
153
|
+
...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
|
|
154
|
+
...(view === 'manage-scans' ? url.searchParams.get('mode') === 'link' ? { scanMode: 'link' }
|
|
155
|
+
: url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle'),
|
|
156
|
+
...(url.searchParams.get('mode') === 'dependencies' ? { scanMode: 'dependencies' } : {}) } : {} : {}),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+)(?:\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?)?)?$/u.exec(path)
|
|
160
|
+
if (bundle) {
|
|
161
|
+
const bundleTab = bundle[3] ?? 'overview'
|
|
162
|
+
if (!BUNDLE_TABS.has(bundleTab)) return null
|
|
163
|
+
const route = { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab }
|
|
164
|
+
if (bundle[4] == null) return route
|
|
165
|
+
if (bundleTab === 'compare') return bundle[5] == null || COMPARE_MODES.has(bundle[5]) ? { ...route, compareSlug: bundle[4], ...compareModeField(bundle[5]) } : null
|
|
166
|
+
const file = Number(bundle[4])
|
|
167
|
+
if (bundleTab !== 'code' || bundle[5] || !/^[1-9]\d*$/u.test(bundle[4]) || !Number.isSafeInteger(file)) return null
|
|
168
|
+
return { ...route, file, ...codeLines(url.hash) }
|
|
169
|
+
}
|
|
170
|
+
const npm = parseNpmRoute(path, url.hash)
|
|
171
|
+
if (npm !== undefined) return npm
|
|
172
|
+
const contentList = /^\/team\/([A-Za-z0-9_-]+)\/(reports|bundles)$/u.exec(path)
|
|
173
|
+
if (contentList) return { view: `workspace-${contentList[2]}`, teamSlug: contentList[1] }
|
|
174
|
+
const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
|
|
175
|
+
if (!match) return null
|
|
176
|
+
try {
|
|
177
|
+
const teamSlug = decodeURIComponent(match[1])
|
|
178
|
+
const reportSlug = match[2] ? decodeURIComponent(match[2]) : null
|
|
179
|
+
if ([teamSlug, reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
|
|
180
|
+
if (match[4]?.length > MAX_FINDING_ID_LENGTH * 9) return null
|
|
181
|
+
const id = match[4] ? decodeURIComponent(match[4]) : null
|
|
182
|
+
if (id != null && !findingPath(id)) return null
|
|
183
|
+
return { view: match[3] ? 'files' : 'findings', teamSlug, reportSlug, ...(id == null ? {} : { finding: { id } }) }
|
|
184
|
+
} catch { return null }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// URL tokens are exact server-assigned slugs. Application state and API calls
|
|
188
|
+
// continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
|
|
189
|
+
export function resolveManagedRoute(route, teams, adminBundles = []) {
|
|
190
|
+
if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
|
|
191
|
+
const matches = teams.filter(team => team.slug === route.teamSlug)
|
|
192
|
+
return matches.length === 1 ? { view: route.view, teamId: matches[0].id } : null
|
|
193
|
+
}
|
|
194
|
+
if (route.view === 'bundles') {
|
|
195
|
+
const { teamSlug, bundleSlug, compareSlug, compareMode, ...rest } = route
|
|
196
|
+
const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
|
|
197
|
+
if (teamSlug != null && matches.length !== 1) return null
|
|
198
|
+
const team = matches[0]
|
|
199
|
+
const candidates = teamSlug == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])
|
|
200
|
+
const idsOf = slug => new Set(candidates.filter(bundle => bundle.slug === slug).map(bundle => bundle.id))
|
|
201
|
+
const ids = idsOf(bundleSlug)
|
|
202
|
+
const bundle = (team?.bundles ?? adminBundles).find(entry => entry.slug === bundleSlug)
|
|
203
|
+
if (!bundleSlug || !bundle || ids.size !== 1) return null
|
|
204
|
+
// The bundle compared with, by the same rule; one no longer listed drops
|
|
205
|
+
// out, leaving Compare to pick again.
|
|
206
|
+
const compared = compareSlug == null ? null : idsOf(compareSlug)
|
|
207
|
+
const compare = compared?.size === 1 ? { compareId: [...compared][0], ...compareModeField(compareMode) } : {}
|
|
208
|
+
return { ...rest, teamId: team?.id ?? null, bundleId: bundle.id, ...compare }
|
|
209
|
+
}
|
|
210
|
+
if (!['findings', 'files'].includes(route.view)) return route
|
|
211
|
+
const { teamSlug, reportSlug, ...rest } = route
|
|
212
|
+
const matches = teams.filter(team => team.slug === teamSlug)
|
|
213
|
+
if (!teamSlug || matches.length !== 1) return null
|
|
214
|
+
const team = matches[0]
|
|
215
|
+
if (reportSlug == null) return { ...rest, teamId: team.id, reportId: null }
|
|
216
|
+
const reports = teams.flatMap(entry => entry.reports ?? []).filter(report => report.slug === reportSlug)
|
|
217
|
+
const ids = new Set(reports.map(report => report.id))
|
|
218
|
+
const report = (team.reports ?? []).find(entry => entry.slug === reportSlug)
|
|
219
|
+
return report && ids.size === 1 ? { ...rest, teamId: team.id, reportId: report.id } : null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function managedRouteForIds(route, teams, adminBundles = []) {
|
|
223
|
+
if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
|
|
224
|
+
const team = teams.find(entry => entry.id === route.teamId)
|
|
225
|
+
const result = { view: route.view, teamSlug: team?.slug }
|
|
226
|
+
return team?.slug && resolveManagedRoute(result, teams) ? result : null
|
|
227
|
+
}
|
|
228
|
+
if (route.view === 'bundles') {
|
|
229
|
+
const { teamId, bundleId, compareId, compareMode, ...rest } = route
|
|
230
|
+
const team = teams.find(entry => entry.id === teamId)
|
|
231
|
+
if (teamId != null && !team?.slug) return null
|
|
232
|
+
const bundle = (team?.bundles ?? adminBundles).find(entry => entry.id === bundleId)
|
|
233
|
+
if (!bundle?.slug) return null
|
|
234
|
+
const compared = compareId == null ? null
|
|
235
|
+
: (teamId == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])).find(entry => entry.id === compareId)
|
|
236
|
+
const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug,
|
|
237
|
+
...(compared?.slug ? { compareSlug: compared.slug, ...compareModeField(compareMode) } : {}) }
|
|
238
|
+
const resolved = resolveManagedRoute(result, teams, adminBundles)
|
|
239
|
+
if (!resolved) return null
|
|
240
|
+
// A compared bundle whose slug doesn't name it alone leaves the route.
|
|
241
|
+
if (result.compareSlug != null && resolved.compareId !== compareId) {
|
|
242
|
+
const { compareSlug: _slug, compareMode: _mode, ...bare } = result
|
|
243
|
+
return bare
|
|
244
|
+
}
|
|
245
|
+
return result
|
|
246
|
+
}
|
|
247
|
+
if (!['findings', 'files'].includes(route.view)) return route
|
|
248
|
+
const { teamId, reportId, ...rest } = route
|
|
249
|
+
const team = teams.find(entry => entry.id === teamId)
|
|
250
|
+
const report = reportId == null ? null : team?.reports?.find(entry => entry.id === reportId)
|
|
251
|
+
if (!team?.slug || (reportId != null && !report?.slug)) return null
|
|
252
|
+
const result = { ...rest, teamSlug: team.slug, reportSlug: report?.slug ?? null }
|
|
253
|
+
return resolveManagedRoute(result, teams) ? result : null
|
|
254
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { defaultScanModels } from '../default-scan-models.ts'
|
|
2
|
+
|
|
3
|
+
// Use the same starting catalogue as the UI until managed scan discovery is wired.
|
|
4
|
+
const catalogue = defaultScanModels()
|
|
5
|
+
export const MANAGED_SCAN_MODELS = Object.freeze(catalogue.models)
|
|
6
|
+
export const DEFAULT_MANAGED_SCAN_MODEL = catalogue.defaultModel
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { inheritReportMeta, isAppFinding, reportEntries } from '@preventive/report'
|
|
2
|
+
import { collectDuplicates } from '../../client/linked-findings.js'
|
|
3
|
+
import { hasRevalidateStamp } from '../finding-filters.js'
|
|
4
|
+
import { workspaceAppMetadata } from '../workspace-app.js'
|
|
5
|
+
|
|
6
|
+
// The same classification as local workspaces, using only the complete,
|
|
7
|
+
// server-filtered published workspace: the reports of one team response and
|
|
8
|
+
// its `links`. The managed server classifies each team before sending its
|
|
9
|
+
// catalog, so the sidebar opens a team collapsed to its App findings at once.
|
|
10
|
+
// The server backfills finding IDs before building these envelopes.
|
|
11
|
+
export function managedTeamAppMetadata(workspace) {
|
|
12
|
+
const duplicates = new Map(), reports = []
|
|
13
|
+
collectDuplicates(workspace.links ?? [], duplicates)
|
|
14
|
+
for (const { filename, data } of workspace) {
|
|
15
|
+
if (data.source === 'links' && Array.isArray(data.links)) {
|
|
16
|
+
collectDuplicates(data.links, duplicates)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
const entries = (reportEntries(data) ?? []).map(entry => Array.isArray(entry) ? entry : [entry])
|
|
20
|
+
const judged = new Set(entries.flat().filter(hasRevalidateStamp).map(f => f.source ?? data.source ?? null))
|
|
21
|
+
const groups = entries.map(group => group.map(f => {
|
|
22
|
+
const finding = { ...f, _source: f.source ?? data.source ?? null }
|
|
23
|
+
inheritReportMeta(finding, data)
|
|
24
|
+
if (!('isApp' in finding)) finding.isApp = isAppFinding(finding, finding._source)
|
|
25
|
+
finding._sourcePass = judged.has(finding._source)
|
|
26
|
+
return finding
|
|
27
|
+
}))
|
|
28
|
+
reports.push({ fileName: filename, groups })
|
|
29
|
+
}
|
|
30
|
+
return workspaceAppMetadata(reports, id => [...duplicates.get(id) ?? []])
|
|
31
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Per-finding triage annotations on a managed server — shared by the server
|
|
2
|
+
// (which stores one row per finding id, validates the wire shape, and decides
|
|
3
|
+
// per report which ids a viewer may read or write) and the managed client
|
|
4
|
+
// (which mirrors entries into its local triage map). Keyed by finding id
|
|
5
|
+
// alone, like that map: reports mostly repeat one another (a re-scan of the
|
|
6
|
+
// same code carries the same finding ids), and a finding's triage is shared by
|
|
7
|
+
// every report that carries it. A wire entry carries the four server-persisted
|
|
8
|
+
// fields below, or is null for a cleared entry (the server's tombstone); the
|
|
9
|
+
// client's `ignoredReports` deliberately does NOT ride this wire — the
|
|
10
|
+
// per-report ignore is a client-local concept keyed by report name.
|
|
11
|
+
export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted' | 'ignored'
|
|
12
|
+
|
|
13
|
+
const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted', 'ignored']
|
|
14
|
+
|
|
15
|
+
// One finding's server-side triage entry (also the write shape — writes
|
|
16
|
+
// replace the whole entry). Absent fields are unset; `flagged` is tri-state:
|
|
17
|
+
// absent = never set, `true` = flagged, `false` = an explicit un-flag
|
|
18
|
+
// tombstone that must round-trip (see client/state.ts TriageEntry).
|
|
19
|
+
export type TriageEntryPatch = {
|
|
20
|
+
color?: string
|
|
21
|
+
triage?: TriageBucket
|
|
22
|
+
// Legacy triage history/migration only. New comments use separate records;
|
|
23
|
+
// the managed triage write endpoint rejects this field.
|
|
24
|
+
comment?: string
|
|
25
|
+
fix?: string
|
|
26
|
+
flagged?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Length caps enforced at the write endpoint so one entry can't balloon a row.
|
|
30
|
+
export const MAX_TRIAGE_TEXT = 10_000
|
|
31
|
+
export const MAX_TRIAGE_COLOR = 50
|
|
32
|
+
|
|
33
|
+
// Caps on one write request as a whole — shared with the client, which batches
|
|
34
|
+
// within them: entries per request, the finding-id length (real ids are 36-char
|
|
35
|
+
// uuids), and the JSON body (the server's small default is too small for entry
|
|
36
|
+
// batches, which carry free text).
|
|
37
|
+
export const MAX_TRIAGE_ENTRIES = 200
|
|
38
|
+
export const MAX_FINDING_ID = 100
|
|
39
|
+
export const MAX_TRIAGE_BODY_BYTES = 262_144
|
|
40
|
+
|
|
41
|
+
// The most trail events one history read returns (newest first). Retention is
|
|
42
|
+
// the server's: everything is kept unless its TRIAGE_HISTORY_LIMIT says
|
|
43
|
+
// otherwise.
|
|
44
|
+
export const MAX_TRIAGE_HISTORY = 200
|
|
45
|
+
|
|
46
|
+
export function isTriageBucket(x: unknown): x is TriageBucket {
|
|
47
|
+
return typeof x === 'string' && (TRIAGE_BUCKETS as readonly string[]).includes(x)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Coerce one wire entry (a request-body value) into a clean patch. `null`
|
|
51
|
+
// means "clear the entry"; a malformed value (wrong type, unknown bucket,
|
|
52
|
+
// over-cap string, non-boolean flagged) is 'invalid' so the endpoint 400s
|
|
53
|
+
// rather than store garbage. Null/empty-string fields count as absent (an
|
|
54
|
+
// absent field is cleared anyway under whole-entry replace); unknown keys are
|
|
55
|
+
// ignored.
|
|
56
|
+
export function parseTriageEntryPatch(x: unknown): TriageEntryPatch | null | 'invalid' {
|
|
57
|
+
if (x === null) return null
|
|
58
|
+
if (typeof x !== 'object' || Array.isArray(x)) return 'invalid'
|
|
59
|
+
const o = x as Record<string, unknown>
|
|
60
|
+
const out: TriageEntryPatch = {}
|
|
61
|
+
const color = o['color']
|
|
62
|
+
if (color != null) {
|
|
63
|
+
if (typeof color !== 'string' || color.length > MAX_TRIAGE_COLOR) return 'invalid'
|
|
64
|
+
if (color !== '') out.color = color
|
|
65
|
+
}
|
|
66
|
+
const triage = o['triage']
|
|
67
|
+
if (triage != null) {
|
|
68
|
+
if (!isTriageBucket(triage)) return 'invalid'
|
|
69
|
+
out.triage = triage
|
|
70
|
+
}
|
|
71
|
+
for (const key of ['comment', 'fix'] as const) {
|
|
72
|
+
const v = o[key]
|
|
73
|
+
if (v == null) continue
|
|
74
|
+
if (typeof v !== 'string' || v.length > MAX_TRIAGE_TEXT) return 'invalid'
|
|
75
|
+
if (v !== '') out[key] = v
|
|
76
|
+
}
|
|
77
|
+
const flagged = o['flagged']
|
|
78
|
+
if (flagged != null) {
|
|
79
|
+
if (typeof flagged !== 'boolean') return 'invalid'
|
|
80
|
+
out.flagged = flagged
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Managed uploads cross TLS-terminating proxies (Cloudflare, corporate
|
|
2
|
+
// gateways) that would otherwise read report and bundle bytes in plaintext.
|
|
3
|
+
// The server sends each session an X25519 public key; the browser seals every
|
|
4
|
+
// upload to it with a fresh ephemeral key (X25519 → HKDF-SHA-256 →
|
|
5
|
+
// AES-256-GCM), so the content key itself never crosses the proxy. Text is
|
|
6
|
+
// gzipped before sealing; binary content is sealed as is.
|
|
7
|
+
//
|
|
8
|
+
// Sealed body: version(1) | flags(1) | ephemeral public key(32), then the
|
|
9
|
+
// payload in 1 MiB segments, each sealed under nonce = big-endian segment
|
|
10
|
+
// index with the last byte marking the final segment. The header is the HKDF
|
|
11
|
+
// salt, so altering it changes the key. Segments bound browser memory for
|
|
12
|
+
// large bundles, and their nonces reject reordered, truncated or extended
|
|
13
|
+
// bodies. Every upload derives its own key, so counter nonces never repeat.
|
|
14
|
+
|
|
15
|
+
import { gunzipBytes } from '../gzip.js'
|
|
16
|
+
import { encodeUtf8 } from '../utf8.js'
|
|
17
|
+
|
|
18
|
+
// Set (to the format version) on a request whose body is sealed.
|
|
19
|
+
export const UPLOAD_SEAL_HEADER = 'x-upload-encryption'
|
|
20
|
+
const VERSION = 1
|
|
21
|
+
const FLAG_GZIP = 1
|
|
22
|
+
const CURVE = { name: 'X25519' }
|
|
23
|
+
const HEADER_BYTES = 2 + 32
|
|
24
|
+
const TAG_BYTES = 16
|
|
25
|
+
const SEGMENT_BYTES = 1024 * 1024
|
|
26
|
+
const SNIFF_BYTES = 64 * 1024
|
|
27
|
+
const KEY_INFO = 'deepview-managed-upload.v1.content-key'
|
|
28
|
+
|
|
29
|
+
// The largest sealed body for `maxBytes` of content. Deflate can grow
|
|
30
|
+
// incompressible text slightly (its worst case is about 0.03%).
|
|
31
|
+
export function maxSealedBytes(maxBytes: number): number {
|
|
32
|
+
const payload = maxBytes + Math.ceil(maxBytes / 1024) + 64
|
|
33
|
+
return HEADER_BYTES + payload + TAG_BYTES * (Math.floor(payload / SEGMENT_BYTES) + 1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nonce(index: number, final: boolean): Uint8Array<ArrayBuffer> {
|
|
37
|
+
const iv = new Uint8Array(12)
|
|
38
|
+
new DataView(iv.buffer).setUint32(7, index)
|
|
39
|
+
iv[11] = final ? 1 : 0
|
|
40
|
+
return iv
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function contentKey(secret: ArrayBuffer, header: Uint8Array<ArrayBuffer>, usage: KeyUsage): Promise<CryptoKey> {
|
|
44
|
+
const ikm = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey'])
|
|
45
|
+
return await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: header, info: encodeUtf8(KEY_INFO) },
|
|
46
|
+
ikm, { name: 'AES-GCM', length: 256 }, false, [usage])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Archives and other binary content would only grow under gzip. A NUL byte or
|
|
50
|
+
// invalid UTF-8 in the first 64 KiB marks content as binary.
|
|
51
|
+
async function isText(blob: Blob): Promise<boolean> {
|
|
52
|
+
const head = new Uint8Array(await blob.slice(0, SNIFF_BYTES).arrayBuffer())
|
|
53
|
+
if (head.includes(0)) return false
|
|
54
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(head, { stream: true }); return true }
|
|
55
|
+
catch { return false }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Seal `blob` to the server's raw public key. Reads the input as a stream;
|
|
59
|
+
// sealed segments are kept as Blobs, which browsers may page to disk. An abort
|
|
60
|
+
// stops reading at the next chunk.
|
|
61
|
+
export async function sealUpload(blob: Blob, publicKey: Uint8Array<ArrayBuffer>, signal?: AbortSignal): Promise<Blob> {
|
|
62
|
+
const gzip = await isText(blob)
|
|
63
|
+
const [server, ephemeral] = await Promise.all([
|
|
64
|
+
crypto.subtle.importKey('raw', publicKey, CURVE, false, []),
|
|
65
|
+
crypto.subtle.generateKey(CURVE, false, ['deriveBits']) as Promise<CryptoKeyPair>,
|
|
66
|
+
])
|
|
67
|
+
const header = new Uint8Array(HEADER_BYTES)
|
|
68
|
+
header[0] = VERSION
|
|
69
|
+
header[1] = gzip ? FLAG_GZIP : 0
|
|
70
|
+
header.set(new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey)), 2)
|
|
71
|
+
const key = await contentKey(await crypto.subtle.deriveBits({ name: 'X25519', public: server }, ephemeral.privateKey, 256), header, 'encrypt')
|
|
72
|
+
const parts = [new Blob([header])], segment = new Uint8Array(SEGMENT_BYTES)
|
|
73
|
+
let filled = 0, index = 0
|
|
74
|
+
const seal = async (final: boolean) => {
|
|
75
|
+
parts.push(new Blob([await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce(index++, final) }, key, segment.subarray(0, filled))]))
|
|
76
|
+
filled = 0
|
|
77
|
+
}
|
|
78
|
+
const stream: ReadableStream<Uint8Array> = gzip ? blob.stream().pipeThrough(new CompressionStream('gzip')) : blob.stream()
|
|
79
|
+
const reader = stream.getReader()
|
|
80
|
+
try {
|
|
81
|
+
for (let read = await reader.read(); !read.done; read = await reader.read()) {
|
|
82
|
+
signal?.throwIfAborted()
|
|
83
|
+
for (let offset = 0; offset < read.value.length;) {
|
|
84
|
+
// A full segment is final only if the input ends with it.
|
|
85
|
+
if (filled === SEGMENT_BYTES) await seal(false)
|
|
86
|
+
const length = Math.min(SEGMENT_BYTES - filled, read.value.length - offset)
|
|
87
|
+
segment.set(read.value.subarray(offset, offset + length), filled)
|
|
88
|
+
filled += length
|
|
89
|
+
offset += length
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
await reader.cancel(err).catch(() => {})
|
|
94
|
+
throw err
|
|
95
|
+
}
|
|
96
|
+
await seal(true)
|
|
97
|
+
return new Blob(parts)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Open a sealed body with the session's private JWK. Throws 'too-large' when
|
|
101
|
+
// the content exceeds `maxBytes`, else 'bad-upload' for any invalid body.
|
|
102
|
+
export async function openUpload(sealed: Uint8Array<ArrayBuffer>, privateKey: JsonWebKey, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
|
|
103
|
+
// Every segment but the last is full, so the layout follows from the length.
|
|
104
|
+
const rest = sealed.length - HEADER_BYTES
|
|
105
|
+
const count = Math.ceil(rest / (SEGMENT_BYTES + TAG_BYTES))
|
|
106
|
+
const last = rest - (count - 1) * (SEGMENT_BYTES + TAG_BYTES)
|
|
107
|
+
if (rest < TAG_BYTES || last < TAG_BYTES || sealed[0] !== VERSION || (sealed[1]! & ~FLAG_GZIP) !== 0) throw new Error('bad-upload')
|
|
108
|
+
const gzip = sealed[1] === FLAG_GZIP, plain = new Uint8Array(rest - count * TAG_BYTES)
|
|
109
|
+
if (!gzip && plain.length > maxBytes) throw new Error('too-large')
|
|
110
|
+
try {
|
|
111
|
+
const header = sealed.slice(0, HEADER_BYTES)
|
|
112
|
+
const [key, peer] = await Promise.all([
|
|
113
|
+
crypto.subtle.importKey('jwk', privateKey, CURVE, false, ['deriveBits']),
|
|
114
|
+
crypto.subtle.importKey('raw', header.slice(2), CURVE, false, []),
|
|
115
|
+
])
|
|
116
|
+
const content = await contentKey(await crypto.subtle.deriveBits({ name: 'X25519', public: peer }, key, 256), header, 'decrypt')
|
|
117
|
+
for (let index = 0; index < count; index++) {
|
|
118
|
+
const start = HEADER_BYTES + index * (SEGMENT_BYTES + TAG_BYTES)
|
|
119
|
+
const segment = sealed.subarray(start, Math.min(start + SEGMENT_BYTES + TAG_BYTES, sealed.length))
|
|
120
|
+
plain.set(new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce(index, index === count - 1) }, content, segment)), index * SEGMENT_BYTES)
|
|
121
|
+
}
|
|
122
|
+
} catch (err) { throw new Error('bad-upload', { cause: err }) }
|
|
123
|
+
if (!gzip) return plain
|
|
124
|
+
try { return await gunzipBytes(plain, { maxBytes }) }
|
|
125
|
+
catch (err) { throw new Error(err instanceof RangeError ? 'too-large' : 'bad-upload', { cause: err }) }
|
|
126
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Type declarations for `common/pretty-print.js`, for `.ts` callers.
|
|
2
|
+
|
|
3
|
+
export const MAX_PRETTY_BYTES: number
|
|
4
|
+
export function prettyExtension(path: string): string | null
|
|
5
|
+
export function needsPrettyPrint(path: string, text: string, read?: () => { category: string }): boolean
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Minified files pretty-printed by the server (server-managed/pretty-print.ts)
|
|
2
|
+
// for the viewer (ui/view/pretty-source.js).
|
|
3
|
+
import { npmFileReadability } from '../risk/files/readability.js'
|
|
4
|
+
import { utf8ByteLength } from './utf8.js'
|
|
5
|
+
|
|
6
|
+
// The extensions formatted, each parsed as the language it names.
|
|
7
|
+
const PRETTY_EXTENSIONS = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'css', 'json'])
|
|
8
|
+
|
|
9
|
+
// The largest file formatted, in UTF-8 bytes: formatting holds a syntax tree
|
|
10
|
+
// a hundred times the file's size.
|
|
11
|
+
export const MAX_PRETTY_BYTES = 4 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
// The extension a path is formatted as, or null for one that isn't.
|
|
14
|
+
export function prettyExtension(path) {
|
|
15
|
+
const extension = /\.([a-z]+)$/iu.exec(path)?.[1]?.toLowerCase()
|
|
16
|
+
return PRETTY_EXTENSIONS.has(extension) ? extension : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Whether a file's text is pretty-printed: in a language formatted, no larger
|
|
20
|
+
// than formatted, and minified, as risk reads it, `read` reading that where
|
|
21
|
+
// the reader keeps it.
|
|
22
|
+
export function needsPrettyPrint(path, text, read = () => npmFileReadability(path, text)) {
|
|
23
|
+
return prettyExtension(path) !== null && utf8ByteLength(text) <= MAX_PRETTY_BYTES && read().category === 'minified'
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { REVALIDATE_KINDS, revalidateKindOf } from '@preventive/report'
|
|
2
|
+
|
|
3
|
+
// Keep only the pass's fields, before gap-filling changes the survivor.
|
|
4
|
+
// In particular, a verdict borrowed from report B must never be attributed
|
|
5
|
+
// to an unstamped copy from report A when report C later contradicts it.
|
|
6
|
+
function fieldsOf(finding) {
|
|
7
|
+
return Object.fromEntries(Object.entries(finding).filter(([key, value]) =>
|
|
8
|
+
key.startsWith('revalidate') && value != null))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function recordRevalidationCopies(survivor, duplicate, reportName) {
|
|
12
|
+
const fields = fieldsOf(duplicate)
|
|
13
|
+
if (!survivor._revalidationCopies) {
|
|
14
|
+
const own = fieldsOf(survivor)
|
|
15
|
+
if (Object.keys(own).length === 0 && Object.keys(fields).length === 0) return []
|
|
16
|
+
survivor._revalidationCopies = Object.keys(own).length > 0
|
|
17
|
+
? [{ reportName: survivor._reportName ?? '', fields: own }]
|
|
18
|
+
: []
|
|
19
|
+
}
|
|
20
|
+
if (Object.keys(fields).length > 0) survivor._revalidationCopies.push({ reportName, fields })
|
|
21
|
+
return survivor._revalidationCopies
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Same comparisons as mergeDuplicateFields: missing values are gaps, stamps
|
|
25
|
+
// are normalized, and prose ignores surrounding whitespace. Group agreeing
|
|
26
|
+
// reports together, retaining every distinct answer rather than just a pair.
|
|
27
|
+
export function revalidationDifferences(copies) {
|
|
28
|
+
const byField = new Map()
|
|
29
|
+
const knownKinds = new Set(REVALIDATE_KINDS)
|
|
30
|
+
for (const { reportName, fields } of copies) {
|
|
31
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
32
|
+
if (value == null) continue
|
|
33
|
+
const normalized = key === 'revalidate'
|
|
34
|
+
? revalidateKindOf(fields) || String(value).trim().toLowerCase()
|
|
35
|
+
: String(value).trim()
|
|
36
|
+
if (key === 'revalidate' && !normalized) continue
|
|
37
|
+
if (key === 'revalidate' && !knownKinds.has(normalized)) continue
|
|
38
|
+
if (!byField.has(key)) byField.set(key, new Map())
|
|
39
|
+
const variants = byField.get(key)
|
|
40
|
+
if (!variants.has(normalized)) variants.set(normalized, new Set())
|
|
41
|
+
variants.get(normalized).add(reportName)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...byField].filter(([, variants]) => variants.size > 1).map(([field, variants]) => ({
|
|
45
|
+
field,
|
|
46
|
+
variants: [...variants].map(([value, reports]) => ({ value, reports: [...reports] })),
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Recover source-level row boundaries before hiding the App layer. App
|
|
2
|
+
// deduplication alone does not make the underlying code findings equivalent.
|
|
3
|
+
// Only split when every source ID is accounted for and every referenced input
|
|
4
|
+
// exists in this row. Missing input lists are empty, not missing evidence for
|
|
5
|
+
// an otherwise complete partition. Original findings and rows stay untouched.
|
|
6
|
+
export function splitRevalidationInputs(group, appFindings = null) {
|
|
7
|
+
const app = appFindings ?? group.filter((f) => f.isApp)
|
|
8
|
+
const source = group.filter((f) => !f.isApp)
|
|
9
|
+
if (app.length === 0 || source.length === 0) return [group]
|
|
10
|
+
// A surviving App finding means this is not a pure non-App projection;
|
|
11
|
+
// never move it into a source-only partition.
|
|
12
|
+
if (appFindings && group.some((f) => f.isApp)) return [group]
|
|
13
|
+
if (source.some((f) => typeof f.id !== 'string' || !f.id)) return [group]
|
|
14
|
+
|
|
15
|
+
const sourceIds = new Set(source.map((f) => f.id))
|
|
16
|
+
const inputsByApp = new Map()
|
|
17
|
+
const covered = new Set()
|
|
18
|
+
for (const f of app) {
|
|
19
|
+
const inputs = f.revalidateInputs === undefined ? [] : f.revalidateInputs
|
|
20
|
+
if (!Array.isArray(inputs)) return [group]
|
|
21
|
+
for (const id of inputs) {
|
|
22
|
+
if (!sourceIds.has(id)) return [group]
|
|
23
|
+
covered.add(id)
|
|
24
|
+
}
|
|
25
|
+
inputsByApp.set(f, inputs)
|
|
26
|
+
}
|
|
27
|
+
if (covered.size !== sourceIds.size) return [group]
|
|
28
|
+
|
|
29
|
+
const parent = new Map([...sourceIds].map((id) => [id, id]))
|
|
30
|
+
const find = (id) => {
|
|
31
|
+
let root = id
|
|
32
|
+
while (parent.get(root) !== root) root = parent.get(root)
|
|
33
|
+
while (parent.get(id) !== root) {
|
|
34
|
+
const next = parent.get(id)
|
|
35
|
+
parent.set(id, root)
|
|
36
|
+
id = next
|
|
37
|
+
}
|
|
38
|
+
return root
|
|
39
|
+
}
|
|
40
|
+
for (const inputs of inputsByApp.values()) {
|
|
41
|
+
if (inputs.length === 0) continue
|
|
42
|
+
const root = find(inputs[0])
|
|
43
|
+
for (const id of inputs) parent.set(find(id), root)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const components = new Map()
|
|
47
|
+
for (const f of group) {
|
|
48
|
+
const inputs = inputsByApp.get(f)
|
|
49
|
+
// Keep an App finding with its inputs until the caller projects the lens.
|
|
50
|
+
// App findings without inputs stand alone; hiding them then drops the row.
|
|
51
|
+
const key = inputs ? inputs.length > 0 ? find(inputs[0]) : f : find(f.id)
|
|
52
|
+
if (!components.has(key)) components.set(key, [])
|
|
53
|
+
components.get(key).push(f)
|
|
54
|
+
}
|
|
55
|
+
return components.size === 1 ? [group] : [...components.values()]
|
|
56
|
+
}
|