@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1330 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +25 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -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,322 @@
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 { LIST_MARKER_RE, 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']) {
97
+ finding.reproduction = normalizeStepList(sections['reproduction steps'])
98
+ }
99
+ if (sections['recommended fix']) finding.recommendation = sections['recommended fix']
100
+ if (meta.repository) finding.repo = { github: meta.repository }
101
+ // Auxiliary metadata, kept as plain strings: nothing renders these
102
+ // specifically, but the markdown export prints what a finding carries.
103
+ if (meta.branch) finding.branch = meta.branch
104
+ if (meta['date created']) finding.dateCreated = meta['date created']
105
+ if (meta.status) finding.status = meta.status
106
+ // The issue class the report filed the finding under ("insufficient
107
+ // verification of data authenticity"), as written. NOT the finding's
108
+ // `type`, which is the analyzer run a native dump names — this report
109
+ // has one analyzer, and `source` above says which.
110
+ if (meta.category) finding.category = meta.category
111
+ // The fingerprint is parse-md-id.js's own parse of this same block,
112
+ // not the fields above: those are presentation and free to change, it
113
+ // is not. Nothing this parser resolved is passed in. Read that
114
+ // module's header before touching either side.
115
+ const idBasis = frozenIdBasis(block)
116
+ if (idBasis) finding._idBasis = idBasis
117
+
118
+ return finding
119
+ }
120
+
121
+ // The sections half (before the first `---`) and the metadata half
122
+ // (from there to the next `---` or the end).
123
+ function splitBody(body) {
124
+ const dashRe = /^---\s*$/mu
125
+ const dashMatch = dashRe.exec(body)
126
+ if (!dashMatch) return { sectionsText: body, metaText: '' }
127
+ const sectionsText = body.slice(0, dashMatch.index).trim()
128
+ const rest = body.slice(dashMatch.index + dashMatch[0].length).replace(/^\n/u, '')
129
+ const next = dashRe.exec(rest)
130
+ const metaText = next ? rest.slice(0, next.index) : rest
131
+ return { sectionsText, metaText }
132
+ }
133
+
134
+ // Named sections, split on `## Header`. Whatever precedes the first
135
+ // heading is dropped.
136
+ function parseSections(sectionsText) {
137
+ const sections = {}
138
+ for (const part of sectionsText.split(/^## /mu).slice(1)) {
139
+ const { title, body } = splitHeadingLine(part)
140
+ const header = title.toLowerCase()
141
+ if (header) sections[header] = body.trim()
142
+ }
143
+ return sections
144
+ }
145
+
146
+ // `**Label:** value` per line, keyed case-folded.
147
+ function parseMeta(metaText) {
148
+ const meta = {}
149
+ for (const m of metaText.matchAll(/\*\*([^:]+):\*\*\s*(.+)/gu)) {
150
+ meta[m[1].trim().toLowerCase()] = m[2].trim()
151
+ }
152
+ return meta
153
+ }
154
+
155
+ // One `## Location` line or one `## Evidence` row, a markdown link
156
+ // preferred. The line comes from a `#L<n>` anchor in the url, a `:<n>`
157
+ // suffix on the name, or nowhere (`?`). A RANGE is kept whole (`10-20`),
158
+ // as parse-piolium.js keeps it, with the en / em dashes the Evidence
159
+ // template writes normalized to a hyphen.
160
+ //
161
+ // `locationLink` is the url, or the raw text when there is none:
162
+ // finding-id.js keys off it with no fileHash available, so two imports
163
+ // of a finding share one uuid and its triage.
164
+ function parseLocation(loc) {
165
+ let file = '', line = '?', locationLink = ''
166
+ // Brackets and parens and all: `app/(main)/[id]/page.ts` is an
167
+ // ordinary Next.js path, and a reading that stops at the first `]`
168
+ // finds no link in it — leaving the whole `[…](…)` as the file name,
169
+ // the line `?`, and an evidence row with no url.
170
+ const link = findMdLink(loc)
171
+ if (link) {
172
+ file = link.label.trim()
173
+ locationLink = link.url.trim()
174
+ const lineFromUrl = locationLink.match(/#L(\d+)(?:-L?(\d+))?/u)
175
+ if (lineFromUrl) line = lineFromUrl[2] ? `${lineFromUrl[1]}-${lineFromUrl[2]}` : lineFromUrl[1]
176
+ } else {
177
+ file = loc.trim()
178
+ locationLink = loc.trim()
179
+ }
180
+ // Backticks are notation and a `\_` is the report escaping markdown;
181
+ // the path is the unescaped name, which is what the displays print
182
+ // and what a rebuilt blob URL must address. The url is left exactly
183
+ // as written — reports don't escape there, and it keys the id.
184
+ file = unescapeMd(file.replaceAll('`', '')).trim()
185
+ // A `:42` / `:10–20` suffix: taken only when the anchor gave no line,
186
+ // but shed from the path either way.
187
+ const colonMatch = file.match(/^(.+):(\d+)(?:\s*[-–—]\s*L?(\d+))?$/u)
188
+ if (colonMatch) {
189
+ file = colonMatch[1]
190
+ if (line === '?') line = colonMatch[3] ? `${colonMatch[2]}-${colonMatch[3]}` : colonMatch[2]
191
+ }
192
+ // `linked` says how the row came in, which `locationLink` can't —
193
+ // the fallback puts raw text there, and that is an id discriminator,
194
+ // not an href.
195
+ return { file, line, locationLink, linked: link !== null }
196
+ }
197
+
198
+ // The `## Reproduction steps` section as a reader can follow it. This
199
+ // report sometimes writes a whole sequence as ONE list item, in two
200
+ // shapes, and neither reads as a list:
201
+ //
202
+ // * a RUN-IN enumeration — `1. 1) Save 2) Restart 3) Watch`, which
203
+ // markdown reads as one step whose text holds all the others —
204
+ // becomes a line per step;
205
+ // * a list of ONE step stops being a list, its marker numbering the
206
+ // single thing the section says.
207
+ //
208
+ // The steps keep the numbers the report gave them, gaps and all: this
209
+ // text is printed as written, so a `6)` behind a `4)` is the report's
210
+ // own count rather than something to renumber.
211
+ //
212
+ // Only a section that IS one item is touched — no other line may open a
213
+ // list of its own — and the run-in reading is tried behind the outer
214
+ // marker (`1. 1) …`) and at the line's own start (`1) … 2) …`), since
215
+ // either can carry the enumeration. The id comes from the RAW block
216
+ // (parse-md-id.js), so reading the section better moves nothing.
217
+ function normalizeStepList(text) {
218
+ const lines = text.split('\n')
219
+ const at = lines.findIndex((line) => line.trim())
220
+ if (at === -1 || !LIST_MARKER_RE.test(lines[at])) return text
221
+ if (lines.some((line, i) => i !== at && LIST_MARKER_RE.test(line))) return text
222
+ const item = lines[at].replace(/^ */u, '')
223
+ const body = item.replace(LIST_MARKER_RE, '')
224
+ if (!body.trim()) return text
225
+ const steps = runInSteps(body) ?? runInSteps(item)
226
+ // An enumeration neither reading could take apart stays as it
227
+ // arrived. Both halves matter: a BODY opening on a marker is the
228
+ // sequence behind an outer one (`1. 3) Later 2) Earlier`), and
229
+ // unwrapping would leave that marker leading the section; an ITEM
230
+ // opening on one is the sequence itself (`3) Later 2) Earlier`),
231
+ // where the marker shed as the item's own is a step number, and
232
+ // unwrapping would drop it and leave the rest of the count behind.
233
+ if (steps === null && (LIST_MARKER_RE.test(body) || RUN_IN_HEAD_RE.test(item))) return text
234
+ const read = steps === null ? [body]
235
+ : steps.length === 1 ? [steps[0].step]
236
+ : steps.map(({ number, step }) => `${number}. ${step}`)
237
+ lines.splice(at, 1, ...read)
238
+ return lines.join('\n')
239
+ }
240
+
241
+ // `1) Save 2) Restart` → a step per marker, or null when the text is no
242
+ // run-in list: the first marker has to open it and the numbers have to
243
+ // ascend, or a step that merely cites `RFC 2616) …` would split the
244
+ // prose around it. A number in parens — `curl(1)`, `(2) results` — is
245
+ // not a marker, and a marker with NOTHING behind it — a truncated
246
+ // `1) Save 2)` — is a sequence this can't read, not a step of its own.
247
+ const RUN_IN_STEP_RE = /(?:^|[ \t])(\d{1,9})\)(?=[ \t]|$)/gu
248
+ // The same marker, asked of a text's own start.
249
+ const RUN_IN_HEAD_RE = /^\d{1,9}\)(?=[ \t]|$)/u
250
+
251
+ function runInSteps(text) {
252
+ const marks = [...text.matchAll(RUN_IN_STEP_RE)]
253
+ if (marks.length === 0 || marks[0].index !== 0) return null
254
+ const steps = []
255
+ for (const [i, mark] of marks.entries()) {
256
+ const number = Number(mark[1])
257
+ if (i > 0 && number <= steps[i - 1].number) return null
258
+ const step = text.slice(mark.index + mark[0].length, marks[i + 1]?.index).trim()
259
+ if (!step) return null
260
+ steps.push({ number, step })
261
+ }
262
+ return steps
263
+ }
264
+
265
+ // Rows of an `## Evidence` section, in document order:
266
+ //
267
+ // 1. [libs/a.ts:10–20](https://github.com/o/r/blob/<sha>/libs/a.ts#L10-L20)
268
+ // Why this line matters.
269
+ //
270
+ // Only a marker line is a reference — numbered or bulleted — and the
271
+ // prose under it is that row's note, left-trimmed, since the renderer
272
+ // indents the row itself.
273
+ //
274
+ // A section with no markers still yields one row when it is a single
275
+ // line, or around the first line carrying a link. Free prose yields
276
+ // none, and parseBlock leaves it in the description rather than
277
+ // promoting a sentence to a path.
278
+ const EVIDENCE_ITEM_RE = /^[ \t]*(?:\d+[.)]|[-*+])\s+/u
279
+
280
+ function evidenceRows(text) {
281
+ const rows = []
282
+ for (const line of text.split('\n')) {
283
+ if (EVIDENCE_ITEM_RE.test(line)) rows.push({ ref: line.replace(EVIDENCE_ITEM_RE, '').trim(), note: [] })
284
+ else if (rows.length > 0 && line.trim()) rows.at(-1).note.push(line.trim())
285
+ }
286
+ if (rows.length === 0) {
287
+ const bare = text.split('\n').map((l) => l.trim()).filter(Boolean)
288
+ const at = bare.findIndex((l) => findMdLink(l) !== null)
289
+ if (at === -1 && bare.length !== 1) return []
290
+ const refAt = at === -1 ? 0 : at
291
+ rows.push({ ref: bare[refAt], note: bare.filter((_, i) => i !== refAt) })
292
+ }
293
+ return rows.filter((r) => r.ref)
294
+ }
295
+
296
+ // One row as it lands on the finding. `url` only where the row carried
297
+ // a real link — the raw-text fallback is an id discriminator, not an
298
+ // href to hand a renderer.
299
+ function evidenceEntry({ ref, note }) {
300
+ const { file, line, locationLink, linked } = parseLocation(ref)
301
+ const entry = { file: file || 'unknown', line }
302
+ if (locationLink && linked) entry.url = locationLink
303
+ const text = note.join('\n')
304
+ if (text) entry.text = text
305
+ return entry
306
+ }
307
+
308
+ // Title + body sections, section labels emitted as `**Label:**` — the
309
+ // shape parse-piolium gives its fields, which render-finding.js turns
310
+ // into real `<strong>` emphasis and the markdown export re-emits as the
311
+ // markdown it is. Everything else survives verbatim, `pre-wrap` on
312
+ // `.desc` keeping the shape the report wrote.
313
+ function buildDescription(title, sections, hasEvidenceRows) {
314
+ const bodyParts = [title]
315
+ if (sections.details) bodyParts.push(sections.details)
316
+ // An Evidence section that parsed into rows lives on
317
+ // `finding.evidence`, and repeating it here would double it. One that
318
+ // parsed into none is free prose, and stays rather than being lost.
319
+ if (sections.evidence && !hasEvidenceRows) bodyParts.push(`**Evidence:**\n${sections.evidence}`)
320
+ if (sections.impact) bodyParts.push(`**Impact:** ${sections.impact}`)
321
+ return bodyParts.join('\n\n')
322
+ }
@@ -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
+ }