@preventive/triage 1.0.0-alpha.14 → 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.
Files changed (40) hide show
  1. package/out/brotli-fallback.js +3 -3
  2. package/out/client-admin.js +2 -2
  3. package/out/client-sync.js +14 -14
  4. package/out/graph.js +30 -5
  5. package/out/index.html +47 -7
  6. package/out/prism.js +2 -2
  7. package/out/stasis.svg +45 -0
  8. package/out/terminal.js +255 -45
  9. package/out/view.css +1 -1
  10. package/out/view.js +144 -109
  11. package/package.json +27 -4
  12. package/report/index.js +253 -0
  13. package/report/src/finding-id.js +80 -0
  14. package/report/src/finding.js +300 -0
  15. package/report/src/labels.js +33 -0
  16. package/report/src/md-structure.js +471 -0
  17. package/report/src/md-text.js +167 -0
  18. package/report/src/meta.js +51 -0
  19. package/report/src/parse-codex.js +147 -0
  20. package/report/src/parse-deepsec.js +197 -0
  21. package/report/src/parse-deepview-fields.js +375 -0
  22. package/report/src/parse-deepview-md.js +185 -0
  23. package/report/src/parse-md-id.js +137 -0
  24. package/report/src/parse-md.js +253 -0
  25. package/report/src/parse-piolium-id.js +79 -0
  26. package/report/src/parse-piolium-rows.js +131 -0
  27. package/report/src/parse-piolium-tokens.js +175 -0
  28. package/report/src/parse-piolium.js +400 -0
  29. package/report/src/utf8.js +21 -0
  30. package/report/src/write-md-finding.js +273 -0
  31. package/report/src/write-md.js +291 -0
  32. package/server-e2e/bus-receiver.ts +1 -0
  33. package/server-e2e/hub.ts +37 -6
  34. package/server-e2e/index.ts +3 -2
  35. package/server-e2e/objstore/handlers.ts +6 -5
  36. package/server-e2e/objstore/init.ts +1 -1
  37. package/server-e2e/objstore/rest-mint.ts +2 -2
  38. package/server-e2e/objstore/rest.ts +2 -2
  39. package/server-e2e/pubsub.ts +8 -5
  40. package/server-e2e/sync-handlers.ts +54 -28
