@preventive/triage 1.0.0-alpha.13 → 1.0.0-alpha.15
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/brotli-fallback.js +3 -3
- package/out/client-admin.js +2 -2
- package/out/client-sync.js +14 -14
- package/out/graph.js +30 -5
- package/out/index.html +47 -7
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +255 -43
- package/out/view.css +1 -1
- package/out/view.js +147 -110
- package/package.json +27 -4
- package/report/index.js +253 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +300 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +51 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +253 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-e2e/bus-receiver.ts +1 -0
- package/server-e2e/hub.ts +37 -6
- package/server-e2e/index.ts +3 -2
- package/server-e2e/objstore/handlers.ts +6 -5
- package/server-e2e/objstore/init.ts +1 -1
- package/server-e2e/objstore/rest-mint.ts +2 -2
- package/server-e2e/objstore/rest.ts +2 -2
- package/server-e2e/pubsub.ts +8 -5
- package/server-e2e/sync-handlers.ts +54 -28
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// One finding as markdown: its heading, the facts that aren't prose as a
|
|
2
|
+
// labelled list, then the narrative in the order a reader needs it. The
|
|
3
|
+
// document (write-md.js) hands in the heading text and the depth; what a
|
|
4
|
+
// finding CARRIES is read here, off the parser's object through
|
|
5
|
+
// finding.js.
|
|
6
|
+
//
|
|
7
|
+
// Nothing is dropped for being unfamiliar to the viewer — a report's
|
|
8
|
+
// status and branch, an audit's PoC state and commit land on the list
|
|
9
|
+
// beside the facts every card shows, and each narrative field gets a
|
|
10
|
+
// section rather than a bold label buried in a paragraph.
|
|
11
|
+
//
|
|
12
|
+
// A dedup group — one finding reported several times — is one heading
|
|
13
|
+
// with a case under it per member, so the reader meets the finding once
|
|
14
|
+
// and its reports as its cases.
|
|
15
|
+
|
|
16
|
+
import { correctedVariants, descriptionSections, displayedSeverity, effectiveSeverity, evidenceNote, findingDisplayName, findingTitle, firstLine, hasSeverityCorrection, locationLabel, revalidateKindOf, runMetaLine, splitDescription, stripExportMarker } from './finding.js'
|
|
17
|
+
import { COLOR_LABELS, SOURCE_LABELS, TRIAGE_LABELS, severityLabel } from './labels.js'
|
|
18
|
+
import { autolink, code, heading, indentUnder, isHttpUrl, joinBlocks, link, plural, prose } from './md-text.js'
|
|
19
|
+
import { isRepoSlug } from './meta.js'
|
|
20
|
+
import { normalizeNewlines } from './md-structure.js'
|
|
21
|
+
|
|
22
|
+
// A heading has to fit on a line, and a JSON finding whose description
|
|
23
|
+
// is one paragraph is NAMED by that paragraph. Past this it is cut, and
|
|
24
|
+
// the body carries the whole name (descriptionBlocks).
|
|
25
|
+
const HEADING_MAX = 120
|
|
26
|
+
|
|
27
|
+
export function findingHeading(f) {
|
|
28
|
+
const title = findingTitle(f) || locationLabel(f) || 'Untitled finding'
|
|
29
|
+
return title.length > HEADING_MAX ? `${title.slice(0, HEADING_MAX - 1).trimEnd()}…` : title
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A repository as a link — a github.com slug points at github.com, a
|
|
33
|
+
// URL at itself, anything else stays the text it is.
|
|
34
|
+
export function repoRef(repo) {
|
|
35
|
+
const s = String(repo ?? '').trim()
|
|
36
|
+
if (isHttpUrl(s)) return autolink(s)
|
|
37
|
+
return isRepoSlug(s) ? link(s, `https://github.com/${s}`) : s
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What produced the finding, as the document names it: a product, which
|
|
41
|
+
// is one analyzer with no runs to tell apart, so its name is the whole
|
|
42
|
+
// answer — or, out of the analyzer's own dump, the run itself (finding.js
|
|
43
|
+
// runMetaLine). What a report filed a finding UNDER, Claude Security's
|
|
44
|
+
// `**Category:**`, is not its analyzer and gets its own line.
|
|
45
|
+
export function analyzerText(f, source, revalidation) {
|
|
46
|
+
if (source) return SOURCE_LABELS[source] ?? String(source)
|
|
47
|
+
return runMetaLine(f, revalidation)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The narrative fields beyond the description, `[heading, field, pass]`
|
|
51
|
+
// in the order the card reads them: what it means, how to trigger it,
|
|
52
|
+
// how to fix it, then the analyzer's and the pass's remarks. The pass's
|
|
53
|
+
// two travel with the revalidation layer (ctx.revalidation).
|
|
54
|
+
const NARRATIVE = [
|
|
55
|
+
['Impact', 'impact', false],
|
|
56
|
+
['Reproduction', 'reproduction', false],
|
|
57
|
+
['Recommendation', 'recommendation', false],
|
|
58
|
+
['Confidence reasoning', 'confidenceReason', false],
|
|
59
|
+
['Revalidation verdict', 'revalidateVerdict', true],
|
|
60
|
+
['Revalidation recommendation', 'revalidateRecommendation', true],
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
// The plain facts a report may attach, `[label, field]`, printed as
|
|
64
|
+
// written and under the name the report used, so a reader of the
|
|
65
|
+
// original recognises each: Claude Security's `Status` / `Branch` /
|
|
66
|
+
// `Date created`, Codex's `detected_at`, Piolium's `PoC status` /
|
|
67
|
+
// `Variant of`, DeepSec's `Slug`. Strings and numbers only — an object
|
|
68
|
+
// has no line to print on.
|
|
69
|
+
const PLAIN_FIELDS = [
|
|
70
|
+
['Status', 'status'], ['Branch', 'branch'], ['Date created', 'dateCreated'],
|
|
71
|
+
['Detected at', 'detectedAt'], ['Committed at', 'committedAt'],
|
|
72
|
+
['PoC status', 'pocStatus'], ['Variant of', 'parent'], ['Slug', 'slug'],
|
|
73
|
+
['Priority', 'priority'],
|
|
74
|
+
]
|
|
75
|
+
// …and the ones that are paths or hashes, set in code.
|
|
76
|
+
const CODE_FIELDS = [['Detailed report', 'reportPath'], ['Commit audited', 'auditedCommit']]
|
|
77
|
+
|
|
78
|
+
// A fact is one line of the list, so a value that arrived with line
|
|
79
|
+
// breaks (a wrapped Piolium bullet) is reflowed onto one — the break
|
|
80
|
+
// would end the list. Prose keeps its lines (proseValue).
|
|
81
|
+
function plainValue(v) {
|
|
82
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
|
83
|
+
return typeof v === 'string' ? v.replaceAll(/\s*\n\s*/gu, ' ').trim() : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function proseValue(v) {
|
|
87
|
+
return typeof v === 'string' ? v.trim() : ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The severity under the reader's lens, with a corrected finding's other
|
|
91
|
+
// value beside it — the document has no toggle, so both are on the page —
|
|
92
|
+
// and the per-report divergence a workspace merge can carry.
|
|
93
|
+
function severityText(f, ctx) {
|
|
94
|
+
const original = ctx.severityMode === 'original'
|
|
95
|
+
let text = severityLabel(displayedSeverity(f, ctx.severityMode))
|
|
96
|
+
if (hasSeverityCorrection(f)) {
|
|
97
|
+
text += original
|
|
98
|
+
? ` — corrected to ${severityLabel(effectiveSeverity(f))}`
|
|
99
|
+
: ` — corrected from ${severityLabel(f.severity)}`
|
|
100
|
+
}
|
|
101
|
+
const variants = correctedVariants(f)
|
|
102
|
+
if (variants) {
|
|
103
|
+
const list = Object.entries(variants).map(([r, v]) => `${r || 'this report'}: ${severityLabel(v?.severity)}`)
|
|
104
|
+
text += ` (varies across reports — ${list.join('; ')})`
|
|
105
|
+
}
|
|
106
|
+
if (f.critical === true) text += ' · flagged critical by the analyzer'
|
|
107
|
+
return text
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function locationText(f, ctx) {
|
|
111
|
+
const label = locationLabel(f)
|
|
112
|
+
if (!label) return ''
|
|
113
|
+
const url = ctx.hooks.location(f)
|
|
114
|
+
const ref = isHttpUrl(url) ? link(code(label), url) : code(label)
|
|
115
|
+
const name = findingDisplayName(f)
|
|
116
|
+
return name ? `${ref} · ${code(name)}` : ref
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// What the reader did with the finding: its triage bucket (or the
|
|
120
|
+
// per-report ignore), its colour mark, its flag — one line.
|
|
121
|
+
function triageText(a) {
|
|
122
|
+
if (!a) return ''
|
|
123
|
+
const parts = []
|
|
124
|
+
const bucket = TRIAGE_LABELS[a.triage] ?? (a.ignored ? TRIAGE_LABELS.ignored : '')
|
|
125
|
+
if (bucket) parts.push(bucket)
|
|
126
|
+
if (a.color) parts.push(`${COLOR_LABELS[a.color] ?? a.color} mark`)
|
|
127
|
+
if (a.flagged === true) parts.push('Flagged')
|
|
128
|
+
return parts.join(' · ')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Whose revalidation pass a stamp came from, in the words this document
|
|
132
|
+
// spells a producer with; an unknown key prints as itself. Written under
|
|
133
|
+
// the stamp, so it travels with the layer and says nothing where the
|
|
134
|
+
// finding's own report ran the pass.
|
|
135
|
+
function sourceText(source) {
|
|
136
|
+
const s = plainValue(source)
|
|
137
|
+
return s ? SOURCE_LABELS[s] ?? s : ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function commitText(f, ctx) {
|
|
141
|
+
const hash = plainValue(f.commitHash)
|
|
142
|
+
if (!hash) return ''
|
|
143
|
+
const url = ctx.hooks.commit(f)
|
|
144
|
+
return isHttpUrl(url) ? link(code(hash.slice(0, 7)), url) : code(hash)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The labelled list under a finding's heading: every fact that isn't
|
|
148
|
+
// prose, in the order the card's rail reads them, then the provenance
|
|
149
|
+
// the report attached, then the id — the one fact that means nothing to
|
|
150
|
+
// a reader and everything to the reader of the file, which keys stored
|
|
151
|
+
// triage off it. A line is written only when its fact is there.
|
|
152
|
+
function metaList(f, ctx, annotation) {
|
|
153
|
+
const rows = []
|
|
154
|
+
const add = (label, value) => { if (value) rows.push(`- **${label}:** ${value}`) }
|
|
155
|
+
add('Location', locationText(f, ctx))
|
|
156
|
+
add('Severity', severityText(f, ctx))
|
|
157
|
+
if (f.confidence !== undefined && f.confidence !== null) add('Confidence', `${f.confidence}/10`)
|
|
158
|
+
if (ctx.showAnalyzer) add('Analyzer', ctx.analyzerOf(f))
|
|
159
|
+
add('Category', plainValue(f.category))
|
|
160
|
+
const kind = ctx.revalidation ? revalidateKindOf(f) : ''
|
|
161
|
+
if (kind) add('Revalidation', kind === 'revalidation' ? 'the revalidation pass itself' : kind)
|
|
162
|
+
if (kind) add('Revalidated by', sourceText(f.revalidateSource))
|
|
163
|
+
add('Triage', triageText(annotation))
|
|
164
|
+
if (annotation?.fix) add('Fix', autolink(String(annotation.fix).trim()))
|
|
165
|
+
if (ctx.showReport) add('Report', code(ctx.hooks.report(f) ?? ''))
|
|
166
|
+
const repo = plainValue(f.repo?.github)
|
|
167
|
+
if (repo && repo !== ctx.repo) add('Repository', repoRef(repo))
|
|
168
|
+
add('Introduced in', commitText(f, ctx))
|
|
169
|
+
const found = plainValue(f.discoveredIn)
|
|
170
|
+
if (found && found !== String(f.file ?? '').trim()) add('Found while analyzing', code(found))
|
|
171
|
+
const npm = f.package?.npm
|
|
172
|
+
const pkg = plainValue(npm?.name)
|
|
173
|
+
if (pkg) add('Package', code(plainValue(npm.version) ? `${pkg}@${plainValue(npm.version)}` : pkg))
|
|
174
|
+
for (const [label, field] of PLAIN_FIELDS) add(label, plainValue(f[field]))
|
|
175
|
+
for (const [label, field] of CODE_FIELDS) add(label, code(plainValue(f[field])))
|
|
176
|
+
add('ID', code(plainValue(f.id)))
|
|
177
|
+
return rows.join('\n')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function section(depth, label, text) {
|
|
181
|
+
const body = prose(text)
|
|
182
|
+
return body ? `${heading(depth, label)}\n\n${body}` : heading(depth, label)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The `## Evidence` rows as a loose numbered list: the reference, linked
|
|
186
|
+
// where the caller can, and the report's note as its own paragraph under
|
|
187
|
+
// it — loose, or a note sharing the reference's line would be reflowed
|
|
188
|
+
// onto it.
|
|
189
|
+
function evidenceList(f, ctx) {
|
|
190
|
+
const rows = Array.isArray(f.evidence) ? f.evidence : []
|
|
191
|
+
return rows.map((row, i) => {
|
|
192
|
+
const marker = `${i + 1}. `
|
|
193
|
+
const label = locationLabel(row)
|
|
194
|
+
const url = ctx.hooks.evidence(row, f, i)
|
|
195
|
+
let ref = ''
|
|
196
|
+
if (label) ref = isHttpUrl(url) ? link(code(label), url) : code(label)
|
|
197
|
+
else if (isHttpUrl(url)) ref = autolink(url)
|
|
198
|
+
const note = prose(evidenceNote(row))
|
|
199
|
+
const head = marker + (ref || '(no reference)')
|
|
200
|
+
return note ? `${head}\n\n${indentUnder(marker, note)}` : head
|
|
201
|
+
}).join('\n\n')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The description's lead, its evidence, then the labelled sections the
|
|
205
|
+
// report wrote — the order a claude-security report writes and the card
|
|
206
|
+
// reads. A `**Label:**` paragraph becomes a section with a heading, as
|
|
207
|
+
// the finding's own impact / reproduction fields do, so a report that
|
|
208
|
+
// wrote those as fields and one that wrote them into its prose read
|
|
209
|
+
// identically.
|
|
210
|
+
function descriptionBlocks(f, ctx, depth) {
|
|
211
|
+
const split = splitDescription(f)
|
|
212
|
+
// Line endings first: the paragraph split below reads blank lines,
|
|
213
|
+
// and a `\r\n\r\n` a JSON report wrote is not one to it.
|
|
214
|
+
const body = normalizeNewlines(split.body)
|
|
215
|
+
// A one-line description IS the heading, and printing it again is a
|
|
216
|
+
// stutter — unless the heading could not carry the whole name
|
|
217
|
+
// (HEADING_MAX), where the body opens on it instead, the only place
|
|
218
|
+
// the whole name appears and where the file's reader finds it.
|
|
219
|
+
const title = findingTitle(f)
|
|
220
|
+
const cut = title !== '' && findingHeading(f) !== title
|
|
221
|
+
const stutter = !cut && !split.title && body.trim() === title
|
|
222
|
+
const carried = cut && firstLine(body) !== title ? `${title}\n\n${body}` : body
|
|
223
|
+
const sections = descriptionSections(stutter ? '' : carried)
|
|
224
|
+
const firstLabel = sections.findIndex((s) => s.label !== null)
|
|
225
|
+
const lead = firstLabel === -1 ? sections : sections.slice(0, firstLabel)
|
|
226
|
+
const rest = firstLabel === -1 ? [] : sections.slice(firstLabel)
|
|
227
|
+
const blocks = lead.map((s) => prose(s.body))
|
|
228
|
+
const evidence = evidenceList(f, ctx)
|
|
229
|
+
if (evidence) blocks.push(`${heading(depth, 'Evidence')}\n\n${evidence}`)
|
|
230
|
+
for (const s of rest) blocks.push(s.label === null ? prose(s.body) : section(depth, s.label, s.body))
|
|
231
|
+
return blocks
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Everything under one case's heading: the facts, the description, the
|
|
235
|
+
// narrative sections, then the reader's comment — about the finding
|
|
236
|
+
// rather than part of it.
|
|
237
|
+
function caseBlocks(f, ctx, depth) {
|
|
238
|
+
const annotation = ctx.hooks.annotation(f)
|
|
239
|
+
const blocks = [metaList(f, ctx, annotation), ...descriptionBlocks(f, ctx, depth)]
|
|
240
|
+
for (const [label, field, pass] of NARRATIVE) {
|
|
241
|
+
if (pass && !ctx.revalidation) continue
|
|
242
|
+
const raw = f[field]
|
|
243
|
+
const value = typeof raw === 'string' ? stripExportMarker(raw, f) : ''
|
|
244
|
+
if (value.trim()) blocks.push(section(depth, label, value))
|
|
245
|
+
}
|
|
246
|
+
if (hasSeverityCorrection(f) && proseValue(f.correctedSeverityReason)) {
|
|
247
|
+
blocks.push(section(depth, 'Severity correction', f.correctedSeverityReason))
|
|
248
|
+
}
|
|
249
|
+
const comment = proseValue(annotation?.comment)
|
|
250
|
+
if (comment) blocks.push(section(depth, 'Comment', comment))
|
|
251
|
+
return blocks
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// One group under its heading: a single case writes straight under it,
|
|
255
|
+
// several get a numbered, located heading each with their sections one
|
|
256
|
+
// level down. A case named differently from the group says so under its
|
|
257
|
+
// own heading.
|
|
258
|
+
export function groupSection(group, ctx, { headingText, depth }) {
|
|
259
|
+
const blocks = [heading(depth, headingText)]
|
|
260
|
+
if (group.length === 1) return joinBlocks([...blocks, ...caseBlocks(group[0], ctx, depth + 1)])
|
|
261
|
+
const reports = [...new Set(group.map((f) => ctx.hooks.report(f)).filter(Boolean))]
|
|
262
|
+
const from = reports.length > 1 ? ` — reported in ${reports.map((r) => code(r)).join(', ')}` : ''
|
|
263
|
+
blocks.push(`${plural(group.length, 'case')} of this finding${from}.`)
|
|
264
|
+
const groupTitle = findingTitle(group[0])
|
|
265
|
+
group.forEach((f, i) => {
|
|
266
|
+
const loc = locationLabel(f)
|
|
267
|
+
blocks.push(heading(depth + 1, `Case ${i + 1} of ${group.length}${loc ? ` — ${code(loc)}` : ''}`))
|
|
268
|
+
const own = findingTitle(f)
|
|
269
|
+
if (own && own !== groupTitle) blocks.push(prose(own))
|
|
270
|
+
blocks.push(...caseBlocks(f, ctx, depth + 2))
|
|
271
|
+
})
|
|
272
|
+
return joinBlocks(blocks)
|
|
273
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// The findings document — what the "Download" button writes: one
|
|
2
|
+
// markdown file a person can read top to bottom or jump around in.
|
|
3
|
+
//
|
|
4
|
+
// The shape, top to bottom:
|
|
5
|
+
//
|
|
6
|
+
// <!-- DeepView findings export -->
|
|
7
|
+
// # <title>
|
|
8
|
+
// - **Source:** … / **Report:** … / **Repository:** … / **Analyzer:** …
|
|
9
|
+
// - **Exported:** … / **View:** … / **Filters:** … / **Included:** N of M findings
|
|
10
|
+
//
|
|
11
|
+
// ## Summary
|
|
12
|
+
// <severity counts> <annotation counts> <index of findings, linked>
|
|
13
|
+
//
|
|
14
|
+
// ## Critical (2)
|
|
15
|
+
// ### 1. <finding> ← write-md-finding.js from here down
|
|
16
|
+
// - **Location:** … the facts
|
|
17
|
+
// <description> #### Evidence #### Impact …
|
|
18
|
+
//
|
|
19
|
+
// The header is the honest part. An export is a SELECTION — the triage
|
|
20
|
+
// view narrowed by the toolbar filters — and a reader who wasn't at the
|
|
21
|
+
// screen has to be told that a file of 12 findings is 12 of 40, and
|
|
22
|
+
// which 28 are missing and why. So the filters ride in the header, in
|
|
23
|
+
// the confirmation dialog's own words and counts.
|
|
24
|
+
//
|
|
25
|
+
// The document is also a report this library READS
|
|
26
|
+
// (parse-deepview-md.js): the first line marks it, every finding carries
|
|
27
|
+
// its id, and what the facts and sections say is what comes back,
|
|
28
|
+
// whichever format the findings first arrived in. So a value goes on the
|
|
29
|
+
// page in a shape the reader can take back off it — a fact on one line,
|
|
30
|
+
// a location in a code span, a line of prose that would read as a
|
|
31
|
+
// heading escaped.
|
|
32
|
+
//
|
|
33
|
+
// `doc` is plain data the caller assembles (ui/view/markdown-export.js,
|
|
34
|
+
// or anything else holding findings out of index.js); `hooks` are the
|
|
35
|
+
// few answers only the caller has — where a location links, what a
|
|
36
|
+
// reader wrote on a finding, which report a case came from. All
|
|
37
|
+
// optional: the defaults link what the report linked and annotate
|
|
38
|
+
// nothing.
|
|
39
|
+
//
|
|
40
|
+
// writeMarkdown({
|
|
41
|
+
// title, workspace, reports: [{ name, source }], repo, generatedAt,
|
|
42
|
+
// view: { bucket, severityMode, revalidation, revalidationDetail },
|
|
43
|
+
// filters: [{ label, value }], counts: { included, total },
|
|
44
|
+
// groups: [ [finding, …], … ], // display order, primary case first
|
|
45
|
+
// }, { annotation, location, evidence, commit, report })
|
|
46
|
+
|
|
47
|
+
import { SEVERITIES, displayedSeverity, locationLabel } from './finding.js'
|
|
48
|
+
import { SOURCE_LABELS, severityLabel } from './labels.js'
|
|
49
|
+
import { analyzerText, findingHeading, groupSection, repoRef } from './write-md-finding.js'
|
|
50
|
+
import { anchorSlug, cell, code, escapeBrackets, formatTimestamp, heading, joinBlocks, link, plural, table } from './md-text.js'
|
|
51
|
+
|
|
52
|
+
// The first line of every document this writes, and what its reader keys
|
|
53
|
+
// on: an HTML comment, invisible rendered, and no other format begins
|
|
54
|
+
// with one. The reader matches the phrase and reads past what follows,
|
|
55
|
+
// so a later document that must be told apart can say so after a comma.
|
|
56
|
+
export const DOCUMENT_MARKER = '<!-- DeepView findings export -->'
|
|
57
|
+
|
|
58
|
+
// What a caller can answer about a finding, and what is assumed when
|
|
59
|
+
// it doesn't: a report's own link for a location or an evidence row
|
|
60
|
+
// still links, nothing else does, and nothing is annotated.
|
|
61
|
+
const DEFAULT_HOOKS = {
|
|
62
|
+
annotation: () => null,
|
|
63
|
+
location: (f) => (typeof f?.location === 'string' ? f.location : null),
|
|
64
|
+
evidence: (row) => (typeof row?.url === 'string' ? row.url : null),
|
|
65
|
+
commit: () => null,
|
|
66
|
+
report: () => null,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function withDefaults(hooks) {
|
|
70
|
+
const out = {}
|
|
71
|
+
for (const [name, fallback] of Object.entries(DEFAULT_HOOKS)) {
|
|
72
|
+
out[name] = typeof hooks?.[name] === 'function' ? hooks[name] : fallback
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Which producer a finding came from — a `source` marker, null for the
|
|
78
|
+
// analyzer's own dump. Its own when it carries one: a re-imported
|
|
79
|
+
// document that mixed products stamps each product's findings, and a
|
|
80
|
+
// finding stays that product's whatever report it now sits in.
|
|
81
|
+
// Otherwise its report's, found by the `report` hook's name in
|
|
82
|
+
// `doc.reports` when the document holds more than one.
|
|
83
|
+
function sourceReader(reports, hooks) {
|
|
84
|
+
const own = (f) => (typeof f?.source === 'string' && f.source ? f.source : null)
|
|
85
|
+
if (reports.length === 1) return (f) => own(f) ?? reports[0].source ?? null
|
|
86
|
+
const byName = new Map(reports.map((r) => [r.name, r.source ?? null]))
|
|
87
|
+
return (f) => own(f) ?? byName.get(hooks.report(f)) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The per-document decisions, made once: the severity lens, whether the
|
|
91
|
+
// revalidation layer is applied, and whether the per-finding analyzer
|
|
92
|
+
// and report lines say anything — written only where they vary, so a
|
|
93
|
+
// single-run report isn't told forty times which run it was.
|
|
94
|
+
function buildContext(doc, hooks, cases) {
|
|
95
|
+
const revalidation = doc.view?.revalidation !== false
|
|
96
|
+
const reports = (Array.isArray(doc.reports) ? doc.reports : []).filter((r) => r && typeof r === 'object')
|
|
97
|
+
const sourceOf = sourceReader(reports, hooks)
|
|
98
|
+
const analyzerOf = (f) => analyzerText(f, sourceOf(f), revalidation)
|
|
99
|
+
const analyzers = new Set(cases.map(analyzerOf))
|
|
100
|
+
const names = new Set(cases.map((f) => hooks.report(f)).filter(Boolean))
|
|
101
|
+
return {
|
|
102
|
+
hooks,
|
|
103
|
+
analyzerOf,
|
|
104
|
+
severityMode: doc.view?.severityMode === 'original' ? 'original' : 'corrected',
|
|
105
|
+
revalidation,
|
|
106
|
+
showAnalyzer: analyzers.size > 1,
|
|
107
|
+
showReport: names.size > 1 || reports.length > 1,
|
|
108
|
+
repo: typeof doc.repo === 'string' && doc.repo ? doc.repo : null,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The view the selection was made in, as one line: which triage
|
|
113
|
+
// bucket, which severity lens (when the set carries corrections), and
|
|
114
|
+
// whether the revalidation pass is applied (when it carries one).
|
|
115
|
+
function viewText(view) {
|
|
116
|
+
if (!view) return ''
|
|
117
|
+
const parts = [view.bucket ? `${view.bucket} findings` : 'Live findings']
|
|
118
|
+
if (view.severityMode === 'original') parts.push('original analyzer severities')
|
|
119
|
+
else if (view.severityMode === 'corrected') parts.push('corrected severities')
|
|
120
|
+
if (view.revalidation === false) parts.push('code view — the revalidation pass is not applied')
|
|
121
|
+
else if (view.revalidation === true) {
|
|
122
|
+
// Which app view: the verdict standing in for the rows it re-rated
|
|
123
|
+
// (the default on screen, folded here as there), or the detailed one
|
|
124
|
+
// that lists them. A caller that doesn't track the detail says the
|
|
125
|
+
// layer is applied and no more.
|
|
126
|
+
if (view.revalidationDetail === true) parts.push('detailed app view — the revalidation pass is applied, with the rows it re-rated')
|
|
127
|
+
else if (view.revalidationDetail === false) parts.push('app view — the revalidation pass is applied, standing in for the rows it re-rated')
|
|
128
|
+
else parts.push('app view — the revalidation pass is applied')
|
|
129
|
+
}
|
|
130
|
+
return parts.join(' · ')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function includedText(counts) {
|
|
134
|
+
if (!counts) return ''
|
|
135
|
+
const included = Number(counts.included) || 0
|
|
136
|
+
const total = Number(counts.total) || 0
|
|
137
|
+
if (total === 0) return 'no findings'
|
|
138
|
+
if (included >= total) return `all ${plural(total, 'finding')}`
|
|
139
|
+
return `${included} of ${plural(total, 'finding')} (${total - included} filtered out)`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The header list: what was exported, from where, when, and under which
|
|
143
|
+
// view and filters. Each line is written only when it has something to
|
|
144
|
+
// say, and the filter line says "none" outright, so its absence never
|
|
145
|
+
// has to be interpreted.
|
|
146
|
+
//
|
|
147
|
+
// `Source` names the products the loaded reports came from, `Analyzer`
|
|
148
|
+
// what produced the included findings. For one product they are the same
|
|
149
|
+
// word and the analyzer line is left out — but only when it would say
|
|
150
|
+
// exactly what Source says, the same products and no fewer: two products
|
|
151
|
+
// filtered down to one name the one, and a document holding the
|
|
152
|
+
// analyzer's own runs lists every analyzer. The reader takes a single
|
|
153
|
+
// analyzer named here as every finding's.
|
|
154
|
+
function headerList(doc, ctx, cases) {
|
|
155
|
+
const rows = []
|
|
156
|
+
const add = (label, value) => { if (value) rows.push(`- **${label}:** ${value}`) }
|
|
157
|
+
const reports = Array.isArray(doc.reports) ? doc.reports : []
|
|
158
|
+
const sources = [...new Set(reports.map((r) => SOURCE_LABELS[r?.source] ?? '').filter(Boolean))]
|
|
159
|
+
add('Source', sources.join(', '))
|
|
160
|
+
const names = reports.map((r) => r?.name).filter(Boolean)
|
|
161
|
+
add(names.length === 1 ? 'Report' : 'Reports', names.map((n) => code(n)).join(', '))
|
|
162
|
+
add('Workspace', typeof doc.workspace === 'string' ? doc.workspace.trim() : '')
|
|
163
|
+
if (ctx.repo) add('Repository', repoRef(ctx.repo))
|
|
164
|
+
const analyzers = [...new Set(cases.map(ctx.analyzerOf).filter(Boolean))]
|
|
165
|
+
const saidAlready = analyzers.length === sources.length && analyzers.every((a) => sources.includes(a))
|
|
166
|
+
if (analyzers.length > 0 && !saidAlready) add(analyzers.length === 1 ? 'Analyzer' : 'Analyzers', analyzers.join('; '))
|
|
167
|
+
if (doc.generatedAt) add('Exported', formatTimestamp(doc.generatedAt))
|
|
168
|
+
add('View', viewText(doc.view))
|
|
169
|
+
if (Array.isArray(doc.filters)) {
|
|
170
|
+
add('Filters', doc.filters.length > 0 ? doc.filters.map((f) => `${f.label}: ${f.value}`).join(' · ') : 'none')
|
|
171
|
+
}
|
|
172
|
+
add('Included', includedText(doc.counts))
|
|
173
|
+
return rows.join('\n')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The groups bucketed by the severity their primary case displays under,
|
|
177
|
+
// in ladder order — an unknown tier last, as the report spelt it —
|
|
178
|
+
// numbered through the document, each with its heading and anchor.
|
|
179
|
+
function documentEntries(groups, ctx) {
|
|
180
|
+
const buckets = new Map()
|
|
181
|
+
for (const g of groups) {
|
|
182
|
+
const severity = displayedSeverity(g[0], ctx.severityMode) ?? 'informational'
|
|
183
|
+
if (!buckets.has(severity)) buckets.set(severity, [])
|
|
184
|
+
buckets.get(severity).push(g)
|
|
185
|
+
}
|
|
186
|
+
const order = [...SEVERITIES, ...[...buckets.keys()].filter((s) => !SEVERITIES.includes(s))]
|
|
187
|
+
const taken = new Set()
|
|
188
|
+
const entries = []
|
|
189
|
+
for (const severity of order) {
|
|
190
|
+
for (const group of buckets.get(severity) ?? []) {
|
|
191
|
+
const number = entries.length + 1
|
|
192
|
+
const title = findingHeading(group[0])
|
|
193
|
+
const headingText = `${number}. ${title}`
|
|
194
|
+
entries.push({ group, number, severity, title, headingText, slug: anchorSlug(headingText, taken) })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return entries
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Findings per tier, in entry order — which is ladder order, since the
|
|
201
|
+
// entries were bucketed that way.
|
|
202
|
+
function severityCounts(entries) {
|
|
203
|
+
const counts = new Map()
|
|
204
|
+
for (const e of entries) counts.set(e.severity, (counts.get(e.severity) ?? 0) + 1)
|
|
205
|
+
return counts
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function annotationSummary(entries, ctx) {
|
|
209
|
+
const tally = { flagged: 0, marked: 0, commented: 0, fixed: 0 }
|
|
210
|
+
for (const { group } of entries) {
|
|
211
|
+
for (const f of group) {
|
|
212
|
+
const a = ctx.hooks.annotation(f)
|
|
213
|
+
if (!a) continue
|
|
214
|
+
if (a.flagged === true) tally.flagged++
|
|
215
|
+
if (a.color) tally.marked++
|
|
216
|
+
if (a.comment) tally.commented++
|
|
217
|
+
if (a.fix) tally.fixed++
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const parts = []
|
|
221
|
+
if (tally.flagged) parts.push(`${tally.flagged} flagged`)
|
|
222
|
+
if (tally.marked) parts.push(`${tally.marked} colour-marked`)
|
|
223
|
+
if (tally.commented) parts.push(`${tally.commented} commented`)
|
|
224
|
+
if (tally.fixed) parts.push(`${tally.fixed} with a fix link`)
|
|
225
|
+
return parts.length > 0 ? `Annotations: ${parts.join(', ')}.` : ''
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// One row per finding, linked to its section, so a reader sees the whole
|
|
229
|
+
// report on one screen and can jump. The confidence column appears only
|
|
230
|
+
// when something has a confidence.
|
|
231
|
+
function indexTable(entries) {
|
|
232
|
+
const withConfidence = entries.some(({ group }) => group.some((f) => f.confidence !== undefined && f.confidence !== null))
|
|
233
|
+
const headers = ['#', 'Severity', 'Finding', 'Location']
|
|
234
|
+
if (withConfidence) headers.push('Confidence')
|
|
235
|
+
const rows = entries.map((e) => {
|
|
236
|
+
const primary = e.group[0]
|
|
237
|
+
const cases = e.group.length > 1 ? ` (${plural(e.group.length, 'case')})` : ''
|
|
238
|
+
const loc = locationLabel(primary)
|
|
239
|
+
const row = [String(e.number), severityLabel(e.severity), link(cell(escapeBrackets(e.title)), `#${e.slug}`) + cases, loc ? cell(code(loc)) : '']
|
|
240
|
+
if (withConfidence) row.push(primary.confidence === undefined || primary.confidence === null ? '' : `${primary.confidence}/10`)
|
|
241
|
+
return row
|
|
242
|
+
})
|
|
243
|
+
return table(headers, rows, ['right'])
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function summaryBlocks(entries, ctx) {
|
|
247
|
+
const blocks = [heading(2, 'Summary')]
|
|
248
|
+
if (entries.length === 0) return [...blocks, 'No findings are included.']
|
|
249
|
+
const rows = [...severityCounts(entries)].map(([s, n]) => [severityLabel(s), String(n)])
|
|
250
|
+
rows.push(['**Total**', `**${entries.length}**`])
|
|
251
|
+
blocks.push(table(['Severity', 'Findings'], rows, ['left', 'right']))
|
|
252
|
+
const notes = annotationSummary(entries, ctx)
|
|
253
|
+
if (notes) blocks.push(notes)
|
|
254
|
+
blocks.push(indexTable(entries))
|
|
255
|
+
return blocks
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// One `## <Severity> (n)` section per tier present, the findings under
|
|
259
|
+
// it in the order they arrived — the caller's sort.
|
|
260
|
+
function severitySections(entries, ctx) {
|
|
261
|
+
const counts = severityCounts(entries)
|
|
262
|
+
const blocks = []
|
|
263
|
+
let current = null
|
|
264
|
+
for (const e of entries) {
|
|
265
|
+
if (e.severity !== current) {
|
|
266
|
+
current = e.severity
|
|
267
|
+
blocks.push(heading(2, `${severityLabel(current)} (${counts.get(current)})`))
|
|
268
|
+
}
|
|
269
|
+
blocks.push(groupSection(e.group, ctx, { headingText: e.headingText, depth: 3 }))
|
|
270
|
+
}
|
|
271
|
+
return blocks
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function writeMarkdown(doc = {}, hooks = {}) {
|
|
275
|
+
const h = withDefaults(hooks)
|
|
276
|
+
const groups = (Array.isArray(doc.groups) ? doc.groups : [])
|
|
277
|
+
.map((g) => (Array.isArray(g) ? g : [g]))
|
|
278
|
+
.map((g) => g.filter((f) => f && typeof f === 'object'))
|
|
279
|
+
.filter((g) => g.length > 0)
|
|
280
|
+
const cases = groups.flat()
|
|
281
|
+
const ctx = buildContext(doc, h, cases)
|
|
282
|
+
const entries = documentEntries(groups, ctx)
|
|
283
|
+
const blocks = [
|
|
284
|
+
DOCUMENT_MARKER,
|
|
285
|
+
heading(1, typeof doc.title === 'string' && doc.title.trim() ? doc.title.trim() : 'Findings'),
|
|
286
|
+
headerList(doc, ctx, cases),
|
|
287
|
+
...summaryBlocks(entries, ctx),
|
|
288
|
+
...severitySections(entries, ctx),
|
|
289
|
+
]
|
|
290
|
+
return `${joinBlocks(blocks)}\n`
|
|
291
|
+
}
|
package/server-e2e/hub.ts
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
// testable and reasoned about on its own.
|
|
8
8
|
|
|
9
9
|
import type { WebSocket } from 'ws'
|
|
10
|
+
import { Buffer } from 'node:buffer'
|
|
10
11
|
import type { PeerRegistry } from './peer.ts'
|
|
11
12
|
|
|
12
13
|
export type Hub = {
|
|
14
|
+
// Buffer broadcasts during async subscription snapshots. The returned
|
|
15
|
+
// release flushes them after the snapshot/chain replies, preserving order.
|
|
16
|
+
pauseBroadcasts(socket: WebSocket): () => void
|
|
13
17
|
subscribe(socket: WebSocket, tag: string): void
|
|
14
18
|
unsubscribeAll(socket: WebSocket): void
|
|
15
19
|
send(socket: WebSocket, msg: object): void
|
|
@@ -38,18 +42,34 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
38
42
|
// workspaceTag → Set<WebSocket>. The per-socket reverse index lives on
|
|
39
43
|
// `Peer.tags` (see ./peer.ts) and is read by `unsubscribeAll` on close.
|
|
40
44
|
const subscribers = new Map<string, Set<WebSocket>>()
|
|
45
|
+
const paused = new WeakMap<WebSocket, { depth: number; payloads: string[]; bytes: number }>()
|
|
46
|
+
|
|
47
|
+
function pauseBroadcasts(socket: WebSocket): () => void {
|
|
48
|
+
const pending = paused.get(socket) ?? { depth: 0, payloads: [], bytes: 0 }
|
|
49
|
+
pending.depth++
|
|
50
|
+
paused.set(socket, pending)
|
|
51
|
+
let released = false
|
|
52
|
+
return () => {
|
|
53
|
+
if (released) return
|
|
54
|
+
released = true
|
|
55
|
+
if (--pending.depth > 0 || paused.get(socket) !== pending) return
|
|
56
|
+
paused.delete(socket)
|
|
57
|
+
for (const payload of pending.payloads) sendRaw(socket, payload)
|
|
58
|
+
pending.payloads.length = 0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
41
61
|
|
|
42
62
|
function subscribe(socket: WebSocket, tag: string): void {
|
|
43
63
|
let set = subscribers.get(tag)
|
|
44
|
-
if (!set) {
|
|
45
|
-
set = new Set()
|
|
46
|
-
subscribers.set(tag, set)
|
|
47
|
-
}
|
|
64
|
+
if (!set) { set = new Set(); subscribers.set(tag, set) }
|
|
48
65
|
set.add(socket)
|
|
49
66
|
peers.get(socket)?.tags.add(tag)
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
function unsubscribeAll(socket: WebSocket): void {
|
|
70
|
+
const pending = paused.get(socket)
|
|
71
|
+
if (pending) pending.payloads.length = 0
|
|
72
|
+
paused.delete(socket)
|
|
53
73
|
const tags = peers.get(socket)?.tags
|
|
54
74
|
if (!tags) return
|
|
55
75
|
for (const tag of tags) {
|
|
@@ -102,6 +122,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
102
122
|
}
|
|
103
123
|
|
|
104
124
|
function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
|
|
125
|
+
const payloadBytes = Buffer.byteLength(payload)
|
|
105
126
|
// Snapshot before iterating — a socket transitioning to CLOSED
|
|
106
127
|
// mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
|
|
107
128
|
// mutating `set` while we walk it. The snapshot also keeps a future
|
|
@@ -109,9 +130,19 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
109
130
|
// subscribers. Audit M4 round-3.
|
|
110
131
|
for (const s of [...set]) {
|
|
111
132
|
if (s === except) continue
|
|
112
|
-
|
|
133
|
+
const pending = paused.get(s)
|
|
134
|
+
if (!pending) { sendRaw(s, payload); continue }
|
|
135
|
+
pending.bytes += payloadBytes
|
|
136
|
+
if (pending.bytes > maxBufferedBytes) {
|
|
137
|
+
// Snapshot waits must obey the same memory bound as socket writes.
|
|
138
|
+
pending.payloads.length = 0
|
|
139
|
+
paused.delete(s)
|
|
140
|
+
try { s.terminate() } catch {}
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
pending.payloads.push(payload)
|
|
113
144
|
}
|
|
114
145
|
}
|
|
115
146
|
|
|
116
|
-
return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
|
|
147
|
+
return { pauseBroadcasts, subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
|
|
117
148
|
}
|
package/server-e2e/index.ts
CHANGED
|
@@ -289,12 +289,13 @@ const publishRevision = (tag: string, revisionId: string): void => {
|
|
|
289
289
|
const publishObjPut = (tag: string, resourceTag: string): void => {
|
|
290
290
|
pubsub.publish({ kind: 'objput', tag, res: resourceTag })
|
|
291
291
|
}
|
|
292
|
-
const publishObjDeleted = (tag: string, resourceTag: string, version: number): void => {
|
|
293
|
-
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
|
|
292
|
+
const publishObjDeleted = (tag: string, resourceTag: string, version: number, incarnation: string): void => {
|
|
293
|
+
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version, incarnation })
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
297
297
|
handle, send, broadcast, publishRevision, subscribe, getNonce,
|
|
298
|
+
pauseBroadcasts: hub.pauseBroadcasts,
|
|
298
299
|
requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
|
|
299
300
|
// Folds the objstore inventory into the `workspace-subscribed` ack.
|
|
300
301
|
// The objstore store keeps its own richer `Handle`, so we wire the
|