@preventive/triage 1.0.0-alpha.23 → 1.0.0-alpha.25
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/out/client-managed-import.js +41 -41
- package/out/client-managed.js +74 -59
- package/out/graph.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +170 -169
- package/package.json +4 -1
- package/report/index.js +7 -1
- package/report/src/finding-id.js +1 -1
- package/report/src/finding.js +6 -4
- package/report/src/labels.js +3 -1
- package/report/src/parse-codex.js +3 -1
- package/report/src/parse-deepsec.js +3 -1
- package/report/src/parse-deepview-md.js +2 -1
- package/report/src/parse-md.js +3 -2
- package/report/src/parse-piolium-rows.js +7 -12
- package/report/src/parse-piolium-tokens.js +12 -2
- package/report/src/parse-piolium.js +7 -8
- package/report/src/severity.js +18 -0
- package/server-common/vercel-blob.ts +7 -18
- package/server-e2e/objstore/blob-vercel.ts +4 -4
- package/server-managed/blob-vercel.ts +4 -4
- package/server-managed/db-methods.ts +26 -9
- package/server-managed/db-neon.ts +11 -4
- package/server-managed/http.ts +71 -82
- package/server-managed/import-triage.ts +2 -2
- package/server-managed/management.ts +86 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +1 -11
- package/server-managed/repository-browser.ts +6 -1
- package/server-managed/sql.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.25",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -119,7 +119,9 @@
|
|
|
119
119
|
"server-managed/http.ts",
|
|
120
120
|
"server-managed/import-triage.ts",
|
|
121
121
|
"server-managed/managed-issues.ts",
|
|
122
|
+
"server-managed/management.ts",
|
|
122
123
|
"server-managed/index.ts",
|
|
124
|
+
"server-managed/package-entry-points.ts",
|
|
123
125
|
"server-managed/public-workspace.ts",
|
|
124
126
|
"server-managed/repo-path.ts",
|
|
125
127
|
"server-managed/report-migration.ts",
|
|
@@ -153,6 +155,7 @@
|
|
|
153
155
|
"report/index.js",
|
|
154
156
|
"report/src/finding-id.js",
|
|
155
157
|
"report/src/finding.js",
|
|
158
|
+
"report/src/severity.js",
|
|
156
159
|
"report/src/security.js",
|
|
157
160
|
"report/src/labels.js",
|
|
158
161
|
"report/src/md-structure.js",
|
package/report/index.js
CHANGED
|
@@ -82,6 +82,7 @@ import { parseDeepviewMarkdown } from './src/parse-deepview-md.js'
|
|
|
82
82
|
import { parseMarkdownFindings } from './src/parse-md.js'
|
|
83
83
|
import { parsePioliumFindings } from './src/parse-piolium.js'
|
|
84
84
|
import { deriveFindingId } from './src/finding-id.js'
|
|
85
|
+
import { normalizeFindingSeverity } from './src/severity.js'
|
|
85
86
|
|
|
86
87
|
// The rest of the surface, so a consumer needs one import: the codex
|
|
87
88
|
// splitter, the id helpers the analyzer shares with the viewer, and the
|
|
@@ -193,7 +194,10 @@ export function readReport(content) {
|
|
|
193
194
|
let jsonError
|
|
194
195
|
try {
|
|
195
196
|
const data = JSON.parse(content)
|
|
196
|
-
if (reportEntries(data))
|
|
197
|
+
if (reportEntries(data)) {
|
|
198
|
+
for (const finding of flattenFindings(reportEntries(data))) normalizeFindingSeverity(finding)
|
|
199
|
+
return { data, format: 'json', reason: null }
|
|
200
|
+
}
|
|
197
201
|
return { data: null, format: null, reason: 'JSON, but not a report: no findings array' }
|
|
198
202
|
} catch (err) {
|
|
199
203
|
jsonError = err
|
|
@@ -233,6 +237,8 @@ function flattenFindings(entries) {
|
|
|
233
237
|
// untouched. Batched via Promise.all — sequential awaits would
|
|
234
238
|
// serialise hundreds of crypto.subtle.digest calls for no reason.
|
|
235
239
|
export async function backfillFindingIds(findings) {
|
|
240
|
+
// Managed responses may arrive as already-parsed objects.
|
|
241
|
+
for (const finding of findings) normalizeFindingSeverity(finding)
|
|
236
242
|
const idLess = findings.filter((f) => !f.id)
|
|
237
243
|
if (idLess.length === 0) return
|
|
238
244
|
const derived = await Promise.all(idLess.map(deriveFindingId))
|
package/report/src/finding-id.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function deriveFindingId(f) {
|
|
|
71
71
|
// The choice above as the object that gets hashed. Key order is part of
|
|
72
72
|
// the id — JSON.stringify keeps insertion order — so every shape lists
|
|
73
73
|
// severity and description first.
|
|
74
|
-
function fingerprintOf(f) {
|
|
74
|
+
export function fingerprintOf(f) {
|
|
75
75
|
if (f._idBasis) return f._idBasis
|
|
76
76
|
const { severity, description } = f
|
|
77
77
|
if (f.fileHash) return { severity, description, fileHash: f.fileHash }
|
package/report/src/finding.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { fenceRanges, inFence } from './md-structure.js'
|
|
10
10
|
import { SOURCE_LABELS } from './labels.js'
|
|
11
|
+
import { canonicalSeverity } from './severity.js'
|
|
11
12
|
|
|
12
13
|
// Severity ranking — higher = more severe. Two stacks: vulnerabilities
|
|
13
14
|
// (critical → low) over bug-class findings (high_bug → bug), with
|
|
@@ -33,6 +34,7 @@ export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'high_bug', 'bug
|
|
|
33
34
|
// Only a known tier counts: an unrecognised one would sort to rank 0 and
|
|
34
35
|
// render an uncolored badge, so the intrinsic severity stands instead.
|
|
35
36
|
function validCorrected(corrected) {
|
|
37
|
+
corrected = canonicalSeverity(corrected)
|
|
36
38
|
return corrected != null && corrected in SEVERITY_ORDER ? corrected : null
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -40,20 +42,20 @@ function validCorrected(corrected) {
|
|
|
40
42
|
// correction — it IS that report's finding — so no report key is needed;
|
|
41
43
|
// divergence across reports comes from correctedVariants.
|
|
42
44
|
export function effectiveSeverity(f) {
|
|
43
|
-
return validCorrected(f?.correctedSeverity) ?? f?.severity
|
|
45
|
+
return validCorrected(f?.correctedSeverity) ?? canonicalSeverity(f?.severity)
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// True when the finding carries a valid correction that actually changes
|
|
47
49
|
// the tier — the trigger for the dual badge / reason affordance.
|
|
48
50
|
export function hasSeverityCorrection(f) {
|
|
49
51
|
const c = validCorrected(f?.correctedSeverity)
|
|
50
|
-
return c != null && c !== f?.severity
|
|
52
|
+
return c != null && c !== canonicalSeverity(f?.severity)
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// Every display, count and sort site reads severity through this with the
|
|
54
56
|
// current lens (`state.severityMode`), not off `f.severity`.
|
|
55
57
|
export function displayedSeverity(f, mode) {
|
|
56
|
-
return mode === 'original' ? f?.severity : effectiveSeverity(f)
|
|
58
|
+
return mode === 'original' ? canonicalSeverity(f?.severity) : effectiveSeverity(f)
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
// The per-report map of a deduped survivor, but only where the reports
|
|
@@ -61,7 +63,7 @@ export function displayedSeverity(f, mode) {
|
|
|
61
63
|
export function correctedVariants(f) {
|
|
62
64
|
const byReport = f?._correctedByReport
|
|
63
65
|
if (!byReport) return null
|
|
64
|
-
const tiers = new Set(Object.values(byReport).map((v) => v?.severity))
|
|
66
|
+
const tiers = new Set(Object.values(byReport).map((v) => canonicalSeverity(v?.severity)))
|
|
65
67
|
return tiers.size > 1 ? byReport : null
|
|
66
68
|
}
|
|
67
69
|
|
package/report/src/labels.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// export dialog, the analyzer dropdown, the page header — so a filter
|
|
4
4
|
// the dialog lists and the header line in the file can't disagree.
|
|
5
5
|
|
|
6
|
+
import { canonicalSeverity } from './severity.js'
|
|
7
|
+
|
|
6
8
|
export const SEVERITY_LABELS = {
|
|
7
9
|
critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low',
|
|
8
10
|
high_bug: 'High bug', bug: 'Bug', informational: 'Informational',
|
|
@@ -29,5 +31,5 @@ export const SOURCE_LABELS = {
|
|
|
29
31
|
// invent one, and the reader is better served by the word than by a
|
|
30
32
|
// blank.
|
|
31
33
|
export function severityLabel(severity) {
|
|
32
|
-
return SEVERITY_LABELS[severity] ?? String(severity ?? '')
|
|
34
|
+
return SEVERITY_LABELS[canonicalSeverity(severity)] ?? String(severity ?? '')
|
|
33
35
|
}
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// Only the first path in `relevant_paths` becomes `f.file` (some
|
|
15
15
|
// findings list several, as `path1 | path2 | …`); the rest are dropped.
|
|
16
16
|
|
|
17
|
+
import { normalizeFindingSeverity } from './severity.js'
|
|
18
|
+
|
|
17
19
|
const REQUIRED_COLUMNS = [
|
|
18
20
|
'finding_url', 'repository', 'title', 'description', 'severity',
|
|
19
21
|
'configured_scan_id', 'relevant_paths',
|
|
@@ -143,5 +145,5 @@ function rowToFinding(r) {
|
|
|
143
145
|
if (r.commit_hash) finding.commitHash = r.commit_hash
|
|
144
146
|
if (r.detected_at) finding.detectedAt = r.detected_at
|
|
145
147
|
if (r.committed_at) finding.committedAt = r.committed_at
|
|
146
|
-
return finding
|
|
148
|
+
return normalizeFindingSeverity(finding)
|
|
147
149
|
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// `## SEVERITY (n)` header appears and the chain moves on.
|
|
31
31
|
|
|
32
32
|
import { normalizeNewlines, splitHeadingLine } from './md-structure.js'
|
|
33
|
+
import { normalizeFindingSeverity } from './severity.js'
|
|
33
34
|
|
|
34
35
|
// The `## SEVERITY (n)` header that marks a DeepSec document. Splitting
|
|
35
36
|
// on it with the tier captured interleaves tiers and content:
|
|
@@ -50,6 +51,7 @@ function mapSeverity(s) {
|
|
|
50
51
|
case 'HIGH_BUG': return 'high_bug'
|
|
51
52
|
case 'BUG': return 'bug'
|
|
52
53
|
case 'INFO': case 'INFORMATIONAL': return 'informational'
|
|
54
|
+
case 'INFORMATIVE': return 'informative'
|
|
53
55
|
default: return 'medium'
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -193,5 +195,5 @@ function parseBlock(block, severity) {
|
|
|
193
195
|
if (fields.reasoning) finding.revalidateVerdict = fields.reasoning.replaceAll('**', '')
|
|
194
196
|
}
|
|
195
197
|
if (fields.slug) finding.slug = fields.slug
|
|
196
|
-
return finding
|
|
198
|
+
return normalizeFindingSeverity(finding, severity === 'informative' ? 'medium' : severity)
|
|
197
199
|
}
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
import { locationLabel } from './finding.js'
|
|
57
57
|
import { H2_RE, H3_RE, H4_RE, normalizeNewlines, splitByHeading, splitLeading } from './md-structure.js'
|
|
58
58
|
import { applyFact, buildDescription, narrativeSplit, readAnalyzer, readEvidence, readProse, readRepository, splitFacts, splitSections, tierOf } from './parse-deepview-fields.js'
|
|
59
|
+
import { normalizeFindingSeverity } from './severity.js'
|
|
59
60
|
|
|
60
61
|
const MARKER_RE = /^<!--\s*DeepView findings export\b[^>]*-->/u
|
|
61
62
|
const CASE_RE = /^Case \d+ of \d+(?:\s|$)/u
|
|
@@ -136,7 +137,7 @@ function readCase(body, depth, entryTitle, tier) {
|
|
|
136
137
|
for (const [field, value] of fields) f[field] = value
|
|
137
138
|
const reason = sections.find((s) => s.label.toLowerCase() === 'severity correction')
|
|
138
139
|
if (reason?.body) f.correctedSeverityReason = readProse(reason.body)
|
|
139
|
-
return { finding: f, analyzer }
|
|
140
|
+
return { finding: normalizeFindingSeverity(f), analyzer }
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
// The producer and the run for the whole report: from the Analyzer facts
|
package/report/src/parse-md.js
CHANGED
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
// carry anything mandatory.
|
|
31
31
|
|
|
32
32
|
import { frozenIdBasis } from './parse-md-id.js'
|
|
33
|
+
import { normalizeFindingSeverity } from './severity.js'
|
|
33
34
|
import { LIST_MARKER_RE, findMdLink, normalizeNewlines, splitHeadingLine, unescapeMd } from './md-structure.js'
|
|
34
35
|
|
|
35
|
-
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational'])
|
|
36
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational', 'informative'])
|
|
36
37
|
|
|
37
38
|
export function parseMarkdownFindings(content) {
|
|
38
39
|
const text = normalizeNewlines(content).trim()
|
|
@@ -115,7 +116,7 @@ function parseBlock(block) {
|
|
|
115
116
|
const idBasis = frozenIdBasis(block)
|
|
116
117
|
if (idBasis) finding._idBasis = idBasis
|
|
117
118
|
|
|
118
|
-
return finding
|
|
119
|
+
return normalizeFindingSeverity(finding)
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
// The sections half (before the first `---`) and the metadata half
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { cellValue, parseCodeRef, stripBold, tableObjects } from './md-structure.js'
|
|
7
7
|
import { frozenIdBasis } from './parse-piolium-id.js'
|
|
8
|
+
import { normalizeFindingSeverity } from './severity.js'
|
|
8
9
|
import {
|
|
9
|
-
idCell, leadingId, leadingLink, mapSeverity, severityFromId, slugTitle,
|
|
10
|
+
idCell, leadingId, leadingLink, mapSeverity, resolveSeverity, severityFromId, slugTitle,
|
|
10
11
|
} from './parse-piolium-tokens.js'
|
|
11
12
|
|
|
12
13
|
// Normalize a table-row object to the shared row shape used by the
|
|
@@ -33,10 +34,7 @@ export function indexRowOf(obj) {
|
|
|
33
34
|
// namespaced to read as an opaque token rather than a URL. A Location
|
|
34
35
|
// column, where a table has one, is parsed like any code reference.
|
|
35
36
|
export function fromIndexRow(row, sevFallback = '') {
|
|
36
|
-
const severity = mapSeverity(row.severity)
|
|
37
|
-
|| sevFallback
|
|
38
|
-
|| severityFromId(row.id)
|
|
39
|
-
|| 'medium'
|
|
37
|
+
const { severity, identitySeverity } = resolveSeverity(mapSeverity(row.severity), sevFallback, severityFromId(row.id))
|
|
40
38
|
const { file, line, locationLink } = parseCodeRef(row.location || '')
|
|
41
39
|
const finding = {
|
|
42
40
|
file: file || 'unknown',
|
|
@@ -49,12 +47,12 @@ export function fromIndexRow(row, sevFallback = '') {
|
|
|
49
47
|
// The fingerprint reads the same reference its own way — see
|
|
50
48
|
// parse-piolium-id.js.
|
|
51
49
|
finding._idBasis = frozenIdBasis({
|
|
52
|
-
severity, description: finding.description, ref: row.location || '', id: row.id,
|
|
50
|
+
severity: identitySeverity, description: finding.description, ref: row.location || '', id: row.id,
|
|
53
51
|
})
|
|
54
52
|
if (row.pocStatus) finding.pocStatus = row.pocStatus
|
|
55
53
|
if (row.status) finding.status = row.status
|
|
56
54
|
if (row.parent) finding.parent = row.parent
|
|
57
|
-
return finding
|
|
55
|
+
return normalizeFindingSeverity(finding)
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
// Findings rendered as a list: the mode outline asks for "links to
|
|
@@ -92,10 +90,7 @@ export function listFindings(body, sev, index) {
|
|
|
92
90
|
}
|
|
93
91
|
|
|
94
92
|
const row = index.get(id)
|
|
95
|
-
const severity = mapSeverity(row?.severity)
|
|
96
|
-
|| sev
|
|
97
|
-
|| severityFromId(id)
|
|
98
|
-
|| 'medium'
|
|
93
|
+
const { severity, identitySeverity } = resolveSeverity(mapSeverity(row?.severity), sev, severityFromId(id))
|
|
99
94
|
const finding = { file: 'unknown', line: '?', severity, description: stripBold(title) }
|
|
100
95
|
if (id) finding.location = `piolium:${id}`
|
|
101
96
|
else if (link) finding.location = link
|
|
@@ -103,7 +98,7 @@ export function listFindings(body, sev, index) {
|
|
|
103
98
|
if (row?.pocStatus) finding.pocStatus = row.pocStatus
|
|
104
99
|
if (row?.status) finding.status = row.status
|
|
105
100
|
if (row?.parent) finding.parent = row.parent
|
|
106
|
-
out.push({ id, finding })
|
|
101
|
+
out.push({ id, finding: normalizeFindingSeverity(finding, identitySeverity) })
|
|
107
102
|
}
|
|
108
103
|
return out
|
|
109
104
|
}
|
|
@@ -23,10 +23,20 @@ export function mapSeverity(s) {
|
|
|
23
23
|
case 'MEDIUM': return 'medium'
|
|
24
24
|
case 'LOW': return 'low'
|
|
25
25
|
case 'INFO': case 'INFORMATIONAL': return 'informational'
|
|
26
|
+
case 'INFORMATIVE': return 'informative'
|
|
26
27
|
default: return ''
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// Keep the pre-alias fallback for the ID fingerprint, while displaying the
|
|
32
|
+
// newly recognized tier. Inputs are ordered from strongest to weakest source.
|
|
33
|
+
export function resolveSeverity(...tiers) {
|
|
34
|
+
return {
|
|
35
|
+
severity: tiers.find(Boolean) || 'medium',
|
|
36
|
+
identitySeverity: tiers.find(tier => tier && tier !== 'informative') || 'medium',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
// Final-report ids are severity-prefixed and sequential — `C1`, `H2`,
|
|
31
41
|
// `H-001` in lite consolidation — so the prefix is a second source for
|
|
32
42
|
// the tier. Only that exact scheme counts: a bare leading letter would
|
|
@@ -42,7 +52,7 @@ export function severityFromId(id) {
|
|
|
42
52
|
// findings. Anchored to the whole heading, so "High memory usage in
|
|
43
53
|
// parser" is never mistaken for one.
|
|
44
54
|
export function severityGroupOf(heading) {
|
|
45
|
-
const m = /^(critical|high|medium|low|informational|info)(?:[ -](?:severity|risk))?(?:[ -]findings?)?(?:\s*\(\d+\))?$/iu
|
|
55
|
+
const m = /^(critical|high|medium|low|informational|informative|info)(?:[ -](?:severity|risk))?(?:[ -]findings?)?(?:\s*\(\d+\))?$/iu
|
|
46
56
|
.exec((heading || '').trim())
|
|
47
57
|
return m ? mapSeverity(m[1]) : ''
|
|
48
58
|
}
|
|
@@ -51,7 +61,7 @@ export function severityGroupOf(heading) {
|
|
|
51
61
|
// `High: remaining` — for sections recognized by their CONTENT rather
|
|
52
62
|
// than the anchored severityGroupOf shape.
|
|
53
63
|
export function headerSeverity(header) {
|
|
54
|
-
const m = /^(critical|high|medium|low|informational|info)\b/iu.exec((header || '').trim())
|
|
64
|
+
const m = /^(critical|high|medium|low|informational|informative|info)\b/iu.exec((header || '').trim())
|
|
55
65
|
return m ? mapSeverity(m[1]) : ''
|
|
56
66
|
}
|
|
57
67
|
|
|
@@ -64,11 +64,12 @@
|
|
|
64
64
|
|
|
65
65
|
import { H2_RE, H3_RE, H4_RE, normalizeNewlines, parseCodeRef, parseLabelledFields, splitByHeading, splitLeading, tableObjects } from './md-structure.js'
|
|
66
66
|
import { frozenIdBasis } from './parse-piolium-id.js'
|
|
67
|
+
import { normalizeFindingSeverity } from './severity.js'
|
|
67
68
|
import { fromIndexRow, indexRowOf, listFindings, variantFindings } from './parse-piolium-rows.js'
|
|
68
69
|
import {
|
|
69
70
|
CODE_REF_FIELDS, codeRefOf, headerSeverity, idCell, idFromToken,
|
|
70
71
|
isVariantsHeading, mapSeverity, parseHeading, preambleMeta,
|
|
71
|
-
severityFromId, severityGroupOf,
|
|
72
|
+
resolveSeverity, severityFromId, severityGroupOf,
|
|
72
73
|
} from './parse-piolium-tokens.js'
|
|
73
74
|
|
|
74
75
|
|
|
@@ -295,11 +296,9 @@ function parseBlock(heading, body, index, groupSeverity = '') {
|
|
|
295
296
|
// Precedence: the block's own bullet, the index row, the enclosing
|
|
296
297
|
// group, the id's prefix, then medium — where an unrecognized tier
|
|
297
298
|
// stays visible rather than dropping out.
|
|
298
|
-
const severity =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|| severityFromId(id)
|
|
302
|
-
|| 'medium'
|
|
299
|
+
const { severity, identitySeverity } = resolveSeverity(
|
|
300
|
+
mapSeverity(fields.severity), mapSeverity(row?.severity), groupSeverity, severityFromId(id),
|
|
301
|
+
)
|
|
303
302
|
|
|
304
303
|
const ref = parseCodeRef(codeRefOf(fields))
|
|
305
304
|
// A `**Line:**` / `**Lines:**` bullet supplies the line when the
|
|
@@ -332,7 +331,7 @@ function parseBlock(heading, body, index, groupSeverity = '') {
|
|
|
332
331
|
// not. finding-id.js prefers `_idBasis` when deriving the uuid; read
|
|
333
332
|
// that module's header before touching either side.
|
|
334
333
|
finding._idBasis = frozenIdBasis({
|
|
335
|
-
severity, description: finding.description, ref: codeRefOf(fields), lineBullet, id,
|
|
334
|
+
severity: identitySeverity, description: finding.description, ref: codeRefOf(fields), lineBullet, id,
|
|
336
335
|
})
|
|
337
336
|
// Auxiliary provenance, kept as plain strings so an export can cite
|
|
338
337
|
// the audit's own artifacts — as parse-md.js keeps branch / status.
|
|
@@ -344,7 +343,7 @@ function parseBlock(heading, body, index, groupSeverity = '') {
|
|
|
344
343
|
const parent = row?.parent || (variantOf ? idCell(variantOf[1]) : '')
|
|
345
344
|
if (parent) finding.parent = parent
|
|
346
345
|
|
|
347
|
-
return { id, finding }
|
|
346
|
+
return { id, finding: normalizeFindingSeverity(finding) }
|
|
348
347
|
}
|
|
349
348
|
|
|
350
349
|
// The `## ` sections, keyed case-folded. A repeated header CONCATENATES
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fingerprintOf } from './finding-id.js'
|
|
2
|
+
|
|
3
|
+
// Informative is an input spelling of the existing Informational tier.
|
|
4
|
+
export function canonicalSeverity(value) {
|
|
5
|
+
return typeof value === 'string' && value.trim().toLowerCase() === 'informative' ? 'informational' : value
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Normalize presentation without re-keying stored triage. Markdown parsers
|
|
9
|
+
// can supply the severity their previous fallback used for the fingerprint.
|
|
10
|
+
export function normalizeFindingSeverity(finding, identitySeverity = finding.severity) {
|
|
11
|
+
const severity = canonicalSeverity(finding.severity)
|
|
12
|
+
if (severity !== finding.severity) {
|
|
13
|
+
if (!finding.id && !finding._idBasis) finding._idBasis = fingerprintOf({ ...finding, severity: identitySeverity })
|
|
14
|
+
finding.severity = severity
|
|
15
|
+
}
|
|
16
|
+
if (finding.correctedSeverity !== undefined) finding.correctedSeverity = canonicalSeverity(finding.correctedSeverity)
|
|
17
|
+
return finding
|
|
18
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { Buffer } from 'node:buffer'
|
|
|
17
17
|
|
|
18
18
|
type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
|
|
19
19
|
export type VercelBlobSdk = {
|
|
20
|
+
BlobNotFoundError: new () => Error
|
|
20
21
|
put: (
|
|
21
22
|
pathname: string,
|
|
22
23
|
body: VercelBlobBody,
|
|
@@ -83,24 +84,12 @@ export type VercelBlobSdk = {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// Recognise "blob is gone" errors uniformly across read/write/delete
|
|
86
|
-
// paths so callers can treat them as success (delete) or
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Class-name check ONLY — the SDK's internal mapper turns every API
|
|
93
|
-
// `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
|
|
94
|
-
// leak doesn't reach here. A broader `/does not exist|\b404\b/` match
|
|
95
|
-
// is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
|
|
96
|
-
// does not exist.", so a config fault (revoked token, deleted store)
|
|
97
|
-
// would silently surface as every-blob-missing across reads/unlinks,
|
|
98
|
-
// masking the fatal misconfiguration. The tight name check lets
|
|
99
|
-
// BlobStoreNotFoundError / other classes propagate as real exceptions.
|
|
100
|
-
export function isNotFound(err: unknown): boolean {
|
|
101
|
-
if (err == null || typeof err !== 'object') return false
|
|
102
|
-
const name = (err as { name?: unknown }).name
|
|
103
|
-
return typeof name === 'string' && name === 'BlobNotFoundError'
|
|
87
|
+
// paths so callers can treat them as success (delete) or not-found (read).
|
|
88
|
+
// SDK error subclasses retain `.name === 'Error'`; identify the missing-blob
|
|
89
|
+
// class from the same lazily loaded SDK instance that performs the request.
|
|
90
|
+
// Store/configuration failures must propagate, never masquerade as cache misses.
|
|
91
|
+
export function isNotFound(err: unknown, sdk: Pick<VercelBlobSdk, 'BlobNotFoundError'>): boolean {
|
|
92
|
+
return err instanceof sdk.BlobNotFoundError
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
export async function loadVercelBlobSdk(): Promise<VercelBlobSdk> {
|
|
@@ -195,7 +195,7 @@ function buildStatStaging(sdk: VercelBlobSdk, token: string): BlobBackend['statS
|
|
|
195
195
|
const h = await sdk.head(stagingBlobPath(tag, stagingId), { token })
|
|
196
196
|
return h.size
|
|
197
197
|
} catch (err) {
|
|
198
|
-
if (isNotFound(err)) return null
|
|
198
|
+
if (isNotFound(err, sdk)) return null
|
|
199
199
|
throw err
|
|
200
200
|
}
|
|
201
201
|
}
|
|
@@ -266,7 +266,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
266
266
|
// same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
|
|
267
267
|
// the client the resource is gone for good when it should refetch.
|
|
268
268
|
// See server-e2e/README.md's GET status table.
|
|
269
|
-
if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
|
|
269
|
+
if (isNotFound(err, sdk)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
|
|
270
270
|
throw err
|
|
271
271
|
}
|
|
272
272
|
// SDK returned null (no blob) — same "bytes missing for a live row"
|
|
@@ -297,7 +297,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
297
297
|
// Blob vanished between get() and the head() size fallback (a
|
|
298
298
|
// racing reaper GC) — still the "live row present, bytes gone"
|
|
299
299
|
// transient, so `unavailable` (503), matching the get() path above.
|
|
300
|
-
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
|
|
300
|
+
if (isNotFound(headErr, sdk)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
|
|
301
301
|
throw headErr
|
|
302
302
|
}
|
|
303
303
|
}
|
|
@@ -328,7 +328,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
328
328
|
function buildUnlink(sdk: VercelBlobSdk, token: string, op: 'staging' | 'live', toPath: (tag: string, id: string) => string): (tag: string, id: string) => Promise<void> {
|
|
329
329
|
return async (tag, id) => {
|
|
330
330
|
try { await sdk.del(toPath(tag, id), { token }) } catch (err) {
|
|
331
|
-
if (isNotFound(err)) return
|
|
331
|
+
if (isNotFound(err, sdk)) return
|
|
332
332
|
// Don't propagate — the DB-side row drop has already
|
|
333
333
|
// committed by the time the caller reaches here, and the
|
|
334
334
|
// reaper picks up the stranded blob on its next sweep. Same
|
|
@@ -64,7 +64,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
|
|
|
64
64
|
// Content-Length in that case so HTTP clients do not truncate the body.
|
|
65
65
|
const size = result.blob.size === 0 ? null : result.blob.size
|
|
66
66
|
return { size, stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
|
|
67
|
-
} catch (err) { if (isNotFound(err)) return null; throw err }
|
|
67
|
+
} catch (err) { if (isNotFound(err, blobs)) return null; throw err }
|
|
68
68
|
}
|
|
69
69
|
async function get(path: string) {
|
|
70
70
|
const result = await open(path)
|
|
@@ -74,7 +74,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
|
|
|
74
74
|
return Buffer.concat(parts)
|
|
75
75
|
}
|
|
76
76
|
async function remove(path: string) {
|
|
77
|
-
try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err)) throw err }
|
|
77
|
+
try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err, blobs)) throw err }
|
|
78
78
|
}
|
|
79
79
|
function store(name: string, suffix = ''): BlobStore {
|
|
80
80
|
const path = (id: string) => `.managed/${name}/${validate(id)}${suffix}`
|
|
@@ -90,7 +90,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
|
|
|
90
90
|
const cacheStorage: BundleCacheStorage = {
|
|
91
91
|
async exists(id, file) {
|
|
92
92
|
try { await blobs.head(cachePath(id, file), { token }); return true }
|
|
93
|
-
catch (err) { if (isNotFound(err)) return false; throw err }
|
|
93
|
+
catch (err) { if (isNotFound(err, blobs)) return false; throw err }
|
|
94
94
|
},
|
|
95
95
|
put: (id, file, bytes) => put(cachePath(id, file), bytes),
|
|
96
96
|
async open(id, file) {
|
|
@@ -110,7 +110,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
|
|
|
110
110
|
const reportSourcesStorage: CacheStorage = {
|
|
111
111
|
async exists(key) {
|
|
112
112
|
try { await blobs.head(sourcesPath(key), { token }); return true }
|
|
113
|
-
catch (err) { if (isNotFound(err)) return false; throw err }
|
|
113
|
+
catch (err) { if (isNotFound(err, blobs)) return false; throw err }
|
|
114
114
|
},
|
|
115
115
|
put: (key, bytes) => put(sourcesPath(key), bytes),
|
|
116
116
|
async open(key) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
2
|
import { teamCatalogRevision } from './team-catalog.ts'
|
|
3
|
-
import type
|
|
3
|
+
import { type Role, roleAtLeast } from '../common/managed/roles.ts'
|
|
4
4
|
import type { TeamUserPermissions } from '../common/managed/permissions.ts'
|
|
5
5
|
import type { TriageEntryPatch } from '../common/managed/triage.ts'
|
|
6
6
|
import { preferredSlug } from './slugs.ts'
|
|
@@ -11,6 +11,7 @@ import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts
|
|
|
11
11
|
import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
|
|
12
12
|
import type { ManagedSql } from './sql.ts'
|
|
13
13
|
import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
|
|
14
|
+
import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
|
|
14
15
|
|
|
15
16
|
// A managed user identity (the subset of GitHub's `GET /user` we keep). Input
|
|
16
17
|
// to the upsert; `githubUserId` is the provider lookup key, never exposed to
|
|
@@ -307,7 +308,7 @@ export interface UserTeam {
|
|
|
307
308
|
}
|
|
308
309
|
|
|
309
310
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
310
|
-
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
|
|
311
|
+
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
|
|
311
312
|
// Upsert the identity; returns the user's opaque id (stable across logins).
|
|
312
313
|
// Initial-admin approval comes only from trusted login configuration. It
|
|
313
314
|
// promotes a matching No access identity only while it is the sole user.
|
|
@@ -349,7 +350,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
349
350
|
insertReport(report: ReportRecordInput, now: number): Promise<void>
|
|
350
351
|
// Content reuse is atomic with insertion, including across server instances.
|
|
351
352
|
// Existing copies are kept; the oldest matching row is the stable identity.
|
|
352
|
-
|
|
353
|
+
// HTTP uploads pass sessionId to recheck role/destination in that transaction.
|
|
354
|
+
insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
|
|
353
355
|
getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
|
|
354
356
|
listReports(userId?: string): Promise<AdminReport[]>
|
|
355
357
|
getReport(id: string): Promise<ReportRecord | null>
|
|
@@ -385,7 +387,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
385
387
|
// (referencing reports' bundle_id null out via the FK). linkReportsToBundle
|
|
386
388
|
// attaches a freshly-stored bundle to the (still-unlinked) reports that
|
|
387
389
|
// declared its integrity.
|
|
388
|
-
insertBundle(bundle: BundleInput, now: number): Promise<void>
|
|
390
|
+
insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
|
|
389
391
|
getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
|
|
390
392
|
getBundle(id: string): Promise<ManagedBundle | null>
|
|
391
393
|
listBundles(userId?: string): Promise<AdminBundle[]>
|
|
@@ -904,8 +906,20 @@ type ReportRow = {
|
|
|
904
906
|
bundleId: string | null
|
|
905
907
|
}
|
|
906
908
|
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
+
// Byte storage may outlast a logout or grant change. HTTP uploads reauthorize
|
|
910
|
+
// inside the metadata writer transaction after their bytes have been stored.
|
|
911
|
+
async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sessionId: string | undefined,
|
|
912
|
+
item: { uploadedBy: string | null; repoId: number | null; repoDirectory?: string }) {
|
|
913
|
+
if (sessionId === undefined) return // Trusted imports and fixtures use the raw store API.
|
|
914
|
+
const user = await stmts.selectSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
|
|
915
|
+
if (!user) throw new ManagedMutationError(401, 'unauthenticated')
|
|
916
|
+
if (user.uid !== item.uploadedBy || !roleAtLeast(user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
|
|
917
|
+
if (item.repoId === null) return
|
|
918
|
+
if (!(await stmts.selectReposStmt.all() as RepoRow[]).some(repo => repo.repoId === item.repoId)) throw new ManagedMutationError(400, 'repo-not-selected')
|
|
919
|
+
if (user.role !== 'admin' && !await stmts.selectRepoPathReadableStmt.get(item.repoDirectory ?? '', item.repoId, user.uid)) throw new ManagedMutationError(403, 'repo-forbidden')
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// The report slice of ManagedDb closes over its prepared statements.
|
|
909
923
|
function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
910
924
|
const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
|
|
911
925
|
async function getReport(id: string): Promise<ReportRecord | null> {
|
|
@@ -929,7 +943,8 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
929
943
|
insertReport,
|
|
930
944
|
getReport,
|
|
931
945
|
getReportByHash,
|
|
932
|
-
async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
|
|
946
|
+
async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
|
|
947
|
+
await authorizeUpload(stmts, sessionId, report)
|
|
933
948
|
// The driver holds its writer lock for this whole operation. Checking
|
|
934
949
|
// again here closes the race between the HTTP lookup and blob upload.
|
|
935
950
|
const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
|
|
@@ -1108,7 +1123,8 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
1108
1123
|
selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
|
|
1109
1124
|
} = stmts
|
|
1110
1125
|
return {
|
|
1111
|
-
async insertBundle(bundle: BundleInput, now: number): Promise<void> {
|
|
1126
|
+
async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
|
|
1127
|
+
await authorizeUpload(stmts, sessionId, bundle)
|
|
1112
1128
|
await insertBundleStmt.run(
|
|
1113
1129
|
bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
|
|
1114
1130
|
bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
|
|
@@ -1297,7 +1313,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1297
1313
|
touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
|
|
1298
1314
|
} = stmts
|
|
1299
1315
|
|
|
1300
|
-
|
|
1316
|
+
const methods: Omit<ManagedDb, keyof ManagementStore> = {
|
|
1301
1317
|
async upsertUser(user, now, initialAdminGithubId = null) {
|
|
1302
1318
|
// New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
|
|
1303
1319
|
// user's id (DO UPDATE leaves it untouched), so re-read to return it.
|
|
@@ -1364,4 +1380,5 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1364
1380
|
await db.close()
|
|
1365
1381
|
},
|
|
1366
1382
|
}
|
|
1383
|
+
return { ...methods, ...managementMethods(methods) }
|
|
1367
1384
|
}
|
|
@@ -6,7 +6,7 @@ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-met
|
|
|
6
6
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
7
7
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
8
8
|
import { ACTIVITY_SCHEMA } from './activity.ts'
|
|
9
|
-
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
9
|
+
import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
10
10
|
import { postgresSchema, postgresSql } from './sql-postgres.ts'
|
|
11
11
|
import { managedTableRenames } from './db-table-names.ts'
|
|
12
12
|
import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
|
|
@@ -144,16 +144,23 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
|
|
|
144
144
|
async scope(write, work) {
|
|
145
145
|
if (closed) throw new Error('Managed database is closed')
|
|
146
146
|
const db = await connect()
|
|
147
|
+
let commitAttempted = false
|
|
148
|
+
let result
|
|
147
149
|
try {
|
|
148
150
|
await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
|
|
149
151
|
if (write) await db.query(LOCK)
|
|
150
|
-
|
|
152
|
+
result = await context.run(db, work)
|
|
153
|
+
commitAttempted = true
|
|
151
154
|
await db.query('COMMIT')
|
|
152
|
-
return result
|
|
153
155
|
} catch (err) {
|
|
154
156
|
await db.query('ROLLBACK').catch(() => {})
|
|
157
|
+
await db.release().catch(() => {})
|
|
158
|
+
if (write && commitAttempted) throw new ManagedCommitError(err)
|
|
155
159
|
throw err
|
|
156
|
-
}
|
|
160
|
+
}
|
|
161
|
+
try { await db.release() }
|
|
162
|
+
catch (err) { if (write) throw new ManagedCommitError(err); throw err }
|
|
163
|
+
return result
|
|
157
164
|
},
|
|
158
165
|
close() { closed = true },
|
|
159
166
|
}
|