@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,185 +0,0 @@
1
- // Token-level helpers for the Piolium report parser: severity words,
2
- // finding-id shapes, heading forms, and field-name aliases. Split from
3
- // parse-piolium.js, which owns the document structure; everything here
4
- // is a pure string classifier.
5
-
6
- import { isCommitHash, stripBrackets } from './md-structure.js'
7
- import { isRepoSlug } from './meta.js'
8
-
9
- // Piolium grades findings CRITICAL / HIGH / MEDIUM — its assembler
10
- // rejects Low-severity leakage into `findings/` — but drafts and
11
- // deferred entries can carry LOW or INFO, so the full ladder is mapped.
12
- // The call sites fall back to medium, keeping an odd tier visible.
13
- //
14
- // Only the first token is read: a bullet value keeps its continuation
15
- // lines and may carry a parenthetical ("CRITICAL (raised after the PoC
16
- // ran)"), while the tier is one word. Backticks and asterisks are shed,
17
- // so `**CRITICAL**` reads.
18
- export function mapSeverity(s) {
19
- const first = ((s || '').trim().split(/\s+/u)[0] || '').replaceAll(/[`*]+/gu, '')
20
- switch (first.toUpperCase()) {
21
- case 'CRITICAL': return 'critical'
22
- case 'HIGH': return 'high'
23
- case 'MEDIUM': return 'medium'
24
- case 'LOW': return 'low'
25
- case 'INFO': case 'INFORMATIONAL': return 'informational'
26
- case 'INFORMATIVE': return 'informative'
27
- default: return ''
28
- }
29
- }
30
-
31
- // Keep the pre-alias fallback for the ID fingerprint, while displaying the
32
- // newly recognized tier. Inputs are ordered from strongest to weakest source.
33
- export function resolveSeverity(...tiers) {
34
- return {
35
- severity: tiers.find(Boolean) || 'medium',
36
- identitySeverity: tiers.find(tier => tier && tier !== 'informative') || 'medium',
37
- }
38
- }
39
-
40
- // Final-report ids are severity-prefixed and sequential — `C1`, `H2`,
41
- // `H-001` in lite consolidation — so the prefix is a second source for
42
- // the tier. Only that exact scheme counts: a bare leading letter would
43
- // read `CVE-2024-1234` as critical, and a draft id carries no tier.
44
- export function severityFromId(id) {
45
- const m = /^([CHML])-?\d+$/iu.exec((id || '').trim())
46
- if (!m) return ''
47
- return { C: 'critical', H: 'high', M: 'medium', L: 'low' }[m[1].toUpperCase()]
48
- }
49
-
50
- // A heading that IS a severity — `Critical`, `HIGH (2)`, `Critical
51
- // Severity`, `Medium-Risk Findings (3)` — marks a GROUP of that tier's
52
- // findings. Anchored to the whole heading, so "High memory usage in
53
- // parser" is never mistaken for one.
54
- export function severityGroupOf(heading) {
55
- const m = /^(critical|high|medium|low|informational|informative|info)(?:[ -](?:severity|risk))?(?:[ -]findings?)?(?:\s*\(\d+\))?$/iu
56
- .exec((heading || '').trim())
57
- return m ? mapSeverity(m[1]) : ''
58
- }
59
-
60
- // A leading severity word on a free-form header — `HIGH — 3 findings`,
61
- // `High: remaining` — for sections recognized by their CONTENT rather
62
- // than the anchored severityGroupOf shape.
63
- export function headerSeverity(header) {
64
- const m = /^(critical|high|medium|low|informational|informative|info)\b/iu.exec((header || '').trim())
65
- return m ? mapSeverity(m[1]) : ''
66
- }
67
-
68
- // A variants heading (`#### Variants`, `### Variants (2)`), not a
69
- // finding — matched wherever finding headings are read.
70
- export function isVariantsHeading(heading) {
71
- return /^variants?\s*(?:\(\d+\))?\s*:?$/iu.test((heading || '').trim())
72
- }
73
-
74
- // A token as a piolium finding id, with the directory slug when it
75
- // carries one. Two schemes: severity-prefixed final ids (`C1`, `H-001`,
76
- // `C1-command-injection`) and draft-phase ids from the analysis phases
77
- // (`p10-011`, `q1-001`, `diff-003`). The phase letters are a closed set
78
- // with a 2+ digit sequence, so prose tokens (`UTF-8`, `SHA-256`) never
79
- // read as ids. Upper-cased, so `[c1]` meets its `[C1]` index row.
80
- export function idFromToken(token) {
81
- const m = /^([CHML]-?\d{1,4}|(?:p|q|b|r|m|l|x|diff)\d{0,4}-\d{2,4})(?:-([A-Za-z0-9][\w-]*))?$/iu
82
- .exec(token || '')
83
- return m ? { id: m[1].toUpperCase(), slug: m[2] || '' } : null
84
- }
85
-
86
- // `command-injection` → `command injection` — the human-readable title
87
- // recovered from an <id>-<slug> directory-name reference.
88
- export function slugTitle(slug) {
89
- return (slug || '').replaceAll('-', ' ')
90
- }
91
-
92
- // An id cell in any of its spellings — `C1`, `[C1]`,
93
- // `[p12-001](#p12-001)` — as the upper-case id.
94
- export function idCell(s) {
95
- const v = (s || '').trim()
96
- const link = /^\[([^\]]+)\]\([^)]*\)$/u.exec(v)
97
- return stripBrackets(link ? link[1] : v).toUpperCase()
98
- }
99
-
100
- // A heading or item leading with a link —
101
- // `[C1-command-injection](…/report.md): Title` — as plain text with the
102
- // url apart: `{ text: 'C1-command-injection Title', link }`.
103
- export function leadingLink(value) {
104
- const m = /^\[([^\]]+)\]\(([^)]+)\)\s*[:—–-]*\s*(.*)$/u.exec(value)
105
- if (!m) return null
106
- return { text: m[3] ? `${m[1].trim()} ${m[3].trim()}` : m[1].trim(), link: m[2].trim() }
107
- }
108
-
109
- // `text` split at its first whitespace when the leading token is an id —
110
- // `p10-011 — Title`, `C1: Title` — as `{ id, slug, rest }`, trailing
111
- // punctuation shed from the token and the separator from the rest.
112
- export function leadingId(text) {
113
- const space = text.search(/\s/u)
114
- const first = (space === -1 ? text : text.slice(0, space)).replace(/[:.,—–-]+$/u, '')
115
- const tok = idFromToken(first)
116
- if (!tok) return null
117
- const rest = (space === -1 ? '' : text.slice(space + 1)).replace(/^[:—–-]+\s*/u, '').trim()
118
- return { id: tok.id, slug: tok.slug, rest }
119
- }
120
-
121
- // A finding heading in any of its observed spellings:
122
- // `[C1] Title` (pentest template)
123
- // `[C1-command-injection](url)` (mode outline: linked dir name)
124
- // `C1-command-injection` (bare dir name)
125
- // `p10-011 — Title` / `C1: Title` (id + separator + title)
126
- // `Title` (bare title)
127
- // Returns { id, title, link } — id '' when the heading carries none,
128
- // link '' unless the heading's leading token is a markdown link.
129
- export function parseHeading(headingText) {
130
- const { text, link } = leadingLink(headingText) ?? { text: headingText, link: '' }
131
- const bracket = /^\[([^\]]+)\] *(.*)$/u.exec(text)
132
- if (bracket) {
133
- const tok = idFromToken(bracket[1].trim())
134
- if (tok) return { id: tok.id, title: bracket[2].trim() || slugTitle(tok.slug) || tok.id, link }
135
- // Non-id bracket content is still a usable dedupe key for the
136
- // seen-set, unrecognized scheme and all (`[SEC-001]`).
137
- return { id: bracket[1].trim().toUpperCase(), title: bracket[2].trim(), link }
138
- }
139
- const lead = leadingId(text)
140
- if (lead) return { id: lead.id, title: lead.rest || slugTitle(lead.slug) || lead.id, link }
141
- return { id: '', title: text.trim(), link }
142
- }
143
-
144
- // The document preamble (before the first `## ` section) carries the
145
- // audit's run metadata as `**Label** value` lines:
146
- //
147
- // # Security Audit Report: owner/repo
148
- // **Target** `owner/repo` (description)
149
- // **Commit audited** `<sha>` (prose)
150
- // **Audit ID** `…` · **Mode** deep (17-phase) · **Report assembled** …
151
- //
152
- // `**Target**` declares the audited repository, where the H1 title's
153
- // bare <project> may be a monorepo path and isn't trusted. The value is
154
- // the first backtick span or leading token, taken only in strict
155
- // `owner/repo` shape, the commit only as plain hex. Audit ID and Mode
156
- // are run bookkeeping with no consumer.
157
- export function preambleMeta(head) {
158
- const meta = {}
159
- const value = (rest) => (/`([^`]+)`/u.exec(rest)?.[1] ?? rest.split(/\s+/u)[0] ?? '').trim()
160
- const target = /^\s*(?:[-*] +)?\*\*Target:?\*\*\s*(.*)$/imu.exec(head || '')
161
- if (target) {
162
- const v = value(target[1])
163
- if (isRepoSlug(v)) meta.repo = v
164
- }
165
- const commit = /^\s*(?:[-*] +)?\*\*Commit[^:*]*:?\*\*\s*(.*)$/imu.exec(head || '')
166
- if (commit) {
167
- const v = value(commit[1])
168
- if (isCommitHash(v)) meta.commitHash = v
169
- }
170
- return meta
171
- }
172
-
173
- // "Key Code Reference" is the assembler's name for a finding's code
174
- // location, which real reports shorten and reword, so every observed
175
- // spelling is accepted, most specific first. Deliberately absent:
176
- // `files`, which reports use for reproduction attachments, and bare
177
- // `code`, which matches PoC-code fields.
178
- export const CODE_REF_FIELDS = [
179
- 'key code reference', 'key code', 'code reference', 'location',
180
- 'affected file', 'file', 'path',
181
- ]
182
- export function codeRefOf(fields) {
183
- for (const k of CODE_REF_FIELDS) if (fields[k]) return fields[k]
184
- return ''
185
- }
@@ -1,399 +0,0 @@
1
- // Piolium markdown findings parser. Piolium is Vigolium's agentic
2
- // repository audit agent (https://github.com/vigolium/piolium), writing
3
- // its artifacts under `piolium/` in the audited repo. The only file
4
- // read here is the CONSOLIDATED run report,
5
- // `piolium/final-audit-report.md`; the per-finding
6
- // `piolium/findings/<id>-<slug>/report.md` files are deliberately not
7
- // an input — one file per finding doesn't fit the one-file-per-report
8
- // model, and the consolidated report already inlines or links each one.
9
- //
10
- // The report is COMPOSED BY AN AGENT, so its structure varies by mode
11
- // and by run. Three observed layouts anchor the parser; everything else
12
- // is handled by being liberal within them.
13
- //
14
- // Layout A — the pentest template: a `## Summary of Findings` index
15
- // table (`| [C1] | Title | CRITICAL | executed | -- |`) plus
16
- // `## Technical Findings Detail` with `### [C1] Title` blocks of
17
- // `- **Severity:** / **Summary:** / **Impact:** / **Root Cause:** /
18
- // **Key Code Reference:** / **PoC Status:**` bullets and an optional
19
- // `#### Variants` sub-table.
20
- //
21
- // Layout B — the mode task outline (modes/balanced.ts L6c,
22
- // modes/deep.ts P15): `## Findings by Severity` with severity groups
23
- // (`### Critical`, counted `### HIGH (2)`, or promoted to
24
- // `## Critical Findings`) whose findings are `#### ` blocks, an
25
- // id/title table, or a `- [<id>-<slug>](…/report.md): summary` list.
26
- //
27
- // Layout C — real assembler output: anchored draft-phase ids and
28
- // per-variant entries,
29
- //
30
- // <a id="p10-011"></a>
31
- // ### p10-011 — Title
32
- //
33
- // - **Severity:** HIGH
34
- // - **Key code:** `src/a.js:20` (`fnA`) → `src/b.js:600` → `src/c.js`
35
- // - **PoC:** executed (…)
36
- // - **Files:** …reproduction attachments, ignored…
37
- //
38
- // #### Variants
39
- // | ID | Title | Severity | Location | PoC |
40
- // |----|-------|----------|----------|-----|
41
- // | [p12-001](#p12-001) | Variant title | MEDIUM | `src/d.js:50-60` | executed |
42
- //
43
- // <a id="p12-001"></a>
44
- // #### p12-001 — Variant title
45
- // - **Variant of** [p10-011](#p10-011) · **Pattern** `pattern-id`
46
- //
47
- // Variants exist BOTH as table rows and as their own full entries; the
48
- // entry carries the narrative and wins, so rows are deferred and
49
- // emitted only for ids no entry covered — never twice, and never as a
50
- // finding titled "Variants". Rows are also registered as index rows, so
51
- // an entry adopts its row's severity / PoC / parent.
52
- //
53
- // Returns `{ type, source: 'piolium', findings }`, or null when the
54
- // text isn't this format.
55
- //
56
- // Deliberately NOT findings: `## Methodology Summary` / `Notes` (a
57
- // finding's bullet shape, but about the run), `## Attack Surface
58
- // Summary` and `## Coverage Gaps` (link lists about the audit), and
59
- // `## Deferred Findings (triage skip)` (drafts triage did not promote).
60
- // Only findings-labelled sections, severity groups and the index are
61
- // read — and structural markdown only OUTSIDE fenced code, since
62
- // piolium inlines PoC snippets and a fenced `## step 2` must not end a
63
- // section (md-structure.js).
64
-
65
- import { H2_RE, H3_RE, H4_RE, normalizeNewlines, parseCodeRef, parseLabelledFields, splitByHeading, splitLeading, tableObjects } from './md-structure.js'
66
- import { frozenIdBasis } from './parse-piolium-id.js'
67
- import { normalizeFindingSeverity } from './severity.js'
68
- import { fromIndexRow, indexRowOf, listFindings, variantFindings } from './parse-piolium-rows.js'
69
- import {
70
- CODE_REF_FIELDS, codeRefOf, headerSeverity, idCell, idFromToken,
71
- isVariantsHeading, mapSeverity, parseHeading, preambleMeta,
72
- resolveSeverity, severityFromId, severityGroupOf,
73
- } from './parse-piolium-tokens.js'
74
-
75
-
76
- // Section headers whose body holds the findings. Deliberate non-matches:
77
- // 'summary of findings' (the index, read separately), the excluded
78
- // appendices, and the prose/link sections.
79
- const DETAIL_HEADERS = new Set([
80
- 'technical findings detail', 'technical findings',
81
- 'detailed findings', 'findings detail', 'findings',
82
- ])
83
- function isDetailHeader(header) {
84
- return DETAIL_HEADERS.has(header) || header.startsWith('findings by severity')
85
- }
86
-
87
- // Never mined for findings, even carrying id-shaped headings or tables.
88
- const EXCLUDED_HEADERS = /^(?:summary of findings|deferred|methodolog|executive|conclusion|attack surface|coverage|discoveries|scope|table of contents|contents|appendix|recommendation|remediation)/u
89
- function isExcludedHeader(header) {
90
- return EXCLUDED_HEADERS.test(header)
91
- }
92
-
93
- // Section names vary run to run ('## HIGH — 3 findings', '## Confirmed
94
- // Findings', emoji prefixes), so a non-excluded section whose headings
95
- // carry id-shaped tokens holds findings whatever it is called.
96
- function headingHasId(heading) {
97
- const { id } = parseHeading(heading)
98
- return Boolean(id && idFromToken(id))
99
- }
100
- function hasIdBlocks(body) {
101
- const blocks = splitByHeading(body, H3_RE)
102
- const list = blocks.length > 0 ? blocks : splitByHeading(body, H4_RE)
103
- return list.some(({ heading }) => headingHasId(heading))
104
- }
105
-
106
- export function parsePioliumFindings(content) {
107
- const text = normalizeNewlines(content).trim()
108
- // Any one signal is enough: a project can retitle the H1, and a
109
- // hand-trimmed report can drop the prose sections and keep the
110
- // findings. Requiring none would steal plain `# Title` documents from
111
- // parse-md.js, which accepts any h1-led markdown.
112
- if (!/^# +Security Audit Report\b/mu.test(text)
113
- && !/^## +Technical Findings Detail\s*$/imu.test(text)
114
- && !/^## +Findings by Severity\b/imu.test(text)) return null
115
-
116
- const sections = parseSections(text)
117
- const index = parseIndexTable(sections['summary of findings'] || '')
118
- const meta = preambleMeta(splitLeading(text, H2_RE).head)
119
-
120
- const findings = []
121
- const seen = new Set()
122
- // Variant rows wait here until the document is read out: a row is
123
- // emitted only where no entry claimed its id.
124
- const pending = []
125
- const push = ({ id, finding }) => {
126
- findings.push(finding)
127
- if (id) seen.add(id)
128
- }
129
- const emit = (entries) => { for (const entry of entries) push(entry) }
130
-
131
- for (const [header, body] of Object.entries(sections)) {
132
- // A findings section: a severity group promoted to section level
133
- // (`## Critical Findings`), a findings-labelled header, or the
134
- // content-based fallback for every other spelling a run invents,
135
- // where a leading severity word still supplies the tier.
136
- const groupSev = severityGroupOf(header)
137
- if (groupSev || isDetailHeader(header)) {
138
- emit(parseFindingsBody(body, groupSev, index, pending))
139
- } else if (!isExcludedHeader(header) && hasIdBlocks(body)) {
140
- emit(parseFindingsBody(body, headerSeverity(header), index, pending))
141
- }
142
- }
143
-
144
- for (const entry of pending) {
145
- if (!entry.id || !seen.has(entry.id)) push(entry)
146
- }
147
-
148
- // Anything the index lists but no block described, from the row
149
- // alone: the index is the authoritative list, so a report with a
150
- // truncated detail section still triages every finding.
151
- for (const row of index.values()) {
152
- if (!seen.has(row.id)) push({ id: row.id, finding: fromIndexRow(row) })
153
- }
154
-
155
- if (findings.length === 0) return null
156
-
157
- // The preamble's `**Target**` names the audited repository, stamped
158
- // per finding (repo.github is per-finding downstream) with its own
159
- // object copy. The H1 title alone is not trusted: its <project> holds
160
- // a monorepo path as easily as a slug, and a wrong `repo.github` is
161
- // worse than none — format.js's fileUrl prefers it over the editable
162
- // repo chip, so a bad guess yields dead links the user can't correct.
163
- // `**Commit audited**` lands as `auditedCommit`, NOT `commitHash`,
164
- // which the card renders as "introduced in <commit>": the scan commit
165
- // says where the audit ran, not where the bug landed.
166
- for (const f of findings) {
167
- if (meta.repo) f.repo = { github: meta.repo }
168
- if (meta.commitHash && !f.auditedCommit) f.auditedCommit = meta.commitHash
169
- }
170
-
171
- // Report-level 'security' for the document.title fallback. No
172
- // per-finding `type` — piolium categorizes by severity, so a
173
- // synthetic one would print the same word on every run-meta line, the
174
- // call parse-deepsec.js and parse-codex.js also make — and ingest.js's
175
- // `data.source` gate keeps the report-level one off the findings.
176
- return { type: 'security', source: 'piolium', findings }
177
- }
178
-
179
- // The `### ` blocks of a findings section or section-level severity
180
- // group, tier in `sev`: each a finding, a severity group of its own, or
181
- // a `### Variants` block parented to the block before it.
182
- function parseDetailBlocks(blocks, index, pending, sev) {
183
- const out = []
184
- let lastId = ''
185
- for (const { heading, body } of blocks) {
186
- const groupSev = severityGroupOf(heading)
187
- if (groupSev) {
188
- out.push(...parseFindingsBody(body, groupSev, index, pending))
189
- lastId = ''
190
- } else if (isVariantsHeading(heading)) {
191
- out.push(...parseVariantsBlock(body, index, lastId, pending, sev))
192
- } else {
193
- const entries = parseFindingBlock(heading, body, index, pending, sev)
194
- out.push(...entries)
195
- lastId = entries[0]?.id || lastId
196
- }
197
- }
198
- return out
199
- }
200
-
201
- // A `### ` finding block: the head, before any `#### `, is the finding.
202
- // A `#### Variants` sub-heading defers its rows to `pending`; any other
203
- // is a full entry of its own, as Layout C writes each variant.
204
- function parseFindingBlock(heading, body, index, pending, sev) {
205
- const { head, subs } = splitLeading(body, H4_RE)
206
- const parent = parseBlock(heading, head, index, sev)
207
- // A `### ` heading with no id and no index row, over id-shaped
208
- // `#### ` entries, is a CATEGORY grouping: the entries are the
209
- // findings, and emitting the heading would add one title-only result
210
- // per category.
211
- const isCategory = parent !== null && !parent.id
212
- && subs.some((s) => !isVariantsHeading(s.heading) && headingHasId(s.heading))
213
- const out = parent && !isCategory ? [parent] : []
214
- out.push(...parseEntries(subs, index, pending, sev, parent?.id || ''))
215
- return out
216
- }
217
-
218
- // `### Variants` as its own block: tables defer to pending, `#### <id>`
219
- // sub-blocks are the variants' entries. `parentId` is the block before
220
- // this one, the structural parent for rows naming none.
221
- function parseVariantsBlock(body, index, parentId, pending, sev) {
222
- const { head, subs } = splitLeading(body, H4_RE)
223
- pending.push(...variantFindings(head, index, parentId, sev))
224
- return parseEntries(subs, index, pending, sev, '')
225
- }
226
-
227
- // The `#### ` entries of a finding block, a `### Variants` block or a
228
- // severity group — each a finding, except a `#### Variants` table, whose
229
- // rows defer to `pending` under `parentId`, or under the entry before
230
- // the table when they are siblings at group level.
231
- function parseEntries(subs, index, pending, sev, parentId) {
232
- const out = []
233
- let lastId = ''
234
- for (const { heading, body } of subs) {
235
- if (isVariantsHeading(heading)) {
236
- pending.push(...variantFindings(body, index, parentId ?? lastId, sev))
237
- continue
238
- }
239
- const entry = parseBlock(heading, body, index, sev)
240
- if (!entry) continue
241
- out.push(entry)
242
- lastId = entry.id || lastId
243
- }
244
- return out
245
- }
246
-
247
- // The body of a findings section or severity group, holding that tier's
248
- // findings in whichever rendering the assembler chose. A body with
249
- // `### ` blocks routes through parseDetailBlocks, and `### ` MUST win
250
- // over `#### ` there, or one `#### Variants` would swallow every `### `
251
- // sibling as its body. Otherwise the findings are `#### ` sub-blocks, an
252
- // id/title table, or a list. Prose alone ("None identified.") yields
253
- // nothing.
254
- function parseFindingsBody(body, sev, index, pending) {
255
- const h3 = splitByHeading(body, H3_RE)
256
- if (h3.length > 0) return parseDetailBlocks(h3, index, pending, sev)
257
-
258
- const out = parseEntries(splitByHeading(body, H4_RE), index, pending, sev, null)
259
- if (out.length > 0) return out
260
-
261
- // An id/title table here is the INDEX in another position — the real
262
- // reports put the overview under `## Findings by Severity` and the
263
- // blocks under per-severity sections — so emitting rows eagerly would
264
- // double-report every finding. They merge into the index instead, and
265
- // the gated fallback emits only ids no block claimed. A row with no id
266
- // can't be index-keyed and defers via pending, as list items do.
267
- const rows = tableObjects(body).map(indexRowOf).filter((r) => r.id || r.title)
268
- if (rows.length > 0) {
269
- for (const r of rows) {
270
- if (!r.severity && sev) r.severity = sev
271
- if (r.id && !index.has(r.id)) index.set(r.id, r)
272
- else if (!r.id) pending.push({ id: '', finding: fromIndexRow(r, sev) })
273
- }
274
- return []
275
- }
276
- pending.push(...listFindings(body, sev, index))
277
- return []
278
- }
279
-
280
- function parseBlock(heading, body, index, groupSeverity = '') {
281
- const headingText = heading.trim()
282
- if (!headingText) return null
283
-
284
- let { id, title, link } = parseHeading(headingText)
285
- let row = index.get(id)
286
- // A bare-title heading ADOPTS the index row of the same title, so the
287
- // block and the row are one finding; without that, the index fallback
288
- // would emit it a second time.
289
- if (!id && title) {
290
- row = [...index.values()].find((r) => r.title.toLowerCase() === title.toLowerCase())
291
- id = row?.id ?? ''
292
- }
293
-
294
- const { fields, labels, prose } = parseLabelledFields(body)
295
-
296
- // Precedence: the block's own bullet, the index row, the enclosing
297
- // group, the id's prefix, then medium — where an unrecognized tier
298
- // stays visible rather than dropping out.
299
- const { severity, identitySeverity } = resolveSeverity(
300
- mapSeverity(fields.severity), mapSeverity(row?.severity), groupSeverity, severityFromId(id),
301
- )
302
-
303
- const ref = parseCodeRef(codeRefOf(fields))
304
- // A `**Line:**` / `**Lines:**` bullet supplies the line when the
305
- // reference itself carries none.
306
- const lineBullet = /\d+/u.exec(fields.line || fields.lines || '')?.[0] ?? ''
307
- const line = ref.line === '?' && lineBullet ? lineBullet : ref.line
308
-
309
- // `- **Variant of** [p10-011](#p10-011) · …` names the parent, with or
310
- // without the colon that would make it a labelled field; read off the
311
- // raw body either way, and kept out of the description along with
312
- // `<a id>` anchor chrome.
313
- const variantOf = /\*\*Variant of:?\*\*\s*\[?([^\]\s)]+)/iu.exec(body)
314
- const proseClean = prose.split('\n')
315
- .filter((l) => !/^\s*<a\s[^>]*>\s*<\/a>\s*$/iu.test(l) && !/\*\*Variant of:?\*\*/iu.test(l))
316
- .join('\n').trim()
317
-
318
- const finding = {
319
- file: ref.file || 'unknown',
320
- line,
321
- severity,
322
- description: buildDescription(title || id, fields, labels, proseClean),
323
- }
324
- if (ref.locationLink) finding.location = ref.locationLink
325
- // Last-resort fingerprint discriminator for an unlocated finding —
326
- // see fromIndexRow for why.
327
- else if (finding.file === 'unknown' && id) finding.location = `piolium:${id}`
328
- // The id fingerprint is parse-piolium-id.js's own reading of the
329
- // same reference, not the one above: what `parseCodeRef` makes of a
330
- // reference is presentation and free to improve, the fingerprint is
331
- // not. finding-id.js prefers `_idBasis` when deriving the uuid; read
332
- // that module's header before touching either side.
333
- finding._idBasis = frozenIdBasis({
334
- severity: identitySeverity, description: finding.description, ref: codeRefOf(fields), lineBullet, id,
335
- })
336
- // Auxiliary provenance, kept as plain strings so an export can cite
337
- // the audit's own artifacts — as parse-md.js keeps branch / status.
338
- const pocStatus = fields['poc status'] || fields.poc || row?.pocStatus
339
- if (pocStatus) finding.pocStatus = pocStatus
340
- const reportPath = fields['detailed report'] || (link.endsWith('report.md') ? link : '')
341
- if (reportPath) finding.reportPath = reportPath
342
- if (row?.status) finding.status = row.status
343
- const parent = row?.parent || (variantOf ? idCell(variantOf[1]) : '')
344
- if (parent) finding.parent = parent
345
-
346
- return { id, finding: normalizeFindingSeverity(finding) }
347
- }
348
-
349
- // The `## ` sections, keyed case-folded. A repeated header CONCATENATES
350
- // rather than overwrites, or concatenated runs (`cat a.md b.md`) and an
351
- // index split across tables would keep only the last. Null-prototype, so
352
- // a section named after an Object.prototype member aliases nothing.
353
- function parseSections(text) {
354
- const sections = Object.create(null)
355
- for (const { heading, body } of splitByHeading(text, H2_RE)) {
356
- const header = heading.trim().toLowerCase()
357
- if (!header) continue
358
- sections[header] = header in sections ? `${sections[header]}\n${body}` : body
359
- }
360
- return sections
361
- }
362
-
363
- // `## Summary of Findings` → id → row: both the gap-filler for a sparse
364
- // block (PoC status, parent, verdict) and the source of last resort.
365
- function parseIndexTable(text) {
366
- const index = new Map()
367
- for (const obj of tableObjects(text)) {
368
- const row = indexRowOf(obj)
369
- if (!row.id) continue
370
- index.set(row.id, row)
371
- }
372
- return index
373
- }
374
-
375
- // Mechanical fields, which must not repeat into the description:
376
- // severity / code-reference / PoC plumbing, attachments, cross-links.
377
- // Everything ELSE a report labels — `Impact`, `Root cause`, or an
378
- // invented `Residual risk` — is narrative and belongs in the story.
379
- const NON_NARRATIVE_FIELDS = new Set([
380
- ...CODE_REF_FIELDS, 'severity', 'summary', 'files', 'poc',
381
- 'poc status', 'line', 'lines', 'detailed report', 'proof of concept',
382
- 'evidence', 'variant of', 'pattern', 'status', 'id', 'title',
383
- ])
384
-
385
- // Heading + narrative in document order: the Summary, from a label or
386
- // from unlabelled prose — both contribute, since a block often carries
387
- // its labels first and its narrative in the paragraph under them — then
388
- // every narrative label in its ORIGINAL casing, kept `**bold**`, which
389
- // the card renders as real <strong> and the export re-emits as markdown.
390
- function buildDescription(title, fields, labels, prose) {
391
- const parts = [title]
392
- if (fields.summary) parts.push(fields.summary)
393
- if (prose) parts.push(prose)
394
- for (const [k, v] of Object.entries(fields)) {
395
- if (!v || NON_NARRATIVE_FIELDS.has(k)) continue
396
- parts.push(`**${labels[k] || k}:** ${v}`)
397
- }
398
- return parts.filter(Boolean).join('\n\n')
399
- }
@@ -1,63 +0,0 @@
1
- import { isSecurityFinding } from './finding.js'
2
-
3
- function securityGraph() {
4
- const ids = [], nodes = new Map()
5
- const nodeFor = (key) => {
6
- if (!nodes.has(key)) {
7
- const node = { security: false }
8
- node.parent = node
9
- nodes.set(key, node)
10
- if (typeof key === 'string') ids.push(key)
11
- }
12
- return nodes.get(key)
13
- }
14
- const root = (node) => {
15
- let current = node
16
- while (current.parent !== current) current = current.parent
17
- while (node.parent !== node) { const next = node.parent; node.parent = current; node = next }
18
- return current
19
- }
20
- const join = (a, b) => {
21
- a = root(a); b = root(b)
22
- if (a !== b) { b.parent = a; a.security ||= b.security }
23
- }
24
- return { nodeFor, root, join, ids }
25
- }
26
-
27
- // Stamp the complete row/link component, before any display lens hides tabs.
28
- // The callbacks are synchronous lookups of already-known links and index rows;
29
- // this never loads reports. Only the supplied findings are mutated. Recompute
30
- // from intrinsic evidence so removing a sibling/link does not latch a true flag.
31
- export function stampSecurityGroups(groups, { source, linkedIds = () => [], knownRows = () => [] } = {}) {
32
- const { nodeFor, root, join, ids } = securityGraph()
33
- const findings = []
34
- const addRow = (group, known = false) => {
35
- let first
36
- for (const f of group) {
37
- if (!f || typeof f !== 'object') continue
38
- const node = nodeFor(f.id || f)
39
- root(node).security ||= known ? f.isSecurity === true : isSecurityFinding(f, f._source ?? f.source ?? source)
40
- if (first) join(first, node)
41
- else first = node
42
- if (!known) findings.push([f, node])
43
- }
44
- }
45
- for (const group of groups) addRow(group)
46
- const seenRows = new Set()
47
- // Growing queue: a known linked row can have siblings with links of their own.
48
- for (let i = 0; i < ids.length; i++) {
49
- const id = ids[i], node = nodeFor(id)
50
- for (const other of linkedIds(id)) join(node, nodeFor(other))
51
- for (const row of knownRows([id])) {
52
- if (seenRows.has(row)) continue
53
- seenRows.add(row)
54
- addRow(row.members, true)
55
- }
56
- }
57
- let changed = false
58
- for (const [f, node] of findings) {
59
- const security = root(node).security
60
- if (f.isSecurity !== security) { f.isSecurity = security; changed = true }
61
- }
62
- return changed
63
- }
@@ -1,18 +0,0 @@
1
- import { fingerprintOf } from './finding-id.js'
2
-
3
- // Informative is an input spelling of the existing Informational tier.
4
- export function canonicalSeverity(value) {
5
- return typeof value === 'string' && value.trim().toLowerCase() === 'informative' ? 'informational' : value
6
- }
7
-
8
- // Normalize presentation without re-keying stored triage. Markdown parsers
9
- // can supply the severity their previous fallback used for the fingerprint.
10
- export function normalizeFindingSeverity(finding, identitySeverity = finding.severity) {
11
- const severity = canonicalSeverity(finding.severity)
12
- if (severity !== finding.severity) {
13
- if (!finding.id && !finding._idBasis) finding._idBasis = fingerprintOf({ ...finding, severity: identitySeverity })
14
- finding.severity = severity
15
- }
16
- if (finding.correctedSeverity !== undefined) finding.correctedSeverity = canonicalSeverity(finding.correctedSeverity)
17
- return finding
18
- }