@@ -0,0 +1,300 @@
1
+ // What one finding IS, read off the parsed object: the severity it
2
+ // displays under, the revalidation stamp it carries, the run it came
3
+ // from, the export marker to strip, how its text splits into a name and
4
+ // a body. The viewer and write-md.js beside it both read a parser's
5
+ // object through these; format.js re-exports every name. Pure — no DOM,
6
+ // no app state, nothing above `report/` — so a viewer switch arrives as
7
+ // an argument (`displayedSeverity`, `runMetaLine`).
8
+
9
+ import { fenceRanges, inFence } from './md-structure.js'
10
+ import { SOURCE_LABELS } from './labels.js'
11
+
12
+ // Severity ranking — higher = more severe. Two stacks: vulnerabilities
13
+ // (critical → low) over bug-class findings (high_bug → bug), with
14
+ // informational at the bottom; only DeepSec emits the bug tiers. A new
15
+ // tier goes here, and every other list keys off SEVERITIES below.
16
+ export const SEVERITY_ORDER = {
17
+ critical: 6, high: 5, medium: 4, low: 3,
18
+ high_bug: 2, bug: 1, informational: 0,
19
+ }
20
+ // Highest-to-lowest iteration order.
21
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational']
22
+
23
+ // ── Corrected severity ───────────────────────────────────────────────
24
+ // `correctedSeverity`, with a free-text `correctedSeverityReason`, is a
25
+ // report's own re-rating of the analyzer's `severity`, and PER-REPORT
26
+ // where `severity` is not: the id hashes `severity`, so a dedupe keeps
27
+ // each occurrence's corrected value on the survivor in
28
+ // `f._correctedByReport`.
29
+ //
30
+ // Every display, count and sort resolves severity through the helpers
31
+ // below; IDENTITY — the id fingerprint, dedupe keys — stays raw.
32
+
33
+ // Only a known tier counts: an unrecognised one would sort to rank 0 and
34
+ // render an uncolored badge, so the intrinsic severity stands instead.
35
+ function validCorrected(corrected) {
36
+ return corrected != null && corrected in SEVERITY_ORDER ? corrected : null
37
+ }
38
+
39
+ // The finding's own effective severity. It carries its own report's
40
+ // correction — it IS that report's finding — so no report key is needed;
41
+ // divergence across reports comes from correctedVariants.
42
+ export function effectiveSeverity(f) {
43
+ return validCorrected(f?.correctedSeverity) ?? f?.severity
44
+ }
45
+
46
+ // True when the finding carries a valid correction that actually changes
47
+ // the tier — the trigger for the dual badge / reason affordance.
48
+ export function hasSeverityCorrection(f) {
49
+ const c = validCorrected(f?.correctedSeverity)
50
+ return c != null && c !== f?.severity
51
+ }
52
+
53
+ // Every display, count and sort site reads severity through this with the
54
+ // current lens (`state.severityMode`), not off `f.severity`.
55
+ export function displayedSeverity(f, mode) {
56
+ return mode === 'original' ? f?.severity : effectiveSeverity(f)
57
+ }
58
+
59
+ // The per-report map of a deduped survivor, but only where the reports
60
+ // disagree — the "varies across reports" hint. null otherwise.
61
+ export function correctedVariants(f) {
62
+ const byReport = f?._correctedByReport
63
+ if (!byReport) return null
64
+ const tiers = new Set(Object.values(byReport).map((v) => v?.severity))
65
+ return tiers.size > 1 ? byReport : null
66
+ }
67
+
68
+ // ── Revalidation ─────────────────────────────────────────────────────
69
+ // A second pass over a finding. `revalidate` is what it concluded —
70
+ // `confirmed` (the finding stands), `partial` (part of it does),
71
+ // `refuted` (it doesn't), `unreachable` (nothing can reach the code),
72
+ // `unknown` (it couldn't tell) — with its reasoning in
73
+ // `revalidateVerdict` and, for a refutation, `revalidateRecommendation`.
74
+ // `revalidation` is the odd one out: the row that IS the pass rather
75
+ // than one it judged, carrying no verdict. `revalidateSource` names
76
+ // WHOSE pass said so, keyed like a report's `source`, and earns its keep
77
+ // when dedup carries a stamp onto another report's finding.
78
+ //
79
+ // One of these words or nothing — an unrecognised value is no stamp.
80
+ export const REVALIDATE_KINDS = ['revalidation', 'refuted', 'unreachable', 'confirmed', 'partial', 'unknown']
81
+ const REVALIDATE_SET = new Set(REVALIDATE_KINDS)
82
+
83
+ // The outcome as the DATA has it, '' when there is none; the viewer's
84
+ // `revalidateKind` is this behind the layer switch.
85
+ //
86
+ // Read as written: `Refuted ` is not this field's value. A drifted
87
+ // spelling is folded back where a person can edit one — a DOCUMENT
88
+ // (parse-deepview-fields.js readRevalidation) — and past that boundary
89
+ // case-folding it would say the opposite of what the analyzer wrote.
90
+ export function revalidateKindOf(f) {
91
+ return REVALIDATE_SET.has(f?.revalidate) ? f.revalidate : ''
92
+ }
93
+
94
+ // Does this finding belong to the APP layer — the code as the
95
+ // application runs it — rather than the source underneath? Another
96
+ // product's does by construction: its producer looked at the
97
+ // application, and a `source` in labels.js is exactly "not DeepView's own
98
+ // dump". DeepView's own are source-layer, except the row that IS its
99
+ // revalidation pass, which describes the run and not a line of code.
100
+ //
101
+ // `source` comes in separately because callers resolve it against the
102
+ // report's marker first. The sole definition of the split; readers take
103
+ // it off the finding as `isApp`, stamped once.
104
+ export function isAppFinding(f, source = f?.source) {
105
+ return Object.hasOwn(SOURCE_LABELS, source) || revalidateKindOf(f) === 'revalidation'
106
+ }
107
+
108
+ // ── Run meta ─────────────────────────────────────────────────────────
109
+ export function prettyModel(model) {
110
+ if (!model) return model
111
+ return model.replace(/^[^/]+\//u, '').replace(/^claude-/u, '').replaceAll('-', ' ')
112
+ }
113
+
114
+ // The run a finding came from, as one line — analyzer type, model,
115
+ // effort, exports mode, ` · `-joined, absent fields elided. Every
116
+ // surface that prints it (card, table row, bundle source rows, the
117
+ // markdown export) comes here, so the field list and separator can't
118
+ // drift apart.
119
+ //
120
+ // The revalidation row names itself right after the mode it ran in
121
+ // (`security · revalidate · opus 5 · …`) — that row only, not a verdict
122
+ // row the pass produced. `revalidation` is whether the layer is
123
+ // applied; off, the pass's name goes with the rest of it.
124
+ export function runMetaLine(f, revalidation = true) {
125
+ const pass = revalidation && revalidateKindOf(f) === 'revalidation' ? 'revalidate' : ''
126
+ return [f?.type, pass, prettyModel(f?.model), f?.effort, f?.exportsMode]
127
+ .filter(Boolean).join(' · ')
128
+ }
129
+
130
+ // ── Export markers ───────────────────────────────────────────────────
131
+ // Isolate mode injects `[export: <name>]` markers and a `(<name>): `
132
+ // lead-in so a merged per-file response stays traceable to individual
133
+ // exports (src/isolate.js). Once post-process has lifted the name into
134
+ // `f.exportName` / `f.methodName`, the inline copy only repeats it, so a
135
+ // marker or prefix naming either field comes off; one naming something
136
+ // else is context ("this export affects <other>") and stays.
137
+ //
138
+ // In isolate mode a leading marker or `(<any>): [export: <any>] ` prefix
139
+ // comes off whatever it names, since that can be a sibling export. That
140
+ // pass runs FIRST, or the per-name strip could decapitate the prefix and
141
+ // leave the `(…): ` lead-in stranded.
142
+ export function stripExportMarker(text, f) {
143
+ if (!text) return text
144
+ let result = text
145
+ if (f?.exportsMode === 'isolate') {
146
+ // One level of nested `()` — `` (first branch of `bar()`) ``.
147
+ // Deeper nesting doesn't match at all, leaving the prose intact
148
+ // rather than over-stripping it.
149
+ result = result.replace(/^\((?:[^()]|\([^()]*\))*\): \[export:\s*\w+\] /u, '')
150
+ result = result.replace(/^\[export:\s*\w+\] /u, '')
151
+ }
152
+ const names = [f?.exportName, f?.methodName].filter(Boolean)
153
+ .map((name) => name.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
154
+ // Every marker first, then the prefixes. The two passes are ordered,
155
+ // not merely grouped: a `(Foo): ` prefix can sit BEHIND a marker
156
+ // naming the OTHER name — `[export: bar] (Foo): …` — and the prefix
157
+ // strip only ever looks at the front of the text, so a per-name pass
158
+ // that checked the prefix before the other name's marker came off
159
+ // would leave it there.
160
+ for (const name of names) result = result.replaceAll(new RegExp(`\\[export:\\s*${name}\\]\\s*`, 'gu'), '')
161
+ for (const name of names) result = result.replace(new RegExp(`^\\(\`?${name}\`?\\): `, 'u'), '')
162
+ return result
163
+ }
164
+
165
+ // The export/method location as a label: `exportName.methodName` for a
166
+ // class export with a specific method, one name when they agree, '' when
167
+ // there is neither.
168
+ export function findingDisplayName(f) {
169
+ const e = f?.exportName
170
+ const m = f?.methodName
171
+ if (e && m && e !== m) return `${e}.${m}`
172
+ return e || m || ''
173
+ }
174
+
175
+ // ── Finding title ────────────────────────────────────────────────────
176
+ // What the finding is CALLED: the `title` field when a report has one,
177
+ // else the description's first line — where every markdown import puts
178
+ // the finding's heading, and where a JSON finding's one-paragraph
179
+ // description stands in for a name. Every surface that names a finding
180
+ // comes through here, so a report's own title reaches all of them or
181
+ // none. The export marker comes off first, being chrome.
182
+ export function firstLine(text) {
183
+ if (!text) return ''
184
+ for (const line of text.split('\n')) {
185
+ if (line.trim()) return line.trim()
186
+ }
187
+ return ''
188
+ }
189
+
190
+ // The `title` a finding carries, trimmed — '' when there is none a
191
+ // reader can use. The three readers below all open on it.
192
+ function ownTitle(f) {
193
+ return typeof f?.title === 'string' ? f.title.trim() : ''
194
+ }
195
+
196
+ export function findingTitle(f) {
197
+ return ownTitle(f) || firstLine(stripExportMarker(f?.description, f))
198
+ }
199
+
200
+ // Title + body for a heading-over-body layout. With a `title` field the
201
+ // split is already made and the description is all body — except a first
202
+ // line REPEATING the title, which would stutter under it.
203
+ //
204
+ // Without one, the first line is the title, but only over a non-empty
205
+ // body: a single-line description stays whole rather than being bolded,
206
+ // and one that OPENS on a fence keeps its first line, since lifting it
207
+ // out would leave the block unopened and render its code as prose.
208
+ export function splitDescription(f) {
209
+ const text = stripExportMarker(f?.description, f) || ''
210
+ const own = ownTitle(f)
211
+ if (own) {
212
+ const body = text.trim()
213
+ const nl = body.indexOf('\n')
214
+ const first = (nl < 0 ? body : body.slice(0, nl)).trim()
215
+ if (first !== own) return { title: own, body }
216
+ return { title: own, body: nl < 0 ? '' : body.slice(nl + 1).replace(/^\s+/u, '') }
217
+ }
218
+ if (!text) return { title: '', body: '' }
219
+ const nl = text.indexOf('\n')
220
+ if (nl < 0) return { title: '', body: text }
221
+ // A fence opening at index 0 — the same reading codeBlockSegments
222
+ // gives it (format.js).
223
+ if (fenceRanges(text)[0]?.[0] === 0) return { title: '', body: text }
224
+ const body = text.slice(nl + 1).replace(/^\s+/u, '')
225
+ if (!body) return { title: '', body: text }
226
+ return { title: text.slice(0, nl).trim(), body }
227
+ }
228
+
229
+ // The description with the name in front of it — the shape a format
230
+ // without a `title` field already writes, for the surfaces that show one
231
+ // blob per finding rather than a heading over a body.
232
+ export function titledDescription(f) {
233
+ const own = ownTitle(f)
234
+ if (!own) return stripExportMarker(f?.description, f) || ''
235
+ const { body } = splitDescription(f)
236
+ return body ? `${own}\n\n${body}` : own
237
+ }
238
+
239
+ // ── Description sections ─────────────────────────────────────────────
240
+ // A description body split into the sections the report wrote it in: a
241
+ // paragraph OPENING with `**Label:**` is one. Every parser emits its
242
+ // narrative fields that way, whatever the report called them, so keying
243
+ // off the markup rather than a list of label words picks all of them up.
244
+ //
245
+ // Everything else is prose, and consecutive unlabelled paragraphs stay
246
+ // in ONE block so their spacing survives. `[{ label, body }]` in
247
+ // document order, `label` null for prose.
248
+ const SECTION_LABEL_RE = /^\*\*([^*\n]+):\*\*[ \t]*/u
249
+
250
+ // Blank lines, but only OUTSIDE a fence. A snippet's own blank line
251
+ // would tear the block in two, leaving each half with one bare fence
252
+ // marker and neither rendering as code.
253
+ function paragraphs(text) {
254
+ const ranges = fenceRanges(text)
255
+ if (ranges.length === 0) return text.split(/\n{2,}/u)
256
+ const parts = []
257
+ let last = 0
258
+ for (const m of text.matchAll(/\n{2,}/gu)) {
259
+ if (inFence(ranges, m.index)) continue
260
+ parts.push(text.slice(last, m.index))
261
+ last = m.index + m[0].length
262
+ }
263
+ parts.push(text.slice(last))
264
+ return parts
265
+ }
266
+
267
+ export function descriptionSections(body) {
268
+ const sections = []
269
+ for (const para of paragraphs(body || '')) {
270
+ if (!para.trim()) continue
271
+ const m = SECTION_LABEL_RE.exec(para)
272
+ if (m) {
273
+ sections.push({ label: m[1].trim(), body: para.slice(m[0].length).trim() })
274
+ continue
275
+ }
276
+ const open = sections.at(-1)
277
+ if (open && open.label === null) open.body += `\n\n${para}`
278
+ else sections.push({ label: null, body: para })
279
+ }
280
+ return sections
281
+ }
282
+
283
+ // ── Locations and evidence ───────────────────────────────────────────
284
+ // `file:line`, the line dropped when there isn't a finite one ('?' on
285
+ // imports that carry none). Takes a finding or an evidence row — both
286
+ // carry the pair and print it alike — and passes `line` through raw, so
287
+ // a range (`10-20`) survives whole.
288
+ export function locationLabel(x) {
289
+ return Number.isFinite(parseInt(x?.line, 10)) ? `${x.file}:${x.line}` : (x?.file ?? '')
290
+ }
291
+
292
+ // An evidence row's note: `text` is what parse-md.js writes from the
293
+ // lines under a reference, `observation` what a JSON report may call the
294
+ // same thing. No producer emits both, but a row carrying both reads as
295
+ // the observation over the text rather than one silently winning.
296
+ // Non-string values are ignored — this reads whatever JSON arrives.
297
+ export function evidenceNote(row) {
298
+ const str = (v) => (typeof v === 'string' ? v : '')
299
+ return `${str(row?.observation)}\n${str(row?.text)}`.trim()
300
+ }
@@ -0,0 +1,33 @@
1
+ // The words the markdown writer uses for the app's enumerations, one
2
+ // table per dimension. The viewer's prose surfaces read them too — the
3
+ // export dialog, the analyzer dropdown, the page header — so a filter
4
+ // the dialog lists and the header line in the file can't disagree.
5
+
6
+ export const SEVERITY_LABELS = {
7
+ critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low',
8
+ high_bug: 'High bug', bug: 'Bug', informational: 'Informational',
9
+ }
10
+
11
+ export const TRIAGE_LABELS = {
12
+ inprogress: 'In progress', fixed: 'Fixed', invalid: 'Invalid',
13
+ deleted: 'Deleted', ignored: 'Ignored',
14
+ }
15
+
16
+ export const COLOR_LABELS = { red: 'Red', blue: 'Blue', green: 'Green', gray: 'Gray', none: 'Unmarked' }
17
+
18
+ // The producer behind a report's `source` marker, one per format the
19
+ // library reads. The analyzer's own dump carries no marker and is
20
+ // described by its run meta instead.
21
+ export const SOURCE_LABELS = {
22
+ 'claude-security': 'Claude Security',
23
+ 'codex-security': 'Codex Security',
24
+ 'deepsec': 'DeepSec',
25
+ 'piolium': 'Piolium',
26
+ }
27
+
28
+ // An unknown tier prints as itself rather than vanishing: a report can
29
+ // invent one, and the reader is better served by the word than by a
30
+ // blank.
31
+ export function severityLabel(severity) {
32
+ return SEVERITY_LABELS[severity] ?? String(severity ?? '')
33
+ }