@preventive/triage 1.0.0-alpha.26 → 1.0.0-alpha.28

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 (96) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +24 -24
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +29 -29
  19. package/server-common/storage-crypto.ts +182 -0
  20. package/server-common/storage-log.ts +5 -1
  21. package/server-common/vercel-blob.ts +6 -3
  22. package/server-e2e/static.ts +3 -4
  23. package/server-managed/avatar-store.ts +1 -40
  24. package/server-managed/blob-store.ts +3 -52
  25. package/server-managed/bundle-advisories.ts +53 -0
  26. package/server-managed/bundle-advisory-inventory.ts +75 -0
  27. package/server-managed/bundle-build-leases.ts +41 -0
  28. package/server-managed/bundle-build-worker.js +44 -0
  29. package/server-managed/bundle-build.ts +141 -0
  30. package/server-managed/bundle-cache.ts +22 -41
  31. package/server-managed/bundle-store.ts +3 -7
  32. package/server-managed/bundle-summary-cache.ts +27 -9
  33. package/server-managed/bundle.ts +66 -1
  34. package/server-managed/cache-storage.ts +4 -25
  35. package/server-managed/cli.js +10 -2
  36. package/server-managed/config.ts +12 -1
  37. package/server-managed/content-access.ts +1 -1
  38. package/server-managed/db-methods.ts +163 -40
  39. package/server-managed/db-neon.ts +124 -30
  40. package/server-managed/db-schema.ts +2 -0
  41. package/server-managed/db.ts +11 -1
  42. package/server-managed/github-app.ts +10 -15
  43. package/server-managed/github-errors.ts +21 -0
  44. package/server-managed/github-issues.ts +1 -1
  45. package/server-managed/github-metadata.ts +34 -1
  46. package/server-managed/github-pulls.ts +142 -30
  47. package/server-managed/http.ts +278 -167
  48. package/server-managed/index.ts +26 -9
  49. package/server-managed/management-catalog.ts +45 -0
  50. package/server-managed/object-storage-disk.ts +144 -0
  51. package/server-managed/object-storage-vercel.ts +60 -0
  52. package/server-managed/object-storage.ts +71 -0
  53. package/server-managed/public-workspace.ts +20 -14
  54. package/server-managed/report-migration.ts +1 -1
  55. package/server-managed/report-sources.ts +14 -4
  56. package/server-managed/repository-browser.ts +8 -1
  57. package/server-managed/repository-discovery.ts +1 -1
  58. package/server-managed/revisions.ts +35 -0
  59. package/server-managed/solidity-entry-points.ts +69 -0
  60. package/server-managed/sql-postgres.ts +1 -0
  61. package/server-managed/sql.ts +14 -8
  62. package/server-managed/storage-command.ts +16 -0
  63. package/server-managed/storage-db.ts +175 -0
  64. package/server-managed/storage-encryption.ts +211 -0
  65. package/server-managed/storage-maintenance.ts +197 -0
  66. package/server-managed/storage-payload.ts +72 -0
  67. package/server-managed/storage-stores.ts +81 -0
  68. package/server-managed/storage.ts +48 -24
  69. package/server-managed/team-feed.ts +50 -5
  70. package/server-managed/team-reports.ts +85 -22
  71. package/server-managed/uploads.ts +2 -2
  72. package/server-managed/workspace-shares.ts +15 -3
  73. package/report/index.js +0 -260
  74. package/report/src/finding-id.js +0 -80
  75. package/report/src/finding.js +0 -314
  76. package/report/src/labels.js +0 -35
  77. package/report/src/md-structure.js +0 -471
  78. package/report/src/md-text.js +0 -167
  79. package/report/src/meta.js +0 -76
  80. package/report/src/parse-codex.js +0 -149
  81. package/report/src/parse-deepsec.js +0 -199
  82. package/report/src/parse-deepview-fields.js +0 -375
  83. package/report/src/parse-deepview-md.js +0 -186
  84. package/report/src/parse-md-id.js +0 -137
  85. package/report/src/parse-md.js +0 -323
  86. package/report/src/parse-piolium-id.js +0 -79
  87. package/report/src/parse-piolium-rows.js +0 -126
  88. package/report/src/parse-piolium-tokens.js +0 -185
  89. package/report/src/parse-piolium.js +0 -399
  90. package/report/src/security.js +0 -63
  91. package/report/src/severity.js +0 -18
  92. package/report/src/utf8.js +0 -21
  93. package/report/src/write-md-finding.js +0 -273
  94. package/report/src/write-md.js +0 -291
  95. package/server-managed/blob-vercel.ts +0 -127
  96. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -1,137 +0,0 @@
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('**', '') }
@@ -1,323 +0,0 @@
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 { normalizeFindingSeverity } from './severity.js'
34
- import { LIST_MARKER_RE, findMdLink, normalizeNewlines, splitHeadingLine, unescapeMd } from './md-structure.js'
35
-
36
- const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational', 'informative'])
37
-
38
- export function parseMarkdownFindings(content) {
39
- const text = normalizeNewlines(content).trim()
40
- // Format guard: these documents always start with an h1. Anything
41
- // else returns null, so the caller surfaces the JSON error rather
42
- // than a misleading markdown one.
43
- if (!text.startsWith('# ')) return null
44
-
45
- // Each finding starts at a line beginning with `# `; whatever
46
- // preceded the first one is preamble, and empty chunks drop out.
47
- const blocks = text.split(/^# /mu).filter((b) => b.trim().length > 0)
48
-
49
- const findings = []
50
- for (const block of blocks) {
51
- const f = parseBlock(block)
52
- if (f) findings.push(f)
53
- }
54
- if (findings.length === 0) return null
55
-
56
- // `source` is what the renderer recognises the product by — the page
57
- // header reads `Claude Security results` — rather than sniffing the
58
- // extension, which a rename defeats. The report-level `type` is the
59
- // product's category as for every source-marked producer: this is ONE
60
- // analyzer, and the per-finding `**Category:**` says what kind of
61
- // issue a finding is, not which run found it.
62
- return { type: 'security', source: 'claude-security', findings }
63
- }
64
-
65
- function parseBlock(block) {
66
- const { title, body } = splitHeadingLine(block)
67
- if (!title) return null
68
-
69
- const { sectionsText, metaText } = splitBody(body)
70
- const sections = parseSections(sectionsText)
71
- const meta = parseMeta(metaText)
72
- const evidence = evidenceRows(sections.evidence || '')
73
- // `## Location`, else the FIRST `## Evidence` row — the primary site
74
- // by the format's convention. Every row, this one included, also
75
- // lands on `finding.evidence` below.
76
- const { file, line, locationLink } = parseLocation(
77
- sections.location || evidence[0]?.ref || '',
78
- )
79
-
80
- // Medium when missing or unrecognized, so an unparsable finding stays
81
- // visible rather than dropping out silently.
82
- const sevRaw = (meta.severity || '').toLowerCase()
83
- const severity = VALID_SEVERITIES.has(sevRaw) ? sevRaw : 'medium'
84
-
85
- const description = buildDescription(title, sections, evidence.length > 0)
86
-
87
- const finding = { file: file || 'unknown', line, severity, description }
88
- if (locationLink) finding.location = locationLink
89
- if (evidence.length > 0) finding.evidence = evidence.map(evidenceEntry)
90
- // Narrative FIELDS, not description — the same two slots a native
91
- // dump fills, so a report that names them here and one that carries
92
- // them as fields read alike. The field is also what render-finding.js
93
- // can collapse into a `<details>`, where a `**Label:**` paragraph in
94
- // the description is an always-open block. They survive a round trip
95
- // through this finding's own export, which writes them as sections
96
- // that parse-deepview-md.js narrativeSplit reads back as fields.
97
- if (sections['reproduction steps']) {
98
- finding.reproduction = normalizeStepList(sections['reproduction steps'])
99
- }
100
- if (sections['recommended fix']) finding.recommendation = sections['recommended fix']
101
- if (meta.repository) finding.repo = { github: meta.repository }
102
- // Auxiliary metadata, kept as plain strings: nothing renders these
103
- // specifically, but the markdown export prints what a finding carries.
104
- if (meta.branch) finding.branch = meta.branch
105
- if (meta['date created']) finding.dateCreated = meta['date created']
106
- if (meta.status) finding.status = meta.status
107
- // The issue class the report filed the finding under ("insufficient
108
- // verification of data authenticity"), as written. NOT the finding's
109
- // `type`, which is the analyzer run a native dump names — this report
110
- // has one analyzer, and `source` above says which.
111
- if (meta.category) finding.category = meta.category
112
- // The fingerprint is parse-md-id.js's own parse of this same block,
113
- // not the fields above: those are presentation and free to change, it
114
- // is not. Nothing this parser resolved is passed in. Read that
115
- // module's header before touching either side.
116
- const idBasis = frozenIdBasis(block)
117
- if (idBasis) finding._idBasis = idBasis
118
-
119
- return normalizeFindingSeverity(finding)
120
- }
121
-
122
- // The sections half (before the first `---`) and the metadata half
123
- // (from there to the next `---` or the end).
124
- function splitBody(body) {
125
- const dashRe = /^---\s*$/mu
126
- const dashMatch = dashRe.exec(body)
127
- if (!dashMatch) return { sectionsText: body, metaText: '' }
128
- const sectionsText = body.slice(0, dashMatch.index).trim()
129
- const rest = body.slice(dashMatch.index + dashMatch[0].length).replace(/^\n/u, '')
130
- const next = dashRe.exec(rest)
131
- const metaText = next ? rest.slice(0, next.index) : rest
132
- return { sectionsText, metaText }
133
- }
134
-
135
- // Named sections, split on `## Header`. Whatever precedes the first
136
- // heading is dropped.
137
- function parseSections(sectionsText) {
138
- const sections = {}
139
- for (const part of sectionsText.split(/^## /mu).slice(1)) {
140
- const { title, body } = splitHeadingLine(part)
141
- const header = title.toLowerCase()
142
- if (header) sections[header] = body.trim()
143
- }
144
- return sections
145
- }
146
-
147
- // `**Label:** value` per line, keyed case-folded.
148
- function parseMeta(metaText) {
149
- const meta = {}
150
- for (const m of metaText.matchAll(/\*\*([^:]+):\*\*\s*(.+)/gu)) {
151
- meta[m[1].trim().toLowerCase()] = m[2].trim()
152
- }
153
- return meta
154
- }
155
-
156
- // One `## Location` line or one `## Evidence` row, a markdown link
157
- // preferred. The line comes from a `#L<n>` anchor in the url, a `:<n>`
158
- // suffix on the name, or nowhere (`?`). A RANGE is kept whole (`10-20`),
159
- // as parse-piolium.js keeps it, with the en / em dashes the Evidence
160
- // template writes normalized to a hyphen.
161
- //
162
- // `locationLink` is the url, or the raw text when there is none:
163
- // finding-id.js keys off it with no fileHash available, so two imports
164
- // of a finding share one uuid and its triage.
165
- function parseLocation(loc) {
166
- let file = '', line = '?', locationLink = ''
167
- // Brackets and parens and all: `app/(main)/[id]/page.ts` is an
168
- // ordinary Next.js path, and a reading that stops at the first `]`
169
- // finds no link in it — leaving the whole `[…](…)` as the file name,
170
- // the line `?`, and an evidence row with no url.
171
- const link = findMdLink(loc)
172
- if (link) {
173
- file = link.label.trim()
174
- locationLink = link.url.trim()
175
- const lineFromUrl = locationLink.match(/#L(\d+)(?:-L?(\d+))?/u)
176
- if (lineFromUrl) line = lineFromUrl[2] ? `${lineFromUrl[1]}-${lineFromUrl[2]}` : lineFromUrl[1]
177
- } else {
178
- file = loc.trim()
179
- locationLink = loc.trim()
180
- }
181
- // Backticks are notation and a `\_` is the report escaping markdown;
182
- // the path is the unescaped name, which is what the displays print
183
- // and what a rebuilt blob URL must address. The url is left exactly
184
- // as written — reports don't escape there, and it keys the id.
185
- file = unescapeMd(file.replaceAll('`', '')).trim()
186
- // A `:42` / `:10–20` suffix: taken only when the anchor gave no line,
187
- // but shed from the path either way.
188
- const colonMatch = file.match(/^(.+):(\d+)(?:\s*[-–—]\s*L?(\d+))?$/u)
189
- if (colonMatch) {
190
- file = colonMatch[1]
191
- if (line === '?') line = colonMatch[3] ? `${colonMatch[2]}-${colonMatch[3]}` : colonMatch[2]
192
- }
193
- // `linked` says how the row came in, which `locationLink` can't —
194
- // the fallback puts raw text there, and that is an id discriminator,
195
- // not an href.
196
- return { file, line, locationLink, linked: link !== null }
197
- }
198
-
199
- // The `## Reproduction steps` section as a reader can follow it. This
200
- // report sometimes writes a whole sequence as ONE list item, in two
201
- // shapes, and neither reads as a list:
202
- //
203
- // * a RUN-IN enumeration — `1. 1) Save 2) Restart 3) Watch`, which
204
- // markdown reads as one step whose text holds all the others —
205
- // becomes a line per step;
206
- // * a list of ONE step stops being a list, its marker numbering the
207
- // single thing the section says.
208
- //
209
- // The steps keep the numbers the report gave them, gaps and all: this
210
- // text is printed as written, so a `6)` behind a `4)` is the report's
211
- // own count rather than something to renumber.
212
- //
213
- // Only a section that IS one item is touched — no other line may open a
214
- // list of its own — and the run-in reading is tried behind the outer
215
- // marker (`1. 1) …`) and at the line's own start (`1) … 2) …`), since
216
- // either can carry the enumeration. The id comes from the RAW block
217
- // (parse-md-id.js), so reading the section better moves nothing.
218
- function normalizeStepList(text) {
219
- const lines = text.split('\n')
220
- const at = lines.findIndex((line) => line.trim())
221
- if (at === -1 || !LIST_MARKER_RE.test(lines[at])) return text
222
- if (lines.some((line, i) => i !== at && LIST_MARKER_RE.test(line))) return text
223
- const item = lines[at].replace(/^ */u, '')
224
- const body = item.replace(LIST_MARKER_RE, '')
225
- if (!body.trim()) return text
226
- const steps = runInSteps(body) ?? runInSteps(item)
227
- // An enumeration neither reading could take apart stays as it
228
- // arrived. Both halves matter: a BODY opening on a marker is the
229
- // sequence behind an outer one (`1. 3) Later 2) Earlier`), and
230
- // unwrapping would leave that marker leading the section; an ITEM
231
- // opening on one is the sequence itself (`3) Later 2) Earlier`),
232
- // where the marker shed as the item's own is a step number, and
233
- // unwrapping would drop it and leave the rest of the count behind.
234
- if (steps === null && (LIST_MARKER_RE.test(body) || RUN_IN_HEAD_RE.test(item))) return text
235
- const read = steps === null ? [body]
236
- : steps.length === 1 ? [steps[0].step]
237
- : steps.map(({ number, step }) => `${number}. ${step}`)
238
- lines.splice(at, 1, ...read)
239
- return lines.join('\n')
240
- }
241
-
242
- // `1) Save 2) Restart` → a step per marker, or null when the text is no
243
- // run-in list: the first marker has to open it and the numbers have to
244
- // ascend, or a step that merely cites `RFC 2616) …` would split the
245
- // prose around it. A number in parens — `curl(1)`, `(2) results` — is
246
- // not a marker, and a marker with NOTHING behind it — a truncated
247
- // `1) Save 2)` — is a sequence this can't read, not a step of its own.
248
- const RUN_IN_STEP_RE = /(?:^|[ \t])(\d{1,9})\)(?=[ \t]|$)/gu
249
- // The same marker, asked of a text's own start.
250
- const RUN_IN_HEAD_RE = /^\d{1,9}\)(?=[ \t]|$)/u
251
-
252
- function runInSteps(text) {
253
- const marks = [...text.matchAll(RUN_IN_STEP_RE)]
254
- if (marks.length === 0 || marks[0].index !== 0) return null
255
- const steps = []
256
- for (const [i, mark] of marks.entries()) {
257
- const number = Number(mark[1])
258
- if (i > 0 && number <= steps[i - 1].number) return null
259
- const step = text.slice(mark.index + mark[0].length, marks[i + 1]?.index).trim()
260
- if (!step) return null
261
- steps.push({ number, step })
262
- }
263
- return steps
264
- }
265
-
266
- // Rows of an `## Evidence` section, in document order:
267
- //
268
- // 1. [libs/a.ts:10–20](https://github.com/o/r/blob/<sha>/libs/a.ts#L10-L20)
269
- // Why this line matters.
270
- //
271
- // Only a marker line is a reference — numbered or bulleted — and the
272
- // prose under it is that row's note, left-trimmed, since the renderer
273
- // indents the row itself.
274
- //
275
- // A section with no markers still yields one row when it is a single
276
- // line, or around the first line carrying a link. Free prose yields
277
- // none, and parseBlock leaves it in the description rather than
278
- // promoting a sentence to a path.
279
- const EVIDENCE_ITEM_RE = /^[ \t]*(?:\d+[.)]|[-*+])\s+/u
280
-
281
- function evidenceRows(text) {
282
- const rows = []
283
- for (const line of text.split('\n')) {
284
- if (EVIDENCE_ITEM_RE.test(line)) rows.push({ ref: line.replace(EVIDENCE_ITEM_RE, '').trim(), note: [] })
285
- else if (rows.length > 0 && line.trim()) rows.at(-1).note.push(line.trim())
286
- }
287
- if (rows.length === 0) {
288
- const bare = text.split('\n').map((l) => l.trim()).filter(Boolean)
289
- const at = bare.findIndex((l) => findMdLink(l) !== null)
290
- if (at === -1 && bare.length !== 1) return []
291
- const refAt = at === -1 ? 0 : at
292
- rows.push({ ref: bare[refAt], note: bare.filter((_, i) => i !== refAt) })
293
- }
294
- return rows.filter((r) => r.ref)
295
- }
296
-
297
- // One row as it lands on the finding. `url` only where the row carried
298
- // a real link — the raw-text fallback is an id discriminator, not an
299
- // href to hand a renderer.
300
- function evidenceEntry({ ref, note }) {
301
- const { file, line, locationLink, linked } = parseLocation(ref)
302
- const entry = { file: file || 'unknown', line }
303
- if (locationLink && linked) entry.url = locationLink
304
- const text = note.join('\n')
305
- if (text) entry.text = text
306
- return entry
307
- }
308
-
309
- // Title + body sections, section labels emitted as `**Label:**` — the
310
- // shape parse-piolium gives its fields, which render-finding.js turns
311
- // into real `<strong>` emphasis and the markdown export re-emits as the
312
- // markdown it is. Everything else survives verbatim, `pre-wrap` on
313
- // `.desc` keeping the shape the report wrote.
314
- function buildDescription(title, sections, hasEvidenceRows) {
315
- const bodyParts = [title]
316
- if (sections.details) bodyParts.push(sections.details)
317
- // An Evidence section that parsed into rows lives on
318
- // `finding.evidence`, and repeating it here would double it. One that
319
- // parsed into none is free prose, and stays rather than being lost.
320
- if (sections.evidence && !hasEvidenceRows) bodyParts.push(`**Evidence:**\n${sections.evidence}`)
321
- if (sections.impact) bodyParts.push(`**Impact:** ${sections.impact}`)
322
- return bodyParts.join('\n\n')
323
- }
@@ -1,79 +0,0 @@
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
- }
@@ -1,126 +0,0 @@
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 { normalizeFindingSeverity } from './severity.js'
9
- import {
10
- idCell, leadingId, leadingLink, mapSeverity, resolveSeverity, severityFromId, slugTitle,
11
- } from './parse-piolium-tokens.js'
12
-
13
- // Normalize a table-row object to the shared row shape used by the
14
- // index, variant tables, group tables, and the row→finding conversion.
15
- // The PoC column appears both as `PoC Status` and plain `PoC`.
16
- export function indexRowOf(obj) {
17
- return {
18
- id: idCell(obj.id || ''),
19
- title: cellValue(obj.title),
20
- severity: cellValue(obj.severity),
21
- pocStatus: cellValue(obj['poc status'] || obj.poc),
22
- status: cellValue(obj.status),
23
- parent: idCell(cellValue(obj.parent || '')),
24
- location: cellValue(obj.location),
25
- }
26
- }
27
-
28
- // A finding known only from a table row. Rows usually carry no path, so
29
- // they land on the same `unknown` / `?` placeholders, and two rows
30
- // sharing a title and tier would derive the SAME uuid for ingest's
31
- // dedupe to swallow one of. The report id is the only discriminator such
32
- // a row has, so it goes in the `location` fingerprint field — which
33
- // deriveFindingId prefers over file/line and nothing renders —
34
- // namespaced to read as an opaque token rather than a URL. A Location
35
- // column, where a table has one, is parsed like any code reference.
36
- export function fromIndexRow(row, sevFallback = '') {
37
- const { severity, identitySeverity } = resolveSeverity(mapSeverity(row.severity), sevFallback, severityFromId(row.id))
38
- const { file, line, locationLink } = parseCodeRef(row.location || '')
39
- const finding = {
40
- file: file || 'unknown',
41
- line,
42
- severity,
43
- description: stripBold(row.title || row.id),
44
- }
45
- if (locationLink) finding.location = locationLink
46
- else if (finding.file === 'unknown' && row.id) finding.location = `piolium:${row.id}`
47
- // The fingerprint reads the same reference its own way — see
48
- // parse-piolium-id.js.
49
- finding._idBasis = frozenIdBasis({
50
- severity: identitySeverity, description: finding.description, ref: row.location || '', id: row.id,
51
- })
52
- if (row.pocStatus) finding.pocStatus = row.pocStatus
53
- if (row.status) finding.status = row.status
54
- if (row.parent) finding.parent = row.parent
55
- return normalizeFindingSeverity(finding)
56
- }
57
-
58
- // Findings rendered as a list: the mode outline asks for "links to
59
- // per-finding report.md", so an item leads with a
60
- // `[<id>-<slug>](…/report.md)` link or a bold id, then a summary. Label
61
- // bullets and "none found" placeholders are not findings.
62
- export function listFindings(body, sev, index) {
63
- const out = []
64
- for (const line of body.split('\n')) {
65
- const m = /^\s{0,3}(?:[-*+]|\d{1,3}[.)])\s+(.+)$/u.exec(line)
66
- if (!m) continue
67
- let text = m[1].trim()
68
- if (/^\*\*[^:*]+:\*\*/u.test(text)) continue
69
-
70
- // The item leads with a link or a bold token; either way it reads
71
- // as plain `<id or title> <summary>` text from here on.
72
- const linked = leadingLink(text)
73
- const link = linked?.link ?? ''
74
- if (linked) {
75
- text = linked.text
76
- } else {
77
- const bold = /^\*\*([^*]+)\*\*\s*[:—–-]*\s*(.*)$/u.exec(text)
78
- if (bold) text = bold[2] ? `${bold[1].trim()} ${bold[2].trim()}` : bold[1].trim()
79
- }
80
- if (/^(?:none\b|no |n\/a\b)/iu.test(text)) continue
81
-
82
- // An id-led item takes its title from the slug and keeps the
83
- // summary as its body; anything else is title only.
84
- const lead = leadingId(text)
85
- const id = lead?.id ?? ''
86
- let title = text
87
- if (lead) {
88
- const slugT = slugTitle(lead.slug)
89
- title = slugT && lead.rest ? `${slugT}\n\n${lead.rest}` : (lead.rest || slugT || lead.id)
90
- }
91
-
92
- const row = index.get(id)
93
- const { severity, identitySeverity } = resolveSeverity(mapSeverity(row?.severity), sev, severityFromId(id))
94
- const finding = { file: 'unknown', line: '?', severity, description: stripBold(title) }
95
- if (id) finding.location = `piolium:${id}`
96
- else if (link) finding.location = link
97
- if (link.endsWith('report.md')) finding.reportPath = link
98
- if (row?.pocStatus) finding.pocStatus = row.pocStatus
99
- if (row?.status) finding.status = row.status
100
- if (row?.parent) finding.parent = row.parent
101
- out.push({ id, finding: normalizeFindingSeverity(finding, identitySeverity) })
102
- }
103
- return out
104
- }
105
-
106
- // Variant rows → findings, parented to the enclosing block where the row
107
- // names none. They are also REGISTERED as index rows, so a variant's own
108
- // `#### <id>` entry adopts their severity / PoC / parent even with no
109
- // `## Summary of Findings` in the report. No table falls back to a
110
- // bullet list at the caller's group severity.
111
- export function variantFindings(tableText, index, parentId, sevFallback = '') {
112
- const out = []
113
- for (const obj of tableObjects(tableText)) {
114
- const row = indexRowOf(obj)
115
- if (!row.id && !row.title) continue
116
- if (!row.parent && parentId) row.parent = parentId
117
- if (row.id && !index.has(row.id)) index.set(row.id, row)
118
- out.push({ id: row.id, finding: fromIndexRow(row, sevFallback) })
119
- }
120
- if (out.length > 0) return out
121
- const items = listFindings(tableText, sevFallback, index)
122
- for (const e of items) {
123
- if (parentId && !e.finding.parent) e.finding.parent = parentId
124
- }
125
- return items
126
- }