@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,137 @@
|
|
|
1
|
+
// FROZEN. The id fingerprint of a Claude Security (markdown) finding.
|
|
2
|
+
//
|
|
3
|
+
// A finding's uuid (finding-id.js) is the key every piece of stored
|
|
4
|
+
// triage hangs off — markers, buckets, comments, fixes — so it has to
|
|
5
|
+
// be a function of the source document alone, and it has to stay that
|
|
6
|
+
// function. `parse-md.js` is not that: what it produces is
|
|
7
|
+
// presentation, and it changes whenever the card does.
|
|
8
|
+
//
|
|
9
|
+
// So the fingerprint comes from a second parse of the same block, the
|
|
10
|
+
// one in this file. It reads a fixed subset of the format — the title,
|
|
11
|
+
// `## Details`, `## Location`, `## Impact`, `## Reproduction steps` and
|
|
12
|
+
// the severity — into severity, description and location of a fixed
|
|
13
|
+
// shape. That is what the uuid hashes: parse-md.js stamps it on the
|
|
14
|
+
// finding as `_idBasis`, and deriveFindingId uses it in place of the
|
|
15
|
+
// finding's own fields.
|
|
16
|
+
//
|
|
17
|
+
// DO NOT change the behaviour of anything in this file — not to fix a
|
|
18
|
+
// bug in it, not to share code with parse-md.js, not to make it read
|
|
19
|
+
// better, not to widen the subset it reads. Every byte it emits is
|
|
20
|
+
// baked into uuids in users' browsers; the golden values in
|
|
21
|
+
// tests/finding-id-md.test.js are what it must keep producing.
|
|
22
|
+
//
|
|
23
|
+
// `## Evidence` is outside the subset. A finding whose only cited site
|
|
24
|
+
// is an evidence row therefore keys by file 'unknown' / line '?' over
|
|
25
|
+
// a description with no evidence in it, and two findings in one report
|
|
26
|
+
// whose title / details / impact / reproduction and severity are all
|
|
27
|
+
// identical share an id. That is the rule, not an oversight: an id
|
|
28
|
+
// that leaves data out of the hash is recoverable, an id that moves is
|
|
29
|
+
// not.
|
|
30
|
+
|
|
31
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational'])
|
|
32
|
+
|
|
33
|
+
// The fingerprint object `deriveFindingId` hashes for one finding
|
|
34
|
+
// block, in a fixed key order (JSON.stringify is order-sensitive, so
|
|
35
|
+
// the order IS part of the id). The discriminator is the location when
|
|
36
|
+
// the source carries a `## Location`, and file / line otherwise — the
|
|
37
|
+
// same two branches deriveFindingId takes for a finding that carries
|
|
38
|
+
// no basis.
|
|
39
|
+
export function frozenIdBasis(block) {
|
|
40
|
+
const basis = frozenParse(block)
|
|
41
|
+
if (!basis) return null
|
|
42
|
+
const { severity, description, location, file, line } = basis
|
|
43
|
+
return location
|
|
44
|
+
? { severity, description, location }
|
|
45
|
+
: { severity, description, file, line }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// This file's parse of one `# Title` block: severity, description and
|
|
49
|
+
// location, from the fixed subset of the format described above.
|
|
50
|
+
function frozenParse(block) {
|
|
51
|
+
const newlineIdx = block.indexOf('\n')
|
|
52
|
+
const title = (newlineIdx === -1 ? block : block.slice(0, newlineIdx)).trim()
|
|
53
|
+
if (!title) return null
|
|
54
|
+
const body = newlineIdx === -1 ? '' : block.slice(newlineIdx + 1)
|
|
55
|
+
|
|
56
|
+
const { sectionsText, metaText } = splitBody(body)
|
|
57
|
+
const sections = parseSections(sectionsText)
|
|
58
|
+
const meta = parseMeta(metaText)
|
|
59
|
+
const { file, line, locationLink } = parseLocation(sections.location || '')
|
|
60
|
+
|
|
61
|
+
const sevRaw = (meta.severity || '').toLowerCase()
|
|
62
|
+
const severity = VALID_SEVERITIES.has(sevRaw) ? sevRaw : 'medium'
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
severity,
|
|
66
|
+
description: buildDescription(title, sections),
|
|
67
|
+
location: locationLink,
|
|
68
|
+
file: file || 'unknown',
|
|
69
|
+
line,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── The frozen readers ───────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function splitBody(body) {
|
|
76
|
+
const dashRe = /^---\s*$/mu
|
|
77
|
+
const dashMatch = dashRe.exec(body)
|
|
78
|
+
if (!dashMatch) return { sectionsText: body, metaText: '' }
|
|
79
|
+
const sectionsText = body.slice(0, dashMatch.index).trim()
|
|
80
|
+
const rest = body.slice(dashMatch.index + dashMatch[0].length).replace(/^\n/u, '')
|
|
81
|
+
const next = dashRe.exec(rest)
|
|
82
|
+
const metaText = next ? rest.slice(0, next.index) : rest
|
|
83
|
+
return { sectionsText, metaText }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseSections(sectionsText) {
|
|
87
|
+
const sections = {}
|
|
88
|
+
const parts = sectionsText.split(/^## /mu)
|
|
89
|
+
for (let i = 1; i < parts.length; i++) {
|
|
90
|
+
const part = parts[i]
|
|
91
|
+
const nl = part.indexOf('\n')
|
|
92
|
+
const header = (nl === -1 ? part : part.slice(0, nl)).trim().toLowerCase()
|
|
93
|
+
const content = (nl === -1 ? '' : part.slice(nl + 1)).trim()
|
|
94
|
+
if (header) sections[header] = content
|
|
95
|
+
}
|
|
96
|
+
return sections
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseMeta(metaText) {
|
|
100
|
+
const meta = {}
|
|
101
|
+
for (const m of metaText.matchAll(/\*\*([^:]+):\*\*\s*(.+)/gu)) {
|
|
102
|
+
meta[m[1].trim().toLowerCase()] = m[2].trim()
|
|
103
|
+
}
|
|
104
|
+
return meta
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseLocation(loc) {
|
|
108
|
+
let file = '', line = '?', locationLink = ''
|
|
109
|
+
const linkMatch = loc.match(/\[([^\]]+)\]\(([^)]+)\)/u)
|
|
110
|
+
if (linkMatch) {
|
|
111
|
+
file = linkMatch[1].trim()
|
|
112
|
+
locationLink = linkMatch[2]
|
|
113
|
+
const lineFromUrl = linkMatch[2].match(/#L(\d+)/u)
|
|
114
|
+
if (lineFromUrl) line = lineFromUrl[1]
|
|
115
|
+
} else {
|
|
116
|
+
file = loc.trim()
|
|
117
|
+
locationLink = loc.trim()
|
|
118
|
+
}
|
|
119
|
+
// `:42` suffix on the file path — common shorthand. Only consume
|
|
120
|
+
// if we don't already have a line from a `#L<n>` anchor.
|
|
121
|
+
const colonMatch = file.match(/^(.+):(\d+)$/u)
|
|
122
|
+
if (colonMatch) {
|
|
123
|
+
file = colonMatch[1]
|
|
124
|
+
if (line === '?') line = colonMatch[2]
|
|
125
|
+
}
|
|
126
|
+
return { file, line, locationLink }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildDescription(title, sections) {
|
|
130
|
+
const bodyParts = [title]
|
|
131
|
+
if (sections.details) bodyParts.push(sections.details)
|
|
132
|
+
if (sections.impact) bodyParts.push(`Impact: ${sections.impact}`)
|
|
133
|
+
if (sections['reproduction steps']) bodyParts.push(`Reproduction: ${sections['reproduction steps']}`)
|
|
134
|
+
return stripBold(bodyParts.join('\n\n'))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripBold(text) { return text.replaceAll('**', '') }
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Claude Security's markdown findings — a secondary input format,
|
|
2
|
+
// supported but deliberately not advertised in the README. Returns what
|
|
3
|
+
// ingest.js expects from JSON, `{ type, source, findings }`, or null when
|
|
4
|
+
// the text isn't this format, so the caller can surface the JSON parse
|
|
5
|
+
// failure instead.
|
|
6
|
+
//
|
|
7
|
+
// One finding (several are separated by a `---` line):
|
|
8
|
+
//
|
|
9
|
+
// # <Title>
|
|
10
|
+
//
|
|
11
|
+
// ## Details
|
|
12
|
+
// ## Evidence
|
|
13
|
+
// 1. [<name>](<url>)
|
|
14
|
+
// <Description>
|
|
15
|
+
// ## Impact
|
|
16
|
+
// ## Reproduction steps
|
|
17
|
+
// ## Recommended fix
|
|
18
|
+
//
|
|
19
|
+
// ---
|
|
20
|
+
// **Severity:** <critical|high|medium|low>
|
|
21
|
+
// **Status:** Open
|
|
22
|
+
// **Category:** <category>
|
|
23
|
+
// **Repository:** <owner/repo>
|
|
24
|
+
// **Branch:** <branch>
|
|
25
|
+
// **Date created:** <YYYY-MM-DD>
|
|
26
|
+
//
|
|
27
|
+
// A report cites its site as a one-line `## Location` or as an
|
|
28
|
+
// `## Evidence` list; both are read, `## Location` winning. Every
|
|
29
|
+
// `## …` section is optional — only the title and the metadata block
|
|
30
|
+
// carry anything mandatory.
|
|
31
|
+
|
|
32
|
+
import { frozenIdBasis } from './parse-md-id.js'
|
|
33
|
+
import { findMdLink, normalizeNewlines, splitHeadingLine, unescapeMd } from './md-structure.js'
|
|
34
|
+
|
|
35
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational'])
|
|
36
|
+
|
|
37
|
+
export function parseMarkdownFindings(content) {
|
|
38
|
+
const text = normalizeNewlines(content).trim()
|
|
39
|
+
// Format guard: these documents always start with an h1. Anything
|
|
40
|
+
// else returns null, so the caller surfaces the JSON error rather
|
|
41
|
+
// than a misleading markdown one.
|
|
42
|
+
if (!text.startsWith('# ')) return null
|
|
43
|
+
|
|
44
|
+
// Each finding starts at a line beginning with `# `; whatever
|
|
45
|
+
// preceded the first one is preamble, and empty chunks drop out.
|
|
46
|
+
const blocks = text.split(/^# /mu).filter((b) => b.trim().length > 0)
|
|
47
|
+
|
|
48
|
+
const findings = []
|
|
49
|
+
for (const block of blocks) {
|
|
50
|
+
const f = parseBlock(block)
|
|
51
|
+
if (f) findings.push(f)
|
|
52
|
+
}
|
|
53
|
+
if (findings.length === 0) return null
|
|
54
|
+
|
|
55
|
+
// `source` is what the renderer recognises the product by — the page
|
|
56
|
+
// header reads `Claude Security results` — rather than sniffing the
|
|
57
|
+
// extension, which a rename defeats. The report-level `type` is the
|
|
58
|
+
// product's category as for every source-marked producer: this is ONE
|
|
59
|
+
// analyzer, and the per-finding `**Category:**` says what kind of
|
|
60
|
+
// issue a finding is, not which run found it.
|
|
61
|
+
return { type: 'security', source: 'claude-security', findings }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseBlock(block) {
|
|
65
|
+
const { title, body } = splitHeadingLine(block)
|
|
66
|
+
if (!title) return null
|
|
67
|
+
|
|
68
|
+
const { sectionsText, metaText } = splitBody(body)
|
|
69
|
+
const sections = parseSections(sectionsText)
|
|
70
|
+
const meta = parseMeta(metaText)
|
|
71
|
+
const evidence = evidenceRows(sections.evidence || '')
|
|
72
|
+
// `## Location`, else the FIRST `## Evidence` row — the primary site
|
|
73
|
+
// by the format's convention. Every row, this one included, also
|
|
74
|
+
// lands on `finding.evidence` below.
|
|
75
|
+
const { file, line, locationLink } = parseLocation(
|
|
76
|
+
sections.location || evidence[0]?.ref || '',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// Medium when missing or unrecognized, so an unparsable finding stays
|
|
80
|
+
// visible rather than dropping out silently.
|
|
81
|
+
const sevRaw = (meta.severity || '').toLowerCase()
|
|
82
|
+
const severity = VALID_SEVERITIES.has(sevRaw) ? sevRaw : 'medium'
|
|
83
|
+
|
|
84
|
+
const description = buildDescription(title, sections, evidence.length > 0)
|
|
85
|
+
|
|
86
|
+
const finding = { file: file || 'unknown', line, severity, description }
|
|
87
|
+
if (locationLink) finding.location = locationLink
|
|
88
|
+
if (evidence.length > 0) finding.evidence = evidence.map(evidenceEntry)
|
|
89
|
+
// Narrative FIELDS, not description — the same two slots a native
|
|
90
|
+
// dump fills, so a report that names them here and one that carries
|
|
91
|
+
// them as fields read alike. The field is also what render-finding.js
|
|
92
|
+
// can collapse into a `<details>`, where a `**Label:**` paragraph in
|
|
93
|
+
// the description is an always-open block. They survive a round trip
|
|
94
|
+
// through this finding's own export, which writes them as sections
|
|
95
|
+
// that parse-deepview-md.js narrativeSplit reads back as fields.
|
|
96
|
+
if (sections['reproduction steps']) finding.reproduction = sections['reproduction steps']
|
|
97
|
+
if (sections['recommended fix']) finding.recommendation = sections['recommended fix']
|
|
98
|
+
if (meta.repository) finding.repo = { github: meta.repository }
|
|
99
|
+
// Auxiliary metadata, kept as plain strings: nothing renders these
|
|
100
|
+
// specifically, but the markdown export prints what a finding carries.
|
|
101
|
+
if (meta.branch) finding.branch = meta.branch
|
|
102
|
+
if (meta['date created']) finding.dateCreated = meta['date created']
|
|
103
|
+
if (meta.status) finding.status = meta.status
|
|
104
|
+
// The issue class the report filed the finding under ("insufficient
|
|
105
|
+
// verification of data authenticity"), as written. NOT the finding's
|
|
106
|
+
// `type`, which is the analyzer run a native dump names — this report
|
|
107
|
+
// has one analyzer, and `source` above says which.
|
|
108
|
+
if (meta.category) finding.category = meta.category
|
|
109
|
+
// The fingerprint is parse-md-id.js's own parse of this same block,
|
|
110
|
+
// not the fields above: those are presentation and free to change, it
|
|
111
|
+
// is not. Nothing this parser resolved is passed in. Read that
|
|
112
|
+
// module's header before touching either side.
|
|
113
|
+
const idBasis = frozenIdBasis(block)
|
|
114
|
+
if (idBasis) finding._idBasis = idBasis
|
|
115
|
+
|
|
116
|
+
return finding
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The sections half (before the first `---`) and the metadata half
|
|
120
|
+
// (from there to the next `---` or the end).
|
|
121
|
+
function splitBody(body) {
|
|
122
|
+
const dashRe = /^---\s*$/mu
|
|
123
|
+
const dashMatch = dashRe.exec(body)
|
|
124
|
+
if (!dashMatch) return { sectionsText: body, metaText: '' }
|
|
125
|
+
const sectionsText = body.slice(0, dashMatch.index).trim()
|
|
126
|
+
const rest = body.slice(dashMatch.index + dashMatch[0].length).replace(/^\n/u, '')
|
|
127
|
+
const next = dashRe.exec(rest)
|
|
128
|
+
const metaText = next ? rest.slice(0, next.index) : rest
|
|
129
|
+
return { sectionsText, metaText }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Named sections, split on `## Header`. Whatever precedes the first
|
|
133
|
+
// heading is dropped.
|
|
134
|
+
function parseSections(sectionsText) {
|
|
135
|
+
const sections = {}
|
|
136
|
+
for (const part of sectionsText.split(/^## /mu).slice(1)) {
|
|
137
|
+
const { title, body } = splitHeadingLine(part)
|
|
138
|
+
const header = title.toLowerCase()
|
|
139
|
+
if (header) sections[header] = body.trim()
|
|
140
|
+
}
|
|
141
|
+
return sections
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `**Label:** value` per line, keyed case-folded.
|
|
145
|
+
function parseMeta(metaText) {
|
|
146
|
+
const meta = {}
|
|
147
|
+
for (const m of metaText.matchAll(/\*\*([^:]+):\*\*\s*(.+)/gu)) {
|
|
148
|
+
meta[m[1].trim().toLowerCase()] = m[2].trim()
|
|
149
|
+
}
|
|
150
|
+
return meta
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// One `## Location` line or one `## Evidence` row, a markdown link
|
|
154
|
+
// preferred. The line comes from a `#L<n>` anchor in the url, a `:<n>`
|
|
155
|
+
// suffix on the name, or nowhere (`?`). A RANGE is kept whole (`10-20`),
|
|
156
|
+
// as parse-piolium.js keeps it, with the en / em dashes the Evidence
|
|
157
|
+
// template writes normalized to a hyphen.
|
|
158
|
+
//
|
|
159
|
+
// `locationLink` is the url, or the raw text when there is none:
|
|
160
|
+
// finding-id.js keys off it with no fileHash available, so two imports
|
|
161
|
+
// of a finding share one uuid and its triage.
|
|
162
|
+
function parseLocation(loc) {
|
|
163
|
+
let file = '', line = '?', locationLink = ''
|
|
164
|
+
// Brackets and parens and all: `app/(main)/[id]/page.ts` is an
|
|
165
|
+
// ordinary Next.js path, and a reading that stops at the first `]`
|
|
166
|
+
// finds no link in it — leaving the whole `[…](…)` as the file name,
|
|
167
|
+
// the line `?`, and an evidence row with no url.
|
|
168
|
+
const link = findMdLink(loc)
|
|
169
|
+
if (link) {
|
|
170
|
+
file = link.label.trim()
|
|
171
|
+
locationLink = link.url.trim()
|
|
172
|
+
const lineFromUrl = locationLink.match(/#L(\d+)(?:-L?(\d+))?/u)
|
|
173
|
+
if (lineFromUrl) line = lineFromUrl[2] ? `${lineFromUrl[1]}-${lineFromUrl[2]}` : lineFromUrl[1]
|
|
174
|
+
} else {
|
|
175
|
+
file = loc.trim()
|
|
176
|
+
locationLink = loc.trim()
|
|
177
|
+
}
|
|
178
|
+
// Backticks are notation and a `\_` is the report escaping markdown;
|
|
179
|
+
// the path is the unescaped name, which is what the displays print
|
|
180
|
+
// and what a rebuilt blob URL must address. The url is left exactly
|
|
181
|
+
// as written — reports don't escape there, and it keys the id.
|
|
182
|
+
file = unescapeMd(file.replaceAll('`', '')).trim()
|
|
183
|
+
// A `:42` / `:10–20` suffix: taken only when the anchor gave no line,
|
|
184
|
+
// but shed from the path either way.
|
|
185
|
+
const colonMatch = file.match(/^(.+):(\d+)(?:\s*[-–—]\s*L?(\d+))?$/u)
|
|
186
|
+
if (colonMatch) {
|
|
187
|
+
file = colonMatch[1]
|
|
188
|
+
if (line === '?') line = colonMatch[3] ? `${colonMatch[2]}-${colonMatch[3]}` : colonMatch[2]
|
|
189
|
+
}
|
|
190
|
+
// `linked` says how the row came in, which `locationLink` can't —
|
|
191
|
+
// the fallback puts raw text there, and that is an id discriminator,
|
|
192
|
+
// not an href.
|
|
193
|
+
return { file, line, locationLink, linked: link !== null }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Rows of an `## Evidence` section, in document order:
|
|
197
|
+
//
|
|
198
|
+
// 1. [libs/a.ts:10–20](https://github.com/o/r/blob/<sha>/libs/a.ts#L10-L20)
|
|
199
|
+
// Why this line matters.
|
|
200
|
+
//
|
|
201
|
+
// Only a marker line is a reference — numbered or bulleted — and the
|
|
202
|
+
// prose under it is that row's note, left-trimmed, since the renderer
|
|
203
|
+
// indents the row itself.
|
|
204
|
+
//
|
|
205
|
+
// A section with no markers still yields one row when it is a single
|
|
206
|
+
// line, or around the first line carrying a link. Free prose yields
|
|
207
|
+
// none, and parseBlock leaves it in the description rather than
|
|
208
|
+
// promoting a sentence to a path.
|
|
209
|
+
const EVIDENCE_ITEM_RE = /^[ \t]*(?:\d+[.)]|[-*+])\s+/u
|
|
210
|
+
|
|
211
|
+
function evidenceRows(text) {
|
|
212
|
+
const rows = []
|
|
213
|
+
for (const line of text.split('\n')) {
|
|
214
|
+
if (EVIDENCE_ITEM_RE.test(line)) rows.push({ ref: line.replace(EVIDENCE_ITEM_RE, '').trim(), note: [] })
|
|
215
|
+
else if (rows.length > 0 && line.trim()) rows.at(-1).note.push(line.trim())
|
|
216
|
+
}
|
|
217
|
+
if (rows.length === 0) {
|
|
218
|
+
const bare = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
219
|
+
const at = bare.findIndex((l) => findMdLink(l) !== null)
|
|
220
|
+
if (at === -1 && bare.length !== 1) return []
|
|
221
|
+
const refAt = at === -1 ? 0 : at
|
|
222
|
+
rows.push({ ref: bare[refAt], note: bare.filter((_, i) => i !== refAt) })
|
|
223
|
+
}
|
|
224
|
+
return rows.filter((r) => r.ref)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// One row as it lands on the finding. `url` only where the row carried
|
|
228
|
+
// a real link — the raw-text fallback is an id discriminator, not an
|
|
229
|
+
// href to hand a renderer.
|
|
230
|
+
function evidenceEntry({ ref, note }) {
|
|
231
|
+
const { file, line, locationLink, linked } = parseLocation(ref)
|
|
232
|
+
const entry = { file: file || 'unknown', line }
|
|
233
|
+
if (locationLink && linked) entry.url = locationLink
|
|
234
|
+
const text = note.join('\n')
|
|
235
|
+
if (text) entry.text = text
|
|
236
|
+
return entry
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Title + body sections, section labels emitted as `**Label:**` — the
|
|
240
|
+
// shape parse-piolium gives its fields, which render-finding.js turns
|
|
241
|
+
// into real `<strong>` emphasis and the markdown export re-emits as the
|
|
242
|
+
// markdown it is. Everything else survives verbatim, `pre-wrap` on
|
|
243
|
+
// `.desc` keeping the shape the report wrote.
|
|
244
|
+
function buildDescription(title, sections, hasEvidenceRows) {
|
|
245
|
+
const bodyParts = [title]
|
|
246
|
+
if (sections.details) bodyParts.push(sections.details)
|
|
247
|
+
// An Evidence section that parsed into rows lives on
|
|
248
|
+
// `finding.evidence`, and repeating it here would double it. One that
|
|
249
|
+
// parsed into none is free prose, and stays rather than being lost.
|
|
250
|
+
if (sections.evidence && !hasEvidenceRows) bodyParts.push(`**Evidence:**\n${sections.evidence}`)
|
|
251
|
+
if (sections.impact) bodyParts.push(`**Impact:** ${sections.impact}`)
|
|
252
|
+
return bodyParts.join('\n\n')
|
|
253
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// FROZEN. The id fingerprint of a Piolium finding's LOCATION.
|
|
2
|
+
//
|
|
3
|
+
// A finding's uuid (finding-id.js) is the key every piece of stored
|
|
4
|
+
// triage hangs off, so it has to be a function of the source document
|
|
5
|
+
// alone, and it has to stay that function. The reference reader in
|
|
6
|
+
// md-structure.js is not that: `parseCodeRef` is what the card shows a
|
|
7
|
+
// finding's file, line and link as, and it changes when a report turns
|
|
8
|
+
// up that it reads wrongly — which is exactly what happened to the
|
|
9
|
+
// reader this one was copied from, whose link expression could not see
|
|
10
|
+
// a path with brackets in it (`app/(main)/[id]/page.ts`).
|
|
11
|
+
//
|
|
12
|
+
// So the fingerprint comes from a second reading of the same text, the
|
|
13
|
+
// one in this file: `parseCodeRef` as it stood when the ids in users'
|
|
14
|
+
// browsers were derived, bug and all. parse-piolium.js stamps what it
|
|
15
|
+
// returns onto each finding as `_idBasis`, and deriveFindingId uses it
|
|
16
|
+
// in place of the finding's own fields.
|
|
17
|
+
//
|
|
18
|
+
// DO NOT change the behaviour of anything in this file — not to fix
|
|
19
|
+
// the bug it preserves, not to share code with md-structure.js, not to
|
|
20
|
+
// make it read better. Every byte it emits is baked into uuids;
|
|
21
|
+
// report/tests/finding-id-piolium.test.js holds the golden values it
|
|
22
|
+
// must keep producing.
|
|
23
|
+
//
|
|
24
|
+
// What it does NOT freeze: the severity and the description, which the
|
|
25
|
+
// live parser hands in. Those are the same exposure they have always
|
|
26
|
+
// been for this format — a change to how a Piolium description is
|
|
27
|
+
// built still re-keys these findings, as it always would have. This
|
|
28
|
+
// file pins the half that was about to move.
|
|
29
|
+
|
|
30
|
+
// `parseCodeRef` (md-structure.js), as of the last commit before the
|
|
31
|
+
// link reading was fixed. Its own copies of the expressions, so
|
|
32
|
+
// nothing it depends on can drift underneath it.
|
|
33
|
+
function frozenCodeRef(raw) {
|
|
34
|
+
let text = (raw || '').trim()
|
|
35
|
+
let locationLink = ''
|
|
36
|
+
const link = /\[([^\]]+)\]\(([^)]+)\)/u.exec(text)
|
|
37
|
+
if (link) {
|
|
38
|
+
text = link[1].trim()
|
|
39
|
+
locationLink = link[2].trim()
|
|
40
|
+
}
|
|
41
|
+
let line = ''
|
|
42
|
+
const anchor = /#L(\d+)/u.exec(locationLink)
|
|
43
|
+
if (anchor) line = anchor[1]
|
|
44
|
+
const spans = [...text.matchAll(/`([^`]+)`/gu)].map((m) => m[1].trim())
|
|
45
|
+
const pathish = spans.find((s) => !s.includes('(') && (s.includes('/') || /\.\w/u.test(s)))
|
|
46
|
+
let file = pathish ?? (text.replaceAll('`', '').trim().split(/[\s,]+/u).find(Boolean) || '')
|
|
47
|
+
const frag = /^(.*?)#L(\d+)(?:-L?\d+)?$/u.exec(file)
|
|
48
|
+
if (frag) {
|
|
49
|
+
file = frag[1]
|
|
50
|
+
if (!line) line = frag[2]
|
|
51
|
+
}
|
|
52
|
+
const colon = /^(.+):(\d+(?:-\d+)?)$/u.exec(file)
|
|
53
|
+
if (colon) {
|
|
54
|
+
if (!line) line = colon[2]
|
|
55
|
+
return { file: colon[1], line, locationLink }
|
|
56
|
+
}
|
|
57
|
+
return { file, line: line || '?', locationLink }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The fingerprint object `deriveFindingId` hashes for one finding, in
|
|
61
|
+
// a fixed key order (JSON.stringify keeps insertion order, so the
|
|
62
|
+
// order IS part of the id). The discriminator is the location when the
|
|
63
|
+
// reference carried a link — or the `piolium:<id>` stand-in an
|
|
64
|
+
// unlocated finding gets — and file / line otherwise: the same two
|
|
65
|
+
// branches deriveFindingId takes for a finding that carries no basis,
|
|
66
|
+
// which is what these findings had before this file existed.
|
|
67
|
+
//
|
|
68
|
+
// `lineBullet` is the `**Line:**` value the detail reader falls back
|
|
69
|
+
// to, and `id` the finding's own; an index row passes neither but its
|
|
70
|
+
// row id.
|
|
71
|
+
export function frozenIdBasis({ severity, description, ref, lineBullet = '', id = '' }) {
|
|
72
|
+
const read = frozenCodeRef(ref)
|
|
73
|
+
const file = read.file || 'unknown'
|
|
74
|
+
const line = read.line === '?' && lineBullet ? lineBullet : read.line
|
|
75
|
+
const location = read.locationLink || (file === 'unknown' && id ? `piolium:${id}` : '')
|
|
76
|
+
return location
|
|
77
|
+
? { severity, description, location }
|
|
78
|
+
: { severity, description, file, line }
|
|
79
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Table rows and list items → findings for the Piolium parser: the
|
|
2
|
+
// index, overview and variants tables and the link-list rendering all
|
|
3
|
+
// reduce to one row shape and one construction. parse-piolium.js owns
|
|
4
|
+
// the document structure and the finding BLOCKS.
|
|
5
|
+
|
|
6
|
+
import { cellValue, parseCodeRef, stripBold, tableObjects } from './md-structure.js'
|
|
7
|
+
import { frozenIdBasis } from './parse-piolium-id.js'
|
|
8
|
+
import {
|
|
9
|
+
idCell, leadingId, leadingLink, mapSeverity, severityFromId, slugTitle,
|
|
10
|
+
} from './parse-piolium-tokens.js'
|
|
11
|
+
|
|
12
|
+
// Normalize a table-row object to the shared row shape used by the
|
|
13
|
+
// index, variant tables, group tables, and the row→finding conversion.
|
|
14
|
+
// The PoC column appears both as `PoC Status` and plain `PoC`.
|
|
15
|
+
export function indexRowOf(obj) {
|
|
16
|
+
return {
|
|
17
|
+
id: idCell(obj.id || ''),
|
|
18
|
+
title: cellValue(obj.title),
|
|
19
|
+
severity: cellValue(obj.severity),
|
|
20
|
+
pocStatus: cellValue(obj['poc status'] || obj.poc),
|
|
21
|
+
status: cellValue(obj.status),
|
|
22
|
+
parent: idCell(cellValue(obj.parent || '')),
|
|
23
|
+
location: cellValue(obj.location),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A finding known only from a table row. Rows usually carry no path, so
|
|
28
|
+
// they land on the same `unknown` / `?` placeholders, and two rows
|
|
29
|
+
// sharing a title and tier would derive the SAME uuid for ingest's
|
|
30
|
+
// dedupe to swallow one of. The report id is the only discriminator such
|
|
31
|
+
// a row has, so it goes in the `location` fingerprint field — which
|
|
32
|
+
// deriveFindingId prefers over file/line and nothing renders —
|
|
33
|
+
// namespaced to read as an opaque token rather than a URL. A Location
|
|
34
|
+
// column, where a table has one, is parsed like any code reference.
|
|
35
|
+
export function fromIndexRow(row, sevFallback = '') {
|
|
36
|
+
const severity = mapSeverity(row.severity)
|
|
37
|
+
|| sevFallback
|
|
38
|
+
|| severityFromId(row.id)
|
|
39
|
+
|| 'medium'
|
|
40
|
+
const { file, line, locationLink } = parseCodeRef(row.location || '')
|
|
41
|
+
const finding = {
|
|
42
|
+
file: file || 'unknown',
|
|
43
|
+
line,
|
|
44
|
+
severity,
|
|
45
|
+
description: stripBold(row.title || row.id),
|
|
46
|
+
}
|
|
47
|
+
if (locationLink) finding.location = locationLink
|
|
48
|
+
else if (finding.file === 'unknown' && row.id) finding.location = `piolium:${row.id}`
|
|
49
|
+
// The fingerprint reads the same reference its own way — see
|
|
50
|
+
// parse-piolium-id.js.
|
|
51
|
+
finding._idBasis = frozenIdBasis({
|
|
52
|
+
severity, description: finding.description, ref: row.location || '', id: row.id,
|
|
53
|
+
})
|
|
54
|
+
if (row.pocStatus) finding.pocStatus = row.pocStatus
|
|
55
|
+
if (row.status) finding.status = row.status
|
|
56
|
+
if (row.parent) finding.parent = row.parent
|
|
57
|
+
return finding
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Findings rendered as a list: the mode outline asks for "links to
|
|
61
|
+
// per-finding report.md", so an item leads with a
|
|
62
|
+
// `[<id>-<slug>](…/report.md)` link or a bold id, then a summary. Label
|
|
63
|
+
// bullets and "none found" placeholders are not findings.
|
|
64
|
+
export function listFindings(body, sev, index) {
|
|
65
|
+
const out = []
|
|
66
|
+
for (const line of body.split('\n')) {
|
|
67
|
+
const m = /^\s{0,3}(?:[-*+]|\d{1,3}[.)])\s+(.+)$/u.exec(line)
|
|
68
|
+
if (!m) continue
|
|
69
|
+
let text = m[1].trim()
|
|
70
|
+
if (/^\*\*[^:*]+:\*\*/u.test(text)) continue
|
|
71
|
+
|
|
72
|
+
// The item leads with a link or a bold token; either way it reads
|
|
73
|
+
// as plain `<id or title> <summary>` text from here on.
|
|
74
|
+
const linked = leadingLink(text)
|
|
75
|
+
const link = linked?.link ?? ''
|
|
76
|
+
if (linked) {
|
|
77
|
+
text = linked.text
|
|
78
|
+
} else {
|
|
79
|
+
const bold = /^\*\*([^*]+)\*\*\s*[:—–-]*\s*(.*)$/u.exec(text)
|
|
80
|
+
if (bold) text = bold[2] ? `${bold[1].trim()} ${bold[2].trim()}` : bold[1].trim()
|
|
81
|
+
}
|
|
82
|
+
if (/^(?:none\b|no |n\/a\b)/iu.test(text)) continue
|
|
83
|
+
|
|
84
|
+
// An id-led item takes its title from the slug and keeps the
|
|
85
|
+
// summary as its body; anything else is title only.
|
|
86
|
+
const lead = leadingId(text)
|
|
87
|
+
const id = lead?.id ?? ''
|
|
88
|
+
let title = text
|
|
89
|
+
if (lead) {
|
|
90
|
+
const slugT = slugTitle(lead.slug)
|
|
91
|
+
title = slugT && lead.rest ? `${slugT}\n\n${lead.rest}` : (lead.rest || slugT || lead.id)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const row = index.get(id)
|
|
95
|
+
const severity = mapSeverity(row?.severity)
|
|
96
|
+
|| sev
|
|
97
|
+
|| severityFromId(id)
|
|
98
|
+
|| 'medium'
|
|
99
|
+
const finding = { file: 'unknown', line: '?', severity, description: stripBold(title) }
|
|
100
|
+
if (id) finding.location = `piolium:${id}`
|
|
101
|
+
else if (link) finding.location = link
|
|
102
|
+
if (link.endsWith('report.md')) finding.reportPath = link
|
|
103
|
+
if (row?.pocStatus) finding.pocStatus = row.pocStatus
|
|
104
|
+
if (row?.status) finding.status = row.status
|
|
105
|
+
if (row?.parent) finding.parent = row.parent
|
|
106
|
+
out.push({ id, finding })
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Variant rows → findings, parented to the enclosing block where the row
|
|
112
|
+
// names none. They are also REGISTERED as index rows, so a variant's own
|
|
113
|
+
// `#### <id>` entry adopts their severity / PoC / parent even with no
|
|
114
|
+
// `## Summary of Findings` in the report. No table falls back to a
|
|
115
|
+
// bullet list at the caller's group severity.
|
|
116
|
+
export function variantFindings(tableText, index, parentId, sevFallback = '') {
|
|
117
|
+
const out = []
|
|
118
|
+
for (const obj of tableObjects(tableText)) {
|
|
119
|
+
const row = indexRowOf(obj)
|
|
120
|
+
if (!row.id && !row.title) continue
|
|
121
|
+
if (!row.parent && parentId) row.parent = parentId
|
|
122
|
+
if (row.id && !index.has(row.id)) index.set(row.id, row)
|
|
123
|
+
out.push({ id: row.id, finding: fromIndexRow(row, sevFallback) })
|
|
124
|
+
}
|
|
125
|
+
if (out.length > 0) return out
|
|
126
|
+
const items = listFindings(tableText, sevFallback, index)
|
|
127
|
+
for (const e of items) {
|
|
128
|
+
if (parentId && !e.finding.parent) e.finding.parent = parentId
|
|
129
|
+
}
|
|
130
|
+
return items
|
|
131
|
+
}
|