@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.
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// The readers for one case of the DeepView markdown document: the fact
|
|
2
|
+
// list under a finding's heading, the sections under that, the evidence
|
|
3
|
+
// list, the description they add up to. Which section is a finding and
|
|
4
|
+
// which `####` is a case belongs to parse-deepview-md.js; this module
|
|
5
|
+
// knows how write-md-finding.js spelt each value.
|
|
6
|
+
//
|
|
7
|
+
// Every reader is the inverse of a writer — `readLocation` of
|
|
8
|
+
// locationText, `readSeverity` of severityText, and so on — and
|
|
9
|
+
// `narrativeSplit` undoes the one thing the writer folds: a `**Label:**`
|
|
10
|
+
// paragraph and a field of the same name both became a section, and
|
|
11
|
+
// only their position says which was which.
|
|
12
|
+
|
|
13
|
+
import { REVALIDATE_KINDS, firstLine } from './finding.js'
|
|
14
|
+
import { SEVERITY_LABELS, SOURCE_LABELS } from './labels.js'
|
|
15
|
+
import { FILE_LINE_RE, fenceRanges, findMdLink, inFence, isCommitHash } from './md-structure.js'
|
|
16
|
+
import { isHttpUrl, unescapeHeadings } from './md-text.js'
|
|
17
|
+
|
|
18
|
+
// label (case-folded) → key, for the words the writer spells the app's
|
|
19
|
+
// enumerations with (labels.js).
|
|
20
|
+
const SEVERITY_KEYS = new Map(Object.entries(SEVERITY_LABELS).map(([k, v]) => [v.toLowerCase(), k]))
|
|
21
|
+
const SOURCE_KEYS = new Map(Object.entries(SOURCE_LABELS).map(([k, v]) => [v.toLowerCase(), k]))
|
|
22
|
+
const REVALIDATE_SET = new Set(REVALIDATE_KINDS)
|
|
23
|
+
|
|
24
|
+
// ── Inline forms ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// The content of the first code span in `s`, or null when it has none.
|
|
27
|
+
// The fence is as many backticks as the writer needed to quote the
|
|
28
|
+
// content (md-text.js code) — always more than any run inside it, so
|
|
29
|
+
// the first closing run of that length is the fence — and a space of
|
|
30
|
+
// padding on each side when the content itself starts or ends on one.
|
|
31
|
+
function codeSpan(s) {
|
|
32
|
+
const m = /(`+)(.+?)\1(?!`)/u.exec(String(s ?? ''))
|
|
33
|
+
if (!m) return null
|
|
34
|
+
const inner = m[2]
|
|
35
|
+
if (inner.length > 2 && inner.startsWith(' ') && inner.endsWith(' ')) {
|
|
36
|
+
const unpadded = inner.slice(1, -1)
|
|
37
|
+
if (unpadded.startsWith('`') || unpadded.endsWith('`')) return unpadded
|
|
38
|
+
}
|
|
39
|
+
return inner
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A markdown link at the START of `s` — `[label](url)`, the URL in
|
|
43
|
+
// angle brackets when the writer had to (md-text.js link). The reading
|
|
44
|
+
// itself is md-structure.js's, which is the whole library's; a link
|
|
45
|
+
// found further along belongs to something else in the value, so only
|
|
46
|
+
// one at index 0 answers here.
|
|
47
|
+
export function readLink(s) {
|
|
48
|
+
const link = findMdLink(String(s ?? ''))
|
|
49
|
+
return link?.index === 0 ? { label: link.label, url: link.url } : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A bare `<url>` autolink (md-text.js autolink), or null.
|
|
53
|
+
function autolinkUrl(s) {
|
|
54
|
+
const m = /^<(https?:[^>\s]*)>$/u.exec(s.trim())
|
|
55
|
+
return m ? m[1] : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `file:line` back into its two fields — the line a number or a
|
|
59
|
+
// `10-20` range, `?` when the label carried none (finding.js
|
|
60
|
+
// locationLabel).
|
|
61
|
+
function fileLine(label) {
|
|
62
|
+
const m = FILE_LINE_RE.exec(label)
|
|
63
|
+
return m ? { file: m[1], line: m[2] } : { file: label, line: '?' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `exportName.methodName` back into the two names, or the one name
|
|
67
|
+
// (finding.js findingDisplayName). A name with no dot is an export.
|
|
68
|
+
function exportNames(name) {
|
|
69
|
+
const dot = name.indexOf('.')
|
|
70
|
+
return dot > 0 ? { exportName: name.slice(0, dot), methodName: name.slice(dot + 1) } : { exportName: name }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A tier's key from the word the writer spelt it with; a tier the
|
|
74
|
+
// ladder doesn't know was printed as itself and comes back as itself.
|
|
75
|
+
export function tierOf(label) {
|
|
76
|
+
const t = String(label ?? '').trim()
|
|
77
|
+
return SEVERITY_KEYS.get(t.toLowerCase()) ?? t
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── The facts ────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
// `[`src/a.js:7`](url) · `Foo.bar`` — the reference, linked or not,
|
|
83
|
+
// then the export it sits in (write-md-finding.js locationText). The
|
|
84
|
+
// link is the report's own location link (finding.js: `location`),
|
|
85
|
+
// which the card links to in preference to anything reconstructed.
|
|
86
|
+
function readLocation(value) {
|
|
87
|
+
const out = {}
|
|
88
|
+
let s = value.trim()
|
|
89
|
+
const named = / · (`+)(.+?)\1$/u.exec(s)
|
|
90
|
+
if (named) {
|
|
91
|
+
Object.assign(out, exportNames(codeSpan(named[0])))
|
|
92
|
+
s = s.slice(0, named.index)
|
|
93
|
+
}
|
|
94
|
+
const link = readLink(s)
|
|
95
|
+
const label = codeSpan(link ? link.label : s)
|
|
96
|
+
if (label !== null) Object.assign(out, fileLine(label))
|
|
97
|
+
if (link && isHttpUrl(link.url)) out.location = link.url
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const CRITICAL_FLAG = ' · flagged critical by the analyzer'
|
|
102
|
+
const VARIES = ' (varies across reports — '
|
|
103
|
+
|
|
104
|
+
// `High — corrected from Medium (varies across reports — …) · flagged
|
|
105
|
+
// critical by the analyzer` back into severity / correctedSeverity /
|
|
106
|
+
// critical (write-md-finding.js severityText). Under the original lens
|
|
107
|
+
// the line reads `Medium — corrected to High`; either way it says which
|
|
108
|
+
// is which. The per-report variants are the viewer's own bookkeeping
|
|
109
|
+
// of a workspace merge, not a finding's field.
|
|
110
|
+
function readSeverity(value) {
|
|
111
|
+
const out = {}
|
|
112
|
+
let s = value.trim()
|
|
113
|
+
if (s.endsWith(CRITICAL_FLAG)) {
|
|
114
|
+
out.critical = true
|
|
115
|
+
s = s.slice(0, -CRITICAL_FLAG.length)
|
|
116
|
+
}
|
|
117
|
+
const varies = s.indexOf(VARIES)
|
|
118
|
+
if (varies !== -1) s = s.slice(0, varies)
|
|
119
|
+
const m = /^(.*?) — corrected (from|to) (.*)$/u.exec(s)
|
|
120
|
+
if (!m) {
|
|
121
|
+
out.severity = tierOf(s)
|
|
122
|
+
} else if (m[2] === 'from') {
|
|
123
|
+
out.severity = tierOf(m[3])
|
|
124
|
+
out.correctedSeverity = tierOf(m[1])
|
|
125
|
+
} else {
|
|
126
|
+
out.severity = tierOf(m[1])
|
|
127
|
+
out.correctedSeverity = tierOf(m[3])
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The effort ladder and import modes a run is described with — closed
|
|
133
|
+
// vocabularies, which is what lets a run's line be read back by
|
|
134
|
+
// position: `<type> · [revalidate] · <model> · <effort> · <mode>`, an
|
|
135
|
+
// absent part elided (finding.js runMetaLine).
|
|
136
|
+
const EFFORTS = new Set(['max', 'xhigh', 'high', 'medium', 'low', 'minimal'])
|
|
137
|
+
const IMPORT_MODES = new Set(['list', 'isolate'])
|
|
138
|
+
|
|
139
|
+
// A model's pretty name carries a version — `opus 5`, `gpt 5.5` — or at
|
|
140
|
+
// least a family, where a mode (`security`) carries neither. Consulted
|
|
141
|
+
// only when the line leaves one free word, whose slot is ambiguous.
|
|
142
|
+
function looksLikeModel(word) {
|
|
143
|
+
return /\d/u.test(word) || /^(?:opus|sonnet|haiku|gpt|gemini|fable|mythos|llama|mistral)\b/iu.test(word)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// What produced a finding (write-md-finding.js analyzerText): a
|
|
147
|
+
// product's name, back into its `source` key — or the run's meta line,
|
|
148
|
+
// back into the run's fields.
|
|
149
|
+
export function readAnalyzer(value) {
|
|
150
|
+
const s = value.trim()
|
|
151
|
+
const source = SOURCE_KEYS.get(s.toLowerCase())
|
|
152
|
+
if (source) return { source }
|
|
153
|
+
const run = {}
|
|
154
|
+
const free = []
|
|
155
|
+
for (const word of s.split(' · ').map((w) => w.trim()).filter(Boolean)) {
|
|
156
|
+
if (word === 'revalidate' && !run.revalidate) run.revalidate = 'revalidation'
|
|
157
|
+
else if (IMPORT_MODES.has(word) && !run.exportsMode) run.exportsMode = word
|
|
158
|
+
else if (EFFORTS.has(word) && !run.effort) run.effort = word
|
|
159
|
+
else free.push(word)
|
|
160
|
+
}
|
|
161
|
+
if (free.length > 1) [run.type, run.model] = free
|
|
162
|
+
else if (free.length === 1) run[looksLikeModel(free[0]) ? 'model' : 'type'] = free[0]
|
|
163
|
+
return { run }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A repository reference (write-md-finding.js repoRef): the slug of a
|
|
167
|
+
// github.com link, a bare URL, or the text as it was.
|
|
168
|
+
export function readRepository(value) {
|
|
169
|
+
const s = value.trim()
|
|
170
|
+
const link = readLink(s)
|
|
171
|
+
if (link) return link.label.trim()
|
|
172
|
+
return autolinkUrl(s) ?? s
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The introducing commit (commitText): linked, the short hash in the
|
|
176
|
+
// label and the whole one at the end of the URL; unlinked, the hash in
|
|
177
|
+
// a code span.
|
|
178
|
+
function readCommit(value) {
|
|
179
|
+
const link = readLink(value.trim())
|
|
180
|
+
if (link) {
|
|
181
|
+
const tail = link.url.split('/').at(-1) ?? ''
|
|
182
|
+
if (isCommitHash(tail)) return tail
|
|
183
|
+
return codeSpan(link.label) ?? tail
|
|
184
|
+
}
|
|
185
|
+
return codeSpan(value) ?? value.trim()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `name@version` in a code span, back into the npm package a
|
|
189
|
+
// dependency finding sits in.
|
|
190
|
+
function readPackage(value) {
|
|
191
|
+
const s = codeSpan(value) ?? value.trim()
|
|
192
|
+
const at = s.lastIndexOf('@')
|
|
193
|
+
return { npm: at > 0 ? { name: s.slice(0, at), version: s.slice(at + 1) } : { name: s } }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The revalidation stamp (metaList): the pass's own row is named in
|
|
197
|
+
// words, a verdict by its kind.
|
|
198
|
+
function readRevalidation(value) {
|
|
199
|
+
const s = value.trim().toLowerCase()
|
|
200
|
+
if (s === 'the revalidation pass itself') return 'revalidation'
|
|
201
|
+
return REVALIDATE_SET.has(s) ? s : undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// One fact back onto the finding, keyed by the label the writer gave it.
|
|
205
|
+
// Not here: `Analyzer`, which the document settles for every finding at
|
|
206
|
+
// once; `Triage` / `Fix` and the `Comment` section, the reader's
|
|
207
|
+
// annotations, which live in the viewer's triage store and follow the
|
|
208
|
+
// id; and `Report`, which names the file a case came from — now this one.
|
|
209
|
+
const FACT_READERS = new Map([
|
|
210
|
+
['location', (f, v) => Object.assign(f, readLocation(v))],
|
|
211
|
+
['severity', (f, v) => Object.assign(f, readSeverity(v))],
|
|
212
|
+
['confidence', (f, v) => { const m = /^(\d+(?:\.\d+)?)\/10$/u.exec(v); if (m) f.confidence = Number(m[1]) }],
|
|
213
|
+
['revalidation', (f, v) => { const kind = readRevalidation(v); if (kind) f.revalidate = kind }],
|
|
214
|
+
['revalidated by', (f, v) => { const s = v.trim(); if (s) f.revalidateSource = SOURCE_KEYS.get(s.toLowerCase()) ?? s }],
|
|
215
|
+
['repository', (f, v) => { f.repo = { github: readRepository(v) } }],
|
|
216
|
+
['introduced in', (f, v) => { f.commitHash = readCommit(v) }],
|
|
217
|
+
['package', (f, v) => { f.package = readPackage(v) }],
|
|
218
|
+
['priority', (f, v) => { f.priority = /^-?\d+(?:\.\d+)?$/u.test(v) ? Number(v) : v }],
|
|
219
|
+
['found while analyzing', (f, v) => { f.discoveredIn = codeSpan(v) ?? v }],
|
|
220
|
+
['detailed report', (f, v) => { f.reportPath = codeSpan(v) ?? v }],
|
|
221
|
+
['commit audited', (f, v) => { f.auditedCommit = codeSpan(v) ?? v }],
|
|
222
|
+
['id', (f, v) => { f.id = codeSpan(v) ?? v }],
|
|
223
|
+
...[
|
|
224
|
+
['category', 'category'], ['status', 'status'], ['branch', 'branch'],
|
|
225
|
+
['date created', 'dateCreated'], ['detected at', 'detectedAt'], ['committed at', 'committedAt'],
|
|
226
|
+
['poc status', 'pocStatus'], ['variant of', 'parent'], ['slug', 'slug'],
|
|
227
|
+
].map(([label, field]) => [label, (f, v) => { f[field] = v }]),
|
|
228
|
+
])
|
|
229
|
+
|
|
230
|
+
export function applyFact(f, label, value) {
|
|
231
|
+
const read = FACT_READERS.get(label.trim().toLowerCase())
|
|
232
|
+
if (read) read(f, value.trim())
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── The shape under a heading ────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
const FACT_RE = /^- \*\*([^*\n]+?):\*\* ?(.*)$/u
|
|
238
|
+
|
|
239
|
+
// The fact list at the top of a case: consecutive `- **Label:** value`
|
|
240
|
+
// lines. A paragraph BEFORE the list is the case's own title, written
|
|
241
|
+
// where a case is named differently from its group — but only when a
|
|
242
|
+
// list follows, since a case with no facts keeps its opening paragraph
|
|
243
|
+
// as prose. Prose comes back with the writer's heading escape off, here
|
|
244
|
+
// and wherever readProse is used, so `\## Internal detail` is the
|
|
245
|
+
// `## Internal detail` the description held.
|
|
246
|
+
export function splitFacts(body) {
|
|
247
|
+
const lines = body.split('\n')
|
|
248
|
+
let i = 0
|
|
249
|
+
const skipBlank = () => { while (i < lines.length && !lines[i].trim()) i++ }
|
|
250
|
+
const readFacts = () => {
|
|
251
|
+
const facts = []
|
|
252
|
+
while (i < lines.length) {
|
|
253
|
+
const m = FACT_RE.exec(lines[i])
|
|
254
|
+
if (!m) break
|
|
255
|
+
facts.push([m[1].trim(), m[2].trim()])
|
|
256
|
+
i++
|
|
257
|
+
}
|
|
258
|
+
return facts
|
|
259
|
+
}
|
|
260
|
+
skipBlank()
|
|
261
|
+
let facts = readFacts()
|
|
262
|
+
if (facts.length > 0) return { title: '', facts, rest: lines.slice(i).join('\n') }
|
|
263
|
+
const para = []
|
|
264
|
+
while (i < lines.length && lines[i].trim()) para.push(lines[i++])
|
|
265
|
+
skipBlank()
|
|
266
|
+
facts = readFacts()
|
|
267
|
+
if (facts.length === 0) return { title: '', facts, rest: body }
|
|
268
|
+
return { title: readProse(para.join('\n').trim()), facts, rest: lines.slice(i).join('\n') }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A run of prose as the description held it: the writer's heading
|
|
272
|
+
// escape off, when the document's writer put one on.
|
|
273
|
+
export function readProse(text) {
|
|
274
|
+
return unescapeHeadings(text)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A case's sections at `depth` (4 under a finding's heading, 5 under a
|
|
278
|
+
// case's): the lead before the first heading and `[{ label, body }]`
|
|
279
|
+
// after it — outside fences only, so a `#### ` line in a snippet stays
|
|
280
|
+
// in the snippet.
|
|
281
|
+
export function splitSections(text, depth) {
|
|
282
|
+
const re = new RegExp(`^#{${depth}} +(.*)$`, 'gmu')
|
|
283
|
+
const ranges = fenceRanges(text)
|
|
284
|
+
const marks = [...text.matchAll(re)].filter((m) => !inFence(ranges, m.index))
|
|
285
|
+
const lead = text.slice(0, marks[0]?.index ?? text.length).trim()
|
|
286
|
+
const sections = marks.map((m, i) => ({
|
|
287
|
+
label: m[1].trim(),
|
|
288
|
+
body: text.slice(m.index + m[0].length, marks[i + 1]?.index).trim(),
|
|
289
|
+
}))
|
|
290
|
+
return { lead, sections }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const ITEM_RE = /^(\d+)\. (.*)$/u
|
|
294
|
+
|
|
295
|
+
// The evidence list (write-md-finding.js evidenceList): a loose
|
|
296
|
+
// numbered list, each item's note on the lines under it, indented to
|
|
297
|
+
// the item's text. Back into rows of `{ file, line, url, text }` — the
|
|
298
|
+
// note under the name parse-md.js gives it, whatever a native dump
|
|
299
|
+
// called it (finding.js evidenceNote reads both).
|
|
300
|
+
export function readEvidence(text) {
|
|
301
|
+
const items = []
|
|
302
|
+
const ranges = fenceRanges(text)
|
|
303
|
+
let pos = 0
|
|
304
|
+
for (const line of text.split('\n')) {
|
|
305
|
+
const m = inFence(ranges, pos) ? null : ITEM_RE.exec(line)
|
|
306
|
+
pos += line.length + 1
|
|
307
|
+
if (m) items.push({ ref: m[2].trim(), indent: m[1].length + 2, note: [] })
|
|
308
|
+
else if (items.length > 0) items.at(-1).note.push(line)
|
|
309
|
+
}
|
|
310
|
+
return items.map((item) => evidenceRow(item))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function evidenceRow({ ref, indent, note }) {
|
|
314
|
+
const row = {}
|
|
315
|
+
const link = readLink(ref)
|
|
316
|
+
const auto = autolinkUrl(ref)
|
|
317
|
+
const label = auto === null ? codeSpan(link ? link.label : ref) : null
|
|
318
|
+
if (label !== null) Object.assign(row, fileLine(label))
|
|
319
|
+
const url = link ? link.url : auto
|
|
320
|
+
if (isHttpUrl(url)) row.url = url
|
|
321
|
+
const text = readProse(note.map((l) => l.slice(Math.min(indent, /^ */u.exec(l)[0].length))).join('\n').trim())
|
|
322
|
+
if (text) row.text = text
|
|
323
|
+
return row
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── The narrative ────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
// The narrative fields the writer gives their own sections, in the
|
|
329
|
+
// order it writes them (write-md-finding.js NARRATIVE) — AFTER the
|
|
330
|
+
// sections the description's own `**Label:**` paragraphs became.
|
|
331
|
+
const NARRATIVE = new Map([
|
|
332
|
+
['impact', 'impact'], ['reproduction', 'reproduction'], ['recommendation', 'recommendation'],
|
|
333
|
+
['confidence reasoning', 'confidenceReason'], ['revalidation verdict', 'revalidateVerdict'],
|
|
334
|
+
['revalidation recommendation', 'revalidateRecommendation'],
|
|
335
|
+
])
|
|
336
|
+
const NARRATIVE_ORDER = [...NARRATIVE.keys()]
|
|
337
|
+
|
|
338
|
+
// Which sections were fields and which the description's own. The writer
|
|
339
|
+
// prints the description's labelled paragraphs first, whatever they are
|
|
340
|
+
// called, then the fields in NARRATIVE order — so the fields are the
|
|
341
|
+
// longest run of narrative labels in that order at the END, and
|
|
342
|
+
// everything before goes back into the description as the `**Label:**`
|
|
343
|
+
// paragraph it was. A report that wrote `**Impact:**` into its prose
|
|
344
|
+
// comes back with an `impact` field, as a native dump would have; a
|
|
345
|
+
// `**Root Cause:**` paragraph and any `**Impact:**` before it stay
|
|
346
|
+
// paragraphs, so a second export reads as the first did.
|
|
347
|
+
export function narrativeSplit(sections) {
|
|
348
|
+
let start = sections.length
|
|
349
|
+
let last = Infinity
|
|
350
|
+
for (let i = sections.length - 1; i >= 0; i--) {
|
|
351
|
+
const rank = NARRATIVE_ORDER.indexOf(sections[i].label.toLowerCase())
|
|
352
|
+
if (rank === -1 || rank >= last) break
|
|
353
|
+
last = rank
|
|
354
|
+
start = i
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
paragraphs: sections.slice(0, start),
|
|
358
|
+
fields: sections.slice(start).map((s) => [NARRATIVE.get(s.label.toLowerCase()), s.body]),
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The description back from its parts: the heading's text as the first
|
|
363
|
+
// line — unless the lead already opens with it, which is how a name too
|
|
364
|
+
// long for a heading travels — then the lead, then the description's own
|
|
365
|
+
// labelled paragraphs, as every parser writes them. No heading text
|
|
366
|
+
// leaves the lead to speak for itself.
|
|
367
|
+
export function buildDescription(title, lead, paragraphs) {
|
|
368
|
+
const parts = []
|
|
369
|
+
const first = firstLine(lead)
|
|
370
|
+
const cut = title.endsWith('…') ? title.slice(0, -1).trimEnd() : ''
|
|
371
|
+
if (!title || first === title || (cut && first.startsWith(cut))) parts.push(lead)
|
|
372
|
+
else parts.push(title, lead)
|
|
373
|
+
for (const { label, body } of paragraphs) parts.push(body ? `**${label}:** ${body}` : `**${label}:**`)
|
|
374
|
+
return parts.filter(Boolean).join('\n\n')
|
|
375
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The reader for the document this library's own writer produces
|
|
2
|
+
// (write-md.js, what the viewer's Download button saves). An export
|
|
3
|
+
// reads back in through the same door as every other format, each
|
|
4
|
+
// finding with the id it had — so its triage still applies — and with
|
|
5
|
+
// its facts in the fields the parser that first read it used: a Claude
|
|
6
|
+
// Security finding comes back as one, a native dump's with its run.
|
|
7
|
+
//
|
|
8
|
+
// The document (write-md.js for the whole shape):
|
|
9
|
+
//
|
|
10
|
+
// <!-- DeepView findings export --> ← the guard
|
|
11
|
+
// # <title>
|
|
12
|
+
// - **Source:** Claude Security the header list
|
|
13
|
+
// - **Repository:** [o/r](…) / **Analyzer:** …
|
|
14
|
+
// ## Summary tables — skipped
|
|
15
|
+
// ## High (2) a tier's section
|
|
16
|
+
// ### 1. <finding> an entry
|
|
17
|
+
// - **Location:** … / **Severity:** … / **ID:** … the facts
|
|
18
|
+
// <description> the lead
|
|
19
|
+
// #### Evidence / #### Impact / … the sections
|
|
20
|
+
//
|
|
21
|
+
// and a finding reported several times is an entry of cases:
|
|
22
|
+
//
|
|
23
|
+
// ### 2. <finding>
|
|
24
|
+
// 2 cases of this finding — reported in `a.json`, `b.json`.
|
|
25
|
+
// #### Case 1 of 2 — `src/a.js:7`
|
|
26
|
+
// - **Location:** … / ##### Impact …
|
|
27
|
+
//
|
|
28
|
+
// Out comes the JSON shape the rest of the chain emits:
|
|
29
|
+
//
|
|
30
|
+
// { type, source?, model?, effort?, exportsMode?, repo?, findings }
|
|
31
|
+
//
|
|
32
|
+
// with `groups` in place of `findings` when an entry has several cases —
|
|
33
|
+
// a pre-deduplicated dump's shape. The producer and the run travel as a
|
|
34
|
+
// native dump carries them: report-level where every finding shares
|
|
35
|
+
// them, per finding where they vary. A document mixing products with the
|
|
36
|
+
// analyzer's own runs stamps `source` on each product's findings, which
|
|
37
|
+
// the viewer reads as that finding's analyzer.
|
|
38
|
+
//
|
|
39
|
+
// Not read back: what the reader wrote on a finding (Triage, Fix,
|
|
40
|
+
// Comment), which lives in the viewer's triage store and follows the id;
|
|
41
|
+
// and the header's account of the export (view, filters, counts), since
|
|
42
|
+
// the findings on the page ARE the selection. Prose comes back with the
|
|
43
|
+
// writer's heading escape off (md-text.js unescapeHeadings).
|
|
44
|
+
//
|
|
45
|
+
// The marker line is the whole guard: without it the text is not this
|
|
46
|
+
// document and returns null, so the chain moves on. The guard reads the
|
|
47
|
+
// phrase and not what follows, so a later document that says more there
|
|
48
|
+
// is still recognised and read as well as this reader can. A document
|
|
49
|
+
// holding NO finding is still the report its header describes — an
|
|
50
|
+
// export is a SELECTION and a selection can be empty, which the header
|
|
51
|
+
// says outright ("Included: no findings") — so an empty export of a
|
|
52
|
+
// Claude Security report reads back as
|
|
53
|
+
// `{ type: 'security', source: 'claude-security', findings: [] }`
|
|
54
|
+
// rather than as a file no format recognises.
|
|
55
|
+
|
|
56
|
+
import { locationLabel } from './finding.js'
|
|
57
|
+
import { H2_RE, H3_RE, H4_RE, normalizeNewlines, splitByHeading, splitLeading } from './md-structure.js'
|
|
58
|
+
import { applyFact, buildDescription, narrativeSplit, readAnalyzer, readEvidence, readProse, readRepository, splitFacts, splitSections, tierOf } from './parse-deepview-fields.js'
|
|
59
|
+
|
|
60
|
+
const MARKER_RE = /^<!--\s*DeepView findings export\b[^>]*-->/u
|
|
61
|
+
const CASE_RE = /^Case \d+ of \d+(?:\s|$)/u
|
|
62
|
+
const HEADER_FACT_RE = /^- \*\*([^*\n]+?):\*\* ?(.*)$/gmu
|
|
63
|
+
|
|
64
|
+
// The sections under a case that are not the description's own nor a
|
|
65
|
+
// narrative field: the evidence rows, the correction's reason, and the
|
|
66
|
+
// reader's comment.
|
|
67
|
+
const OWN_SECTIONS = new Set(['evidence', 'severity correction', 'comment'])
|
|
68
|
+
|
|
69
|
+
export function parseDeepviewMarkdown(content) {
|
|
70
|
+
const text = normalizeNewlines(content).trim()
|
|
71
|
+
if (!MARKER_RE.test(text)) return null
|
|
72
|
+
const { head, subs } = splitLeading(text, H2_RE)
|
|
73
|
+
const entries = []
|
|
74
|
+
for (const { heading, body } of subs) {
|
|
75
|
+
if (heading.trim().toLowerCase() === 'summary') continue
|
|
76
|
+
const tier = sectionTier(heading)
|
|
77
|
+
for (const block of splitByHeading(body, H3_RE)) entries.push(readEntry(block, tier))
|
|
78
|
+
}
|
|
79
|
+
return assemble(readHeader(head), entries)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `High (2)` → 'high'. A section named after no tier passes its name
|
|
83
|
+
// through as printed; the fact line under each finding is authoritative.
|
|
84
|
+
function sectionTier(heading) {
|
|
85
|
+
const m = /^(.*?)\s*\(\d+\)\s*$/u.exec(heading.trim())
|
|
86
|
+
return tierOf(m ? m[1] : heading)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The header list: the products the reports came from, the analyzers
|
|
90
|
+
// named, the repository. The rest describes the export, not the findings.
|
|
91
|
+
function readHeader(head) {
|
|
92
|
+
const facts = new Map()
|
|
93
|
+
for (const m of head.matchAll(HEADER_FACT_RE)) {
|
|
94
|
+
const key = m[1].trim().toLowerCase()
|
|
95
|
+
if (!facts.has(key)) facts.set(key, m[2].trim())
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
sources: (facts.get('source') ?? '').split(',').map((s) => readAnalyzer(s).source).filter(Boolean),
|
|
99
|
+
analyzers: (facts.get('analyzer') ?? facts.get('analyzers') ?? '').split(';').map((s) => s.trim()).filter(Boolean),
|
|
100
|
+
repo: facts.has('repository') ? readRepository(facts.get('repository')) : '',
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One `### N. <finding>` block: a finding, or one with a case per
|
|
105
|
+
// `#### Case i of n` under it.
|
|
106
|
+
function readEntry({ heading, body }, tier) {
|
|
107
|
+
const title = heading.trim().replace(/^\d+\.\s+/u, '')
|
|
108
|
+
const cases = splitLeading(body, H4_RE).subs.filter((s) => CASE_RE.test(s.heading.trim()))
|
|
109
|
+
if (cases.length === 0) return [readCase(body, 4, title, tier)]
|
|
110
|
+
return cases.map((s) => readCase(s.body, 5, title, tier))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// One case's text into a finding: the facts, the description its lead
|
|
114
|
+
// and sections add up to, the evidence, the narrative fields. The
|
|
115
|
+
// Analyzer fact rides beside it for `assemble` to settle report-level.
|
|
116
|
+
function readCase(body, depth, entryTitle, tier) {
|
|
117
|
+
const { title, facts, rest } = splitFacts(body)
|
|
118
|
+
const { lead, sections } = splitSections(rest, depth)
|
|
119
|
+
const f = { file: 'unknown', line: '?' }
|
|
120
|
+
let analyzer = null
|
|
121
|
+
for (const [label, value] of facts) {
|
|
122
|
+
if (label.trim().toLowerCase() === 'analyzer') analyzer = readAnalyzer(value)
|
|
123
|
+
else applyFact(f, label, value)
|
|
124
|
+
}
|
|
125
|
+
if (!f.severity) f.severity = tier || 'medium'
|
|
126
|
+
// A finding the writer could only head by its location, or by nothing
|
|
127
|
+
// at all, had no name; its description is what the lead says.
|
|
128
|
+
const name = title || entryTitle
|
|
129
|
+
const named = name !== 'Untitled finding' && name !== locationLabel(f)
|
|
130
|
+
const own = sections.filter((s) => !OWN_SECTIONS.has(s.label.toLowerCase()))
|
|
131
|
+
.map((s) => ({ label: s.label, body: readProse(s.body) }))
|
|
132
|
+
const { paragraphs, fields } = narrativeSplit(own)
|
|
133
|
+
f.description = buildDescription(named ? name : '', readProse(lead), paragraphs)
|
|
134
|
+
const evidence = sections.filter((s) => s.label.toLowerCase() === 'evidence').flatMap((s) => readEvidence(s.body))
|
|
135
|
+
if (evidence.length > 0) f.evidence = evidence
|
|
136
|
+
for (const [field, value] of fields) f[field] = value
|
|
137
|
+
const reason = sections.find((s) => s.label.toLowerCase() === 'severity correction')
|
|
138
|
+
if (reason?.body) f.correctedSeverityReason = readProse(reason.body)
|
|
139
|
+
return { finding: f, analyzer }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The producer and the run for the whole report: from the Analyzer facts
|
|
143
|
+
// where the writer named one per finding — a product every case shares
|
|
144
|
+
// goes report-level, a run to its finding, a product only some cases
|
|
145
|
+
// came from to those cases — else from the header's one analyzer or its
|
|
146
|
+
// Source line. The pass marker is per-finding, never report-level.
|
|
147
|
+
function settleAnalyzers(header, cases) {
|
|
148
|
+
const out = { source: null, run: {} }
|
|
149
|
+
if (cases.some((c) => c.analyzer !== null)) {
|
|
150
|
+
const sources = new Set(cases.map((c) => c.analyzer?.source ?? null))
|
|
151
|
+
if (sources.size === 1 && !sources.has(null)) [out.source] = sources
|
|
152
|
+
for (const c of cases) {
|
|
153
|
+
if (c.analyzer?.run) Object.assign(c.finding, c.analyzer.run)
|
|
154
|
+
else if (c.analyzer?.source && !out.source) c.finding.source = c.analyzer.source
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
const one = header.analyzers.length === 1 ? readAnalyzer(header.analyzers[0]) : null
|
|
159
|
+
if (one?.source) out.source = one.source
|
|
160
|
+
else if (one?.run) out.run = one.run
|
|
161
|
+
else if (header.analyzers.length === 0 && header.sources.length === 1) [out.source] = header.sources
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The report: what the header and findings agree on at the top, then the
|
|
166
|
+
// findings, grouped only where an entry had cases.
|
|
167
|
+
function assemble(header, entries) {
|
|
168
|
+
const cases = entries.flat()
|
|
169
|
+
const { source, run } = settleAnalyzers(header, cases)
|
|
170
|
+
const data = {}
|
|
171
|
+
// The report's `type` is the run's — or, where each finding names its
|
|
172
|
+
// own, the one mode they all ran in, as a deduplicated dump keeps the
|
|
173
|
+
// mode in its header while its findings carry their models. A
|
|
174
|
+
// product's report is a security report, as its own parser says.
|
|
175
|
+
const modes = new Set(cases.map((c) => c.finding.type).filter(Boolean))
|
|
176
|
+
const type = run.type ?? (modes.size === 1 ? [...modes][0] : null) ?? (source ? 'security' : null)
|
|
177
|
+
if (type) data.type = type
|
|
178
|
+
if (source) data.source = source
|
|
179
|
+
for (const key of ['model', 'effort', 'exportsMode']) if (run[key]) data[key] = run[key]
|
|
180
|
+
if (header.repo) data.repo = { github: header.repo }
|
|
181
|
+
const groups = entries.map((entry) => entry.map((c) => c.finding))
|
|
182
|
+
if (groups.every((g) => g.length === 1)) data.findings = groups.flat()
|
|
183
|
+
else data.groups = groups
|
|
184
|
+
return data
|
|
185
|
+
}
|