@gpzhang2001/sharpkit-reporting 0.2.1

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/src/state.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Report state — port of strix report/state.py: the per-scan report store
3
+ * with sequential `vuln-NNNN` ids, strix's exact field insertion order,
4
+ * title cleaning, update whitelist with dependent-field dropping, update
5
+ * history, and hydration from a previous run dir. Key order matters: the
6
+ * stored dicts are JSON-serialized byte-for-byte into vulnerabilities.json,
7
+ * so fields are inserted in state.py `add_vulnerability_report` order.
8
+ * @module @gpzhang2001/sharpkit-reporting/state
9
+ */
10
+
11
+ /** A stored vulnerability/dependency report dict (strix shape). */
12
+ export interface VulnerabilityReport {
13
+ readonly id: string
14
+ title: string
15
+ severity: string
16
+ readonly timestamp: string
17
+ [key: string]: unknown
18
+ }
19
+
20
+ /** Injectable clock (tests pass fixed times; default wall clock). */
21
+ export type Clock = () => Date
22
+
23
+ /** strix display timestamp format (state.py :349). */
24
+ export function formatTimestamp(date: Date): string {
25
+ const pad = (value: number): string => String(value).padStart(2, '0')
26
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`
27
+ }
28
+
29
+ /** strix ISO instant format (start/end times). */
30
+ export function formatIso(date: Date): string {
31
+ return date.toISOString().replace('Z', '+00:00')
32
+ }
33
+
34
+ /** Control-char → space + whitespace collapse (state.py `_clean_title`). */
35
+ export function cleanTitle(title: string): string {
36
+ // eslint-disable-next-line no-control-regex -- strix strips exactly these control chars (state.py :36)
37
+ return title.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
38
+ }
39
+
40
+ /** strix severity order (tool.py `_SEVERITY_ORDER`). */
41
+ export const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'] as const
42
+
43
+ /** Severity rank with unknown → last (writer parity). */
44
+ export function severityRank(severity: string): number {
45
+ const index = (SEVERITY_ORDER as readonly string[]).indexOf(severity)
46
+ return index === -1 ? SEVERITY_ORDER.length : index
47
+ }
48
+
49
+ /** Field insertion order for optional string fields (state.py :352-396). */
50
+ const OPTIONAL_STRING_FIELDS = [
51
+ 'description', 'impact', 'target', 'technical_analysis', 'poc_description', 'poc_script_code',
52
+ 'remediation_steps', 'evidence', 'assumptions', 'counterevidence', 'confidence_rationale',
53
+ 'severity_change_conditions', 'fix_verification', 'fix_pr_body', 'endpoint', 'method', 'cve',
54
+ ] as const
55
+
56
+ /** Lowercased optional fields (state.py `_LOWERCASE_REPORT_FIELDS` subset). */
57
+ const LOWERCASE_FIELDS = new Set(['confidence', 'fix_effort'])
58
+
59
+ /** Updatable field whitelist (state.py `UPDATABLE_REPORT_FIELDS`). */
60
+ export const UPDATABLE_REPORT_FIELDS = new Set([
61
+ 'title', 'dependency_metadata', 'severity', 'description', 'impact', 'target', 'technical_analysis',
62
+ 'poc_description', 'poc_script_code', 'remediation_steps', 'evidence', 'assumptions', 'counterevidence',
63
+ 'confidence', 'confidence_rationale', 'severity_change_conditions', 'fix_effort', 'cvss', 'cvss_breakdown',
64
+ 'endpoint', 'method', 'cve', 'cwe', 'code_locations', 'fix_verification', 'fix_pr_body',
65
+ ])
66
+
67
+ /** Dependent fields dropped when their primary changes without replacement. */
68
+ export const DEPENDENT_REPORT_FIELDS: Readonly<Record<string, string>> = {
69
+ confidence: 'confidence_rationale',
70
+ severity: 'severity_change_conditions',
71
+ cvss: 'cvss_breakdown',
72
+ code_locations: 'fix_verification',
73
+ }
74
+
75
+ /** One update-history entry (state.py :467-487). */
76
+ export interface UpdateHistoryEntry {
77
+ readonly timestamp: string
78
+ fields: string[]
79
+ dropped_fields?: string[]
80
+ reason?: string
81
+ agent_id?: string
82
+ agent_name?: string
83
+ previous_severity?: string
84
+ previous_cvss?: number
85
+ previous_confidence?: string
86
+ }
87
+
88
+ export interface AddReportInput {
89
+ readonly title: string
90
+ readonly severity: string
91
+ readonly findingClass?: string | undefined
92
+ readonly dependencyMetadata?: Record<string, unknown> | undefined
93
+ readonly agentId?: string | undefined
94
+ readonly agentName?: string | undefined
95
+ /** All remaining report fields (already validated/normalized by the tool layer). */
96
+ readonly fields: Record<string, unknown>
97
+ }
98
+
99
+ /** The revision outcome: null = no-op (strix parity). */
100
+ export type UpdateOutcome =
101
+ | { readonly report: VulnerabilityReport }
102
+ | { readonly noop: true }
103
+
104
+ /**
105
+ * One scan's report store. Not a process singleton — the suite runs one per
106
+ * scan id inside the tool package's closure (strix's module-global maps to a
107
+ * per-scan instance in dsh).
108
+ */
109
+ export class ReportState {
110
+ readonly runId: string
111
+ runName: string | null
112
+ readonly startTime: string
113
+ endTime: string | null = null
114
+ status = 'running'
115
+ finalScanResult: string | null = null
116
+ readonly vulnerabilityReports: VulnerabilityReport[] = []
117
+ /** Ids already rendered to markdown (incremental writer input). */
118
+ readonly savedVulnIds = new Set<string>()
119
+ updateHistoryAgent: { readonly agentId?: string; readonly agentName?: string } | undefined
120
+ private readonly clock: Clock
121
+
122
+ constructor(options: { readonly runId?: string; readonly runName?: string | null; readonly clock?: Clock } = {}) {
123
+ this.clock = options.clock ?? (() => new Date())
124
+ this.runId = options.runId ?? `run-${Math.random().toString(16).slice(2, 10)}`
125
+ this.runName = options.runName ?? null
126
+ this.startTime = formatIso(this.clock())
127
+ }
128
+
129
+ /** Allocate the next sequential id (state.py :343 — length-derived). */
130
+ private nextId(): string {
131
+ return `vuln-${String(this.vulnerabilityReports.length + 1).padStart(4, '0')}`
132
+ }
133
+
134
+ /**
135
+ * Add one report with strix's exact field construction order.
136
+ * @param input - the validated/normalized create payload.
137
+ */
138
+ addVulnerabilityReport(input: AddReportInput): VulnerabilityReport {
139
+ const report: Record<string, unknown> = {
140
+ id: this.nextId(),
141
+ title: cleanTitle(input.title),
142
+ severity: input.severity.toLowerCase().trim(),
143
+ timestamp: formatTimestamp(this.clock()),
144
+ }
145
+ for (const field of OPTIONAL_STRING_FIELDS) {
146
+ const value = input.fields[field]
147
+ if (typeof value === 'string' && value.trim() !== '') report[field] = value.trim()
148
+ }
149
+ const confidence = input.fields['confidence']
150
+ if (typeof confidence === 'string' && confidence.trim() !== '') report['confidence'] = confidence.trim().toLowerCase()
151
+ const fixEffort = input.fields['fix_effort']
152
+ if (typeof fixEffort === 'string' && fixEffort.trim() !== '') report['fix_effort'] = fixEffort.trim().toLowerCase()
153
+ const cvss = input.fields['cvss']
154
+ if (cvss !== null && cvss !== undefined) report['cvss'] = cvss
155
+ const breakdown = input.fields['cvss_breakdown']
156
+ if (breakdown !== null && breakdown !== undefined && Object.keys(breakdown as object).length > 0) report['cvss_breakdown'] = breakdown
157
+ const cwe = input.fields['cwe']
158
+ if (typeof cwe === 'string' && cwe.trim() !== '') report['cwe'] = cwe.trim()
159
+ const codeLocations = input.fields['code_locations']
160
+ if (codeLocations !== null && codeLocations !== undefined && (codeLocations as unknown[]).length > 0) report['code_locations'] = codeLocations
161
+ report['finding_class'] = (input.findingClass ?? 'dynamic').toLowerCase().trim()
162
+ if (input.dependencyMetadata !== undefined && Object.keys(input.dependencyMetadata).length > 0) report['dependency_metadata'] = input.dependencyMetadata
163
+ if (input.agentId !== undefined && input.agentId !== '') report['agent_id'] = input.agentId
164
+ if (input.agentName !== undefined && input.agentName !== '') report['agent_name'] = input.agentName
165
+ const frozen = report as VulnerabilityReport
166
+ this.vulnerabilityReports.push(frozen)
167
+ return frozen
168
+ }
169
+
170
+ /**
171
+ * Revise one report in place (state.py `update_vulnerability_report`):
172
+ * whitelist + strip/lowercase, unchanged-skip, dependent-field dropping,
173
+ * history append, and the MD invalidation marker.
174
+ * @param reportId - the `vuln-NNNN` id.
175
+ * @param changes - only the fields the model passed.
176
+ * @param reason - the update_reason (truncated to 500).
177
+ */
178
+ updateVulnerabilityReport(reportId: string, changes: Record<string, unknown>, reason: string): UpdateOutcome {
179
+ const report = this.vulnerabilityReports.find(entry => entry.id === reportId)
180
+ if (report === undefined) return { noop: true }
181
+ const mutable = report as Record<string, unknown>
182
+ const changed: string[] = []
183
+ const dropped: string[] = []
184
+ const history: UpdateHistoryEntry = {
185
+ timestamp: formatTimestamp(this.clock()),
186
+ fields: [],
187
+ reason: reason.slice(0, 500),
188
+ ...(this.updateHistoryAgent?.agentId !== undefined ? { agent_id: this.updateHistoryAgent.agentId } : {}),
189
+ ...(this.updateHistoryAgent?.agentName !== undefined ? { agent_name: this.updateHistoryAgent.agentName } : {}),
190
+ }
191
+ const previous: { severity?: string; cvss?: number; confidence?: string } = {}
192
+ for (const [field, rawValue] of Object.entries(changes)) {
193
+ if (!UPDATABLE_REPORT_FIELDS.has(field)) continue
194
+ let value: unknown = rawValue
195
+ if (field === 'title' && typeof value === 'string') value = cleanTitle(value)
196
+ else if (typeof value === 'string') value = value.trim()
197
+ if (LOWERCASE_FIELDS.has(field) && typeof value === 'string') value = value.toLowerCase()
198
+ if (Object.is(mutable[field], value)) continue
199
+ if (JSON.stringify(mutable[field]) === JSON.stringify(value)) continue
200
+ if (mutable[field] !== undefined) {
201
+ if (field === 'severity') previous.severity = mutable['severity'] as string
202
+ if (field === 'cvss') previous.cvss = mutable['cvss'] as number
203
+ if (field === 'confidence') previous.confidence = mutable['confidence'] as string
204
+ }
205
+ const dependent = DEPENDENT_REPORT_FIELDS[field]
206
+ if (dependent !== undefined && mutable[dependent] !== undefined && changes[dependent] === undefined) {
207
+ delete mutable[dependent]
208
+ dropped.push(dependent)
209
+ }
210
+ mutable[field] = value
211
+ changed.push(field)
212
+ }
213
+ if (changed.length === 0 && dropped.length === 0) return { noop: true }
214
+ history.fields = [...changed].sort()
215
+ if (dropped.length > 0) history.dropped_fields = [...dropped].sort()
216
+ if (previous.severity !== undefined) history.previous_severity = previous.severity
217
+ if (previous.cvss !== undefined) history.previous_cvss = previous.cvss
218
+ if (previous.confidence !== undefined) history.previous_confidence = previous.confidence
219
+ const historyList = (mutable['update_history'] as UpdateHistoryEntry[] | undefined) ?? []
220
+ historyList.push(history)
221
+ mutable['update_history'] = historyList
222
+ mutable['updated_at'] = history.timestamp
223
+ this.savedVulnIds.delete(reportId)
224
+ return { report }
225
+ }
226
+
227
+ /**
228
+ * Reload from a previous run's vulnerabilities.json so id allocation does
229
+ * not collide (state.py `hydrate_from_run_dir`; raises on corrupt JSON).
230
+ * @param reports - the parsed JSON array.
231
+ */
232
+ hydrate(reports: unknown): void {
233
+ if (!Array.isArray(reports)) throw new Error('corrupt vulnerabilities.json: expected a list of reports')
234
+ for (const entry of reports) {
235
+ const report = entry as Record<string, unknown>
236
+ if (report['finding_class'] === undefined) {
237
+ report['finding_class'] = report['dependency_metadata'] !== undefined ? 'dependency_cve' : 'dynamic'
238
+ }
239
+ if (typeof report['title'] === 'string') report['title'] = cleanTitle(report['title'])
240
+ this.vulnerabilityReports.push(report as VulnerabilityReport)
241
+ if (typeof report['id'] === 'string') this.savedVulnIds.add(report['id'])
242
+ }
243
+ }
244
+
245
+ /** Mark the run complete (status transition + end time). */
246
+ complete(exitStatus = 'completed'): void {
247
+ this.endTime = formatIso(this.clock())
248
+ this.status = exitStatus
249
+ }
250
+ }
package/src/writers.ts ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Artifact writers — port of strix report/writer.py: atomic writes
3
+ * (temp-in-same-dir + rename, no fsync), run.json, vulnerabilities.csv
4
+ * (formula-injection guard, \r\n terminators, uppercase severity,
5
+ * severity-then-timestamp ordering), vulnerabilities.json (byte-identical
6
+ * serialization), the vuln-NNNN.md renderer (exact section order), and the
7
+ * executive report template.
8
+ * @module @gpzhang2001/sharpkit-reporting/writers
9
+ */
10
+
11
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
12
+ import { dirname, join } from 'node:path'
13
+ import { SEVERITY_ORDER, severityRank, type VulnerabilityReport } from './state.ts'
14
+
15
+ /** JSON.stringify with Python `json.dumps(ensure_ascii=False, indent=2)` parity. */
16
+ export function dumpsIndent(value: unknown): string {
17
+ return JSON.stringify(value, null, 2)
18
+ }
19
+
20
+ /**
21
+ * Atomic text write (writer.py `atomic_write_text` :201-220): temp file in
22
+ * the target's directory + rename; no fsync (strix parity).
23
+ * @param path - target file path.
24
+ * @param payload - exact bytes to write.
25
+ */
26
+ export async function atomicWriteText(path: string, payload: string): Promise<void> {
27
+ await mkdir(dirname(path), { recursive: true })
28
+ const temp = `${dirname(path)}/.${join('', basenameOf(path))}.${process.pid}.tmp`
29
+ await writeFile(temp, payload, 'utf8')
30
+ await rename(temp, path)
31
+ }
32
+
33
+ function basenameOf(path: string): string {
34
+ const index = path.lastIndexOf('/')
35
+ return index === -1 ? path : path.slice(index + 1)
36
+ }
37
+
38
+ /**
39
+ * Formula-injection guard (writer.py `csv_safe` :35-53): prefix `'` when the
40
+ * rendered cell starts with `= + - @ \t \r`.
41
+ * @param value - the cell value.
42
+ */
43
+ export function csvSafe(value: string): string {
44
+ return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
45
+ }
46
+
47
+ /** CSV columns (writer.py :165-184). */
48
+ const CSV_COLUMNS = ['id', 'title', 'severity', 'timestamp', 'file'] as const
49
+
50
+ /** CSV-escape one cell per RFC 4180 as Python's csv module does. */
51
+ function csvCell(value: string): string {
52
+ const safe = csvSafe(value)
53
+ if (safe.includes('"') || safe.includes(',') || safe.includes('\r') || safe.includes('\n')) {
54
+ return `"${safe.replace(/"/g, '""')}"`
55
+ }
56
+ return safe
57
+ }
58
+
59
+ /**
60
+ * Render vulnerabilities.csv: header + rows sorted by (severity rank,
61
+ * timestamp), uppercase severity, \r\n terminators.
62
+ * @param reports - the stored reports.
63
+ */
64
+ export function renderVulnerabilitiesCsv(reports: readonly VulnerabilityReport[]): string {
65
+ const sorted = [...reports].sort((a, b) =>
66
+ severityRank(String(a.severity)) - severityRank(String(b.severity))
67
+ || String(a.timestamp).localeCompare(String(b.timestamp)),
68
+ )
69
+ const lines = [CSV_COLUMNS.join(',')]
70
+ for (const report of sorted) {
71
+ const cells = [
72
+ csvCell(String(report.id)),
73
+ csvCell(String(report.title)),
74
+ csvCell(String(report.severity).toUpperCase()),
75
+ csvCell(String(report.timestamp)),
76
+ csvCell(`vulnerabilities/${String(report.id)}.md`),
77
+ ]
78
+ lines.push(cells.join(','))
79
+ }
80
+ return `${lines.join('\r\n')}\r\n`
81
+ }
82
+
83
+ /** title-case one word (strix Confidence/Fix Effort display). */
84
+ function titleCase(value: string): string {
85
+ return value.charAt(0).toUpperCase() + value.slice(1)
86
+ }
87
+
88
+ /** Safe fence length: one longer than the longest backtick run (writer.py :56-66). */
89
+ function safeFence(code: string): string {
90
+ let longest = 0
91
+ let current = 0
92
+ for (const char of code) {
93
+ if (char === '`') {
94
+ current++
95
+ longest = Math.max(longest, current)
96
+ } else {
97
+ current = 0
98
+ }
99
+ }
100
+ return '`'.repeat(Math.max(3, longest + 1))
101
+ }
102
+
103
+ /** Unwrap an existing fence and return its language (writer.py :69-82). */
104
+ function parseFencedCode(code: string): { readonly language: string; readonly body: string } {
105
+ const match = /^```([A-Za-z0-9_+-]*)\n([\s\S]*?)\n?```$/.exec(code.trim())
106
+ if (match === null) return { language: '', body: code }
107
+ return { language: match[1] ?? '', body: match[2] ?? '' }
108
+ }
109
+
110
+ /** Guess a fenced language for a PoC script (writer.py :107-116). */
111
+ function guessLanguageName(code: string): string {
112
+ if (/^\s*(import |from |def |class |print\()/.test(code)) return 'python'
113
+ if (/^\s*(const |let |var |function |require\()/.test(code)) return 'javascript'
114
+ if (/^\s*(curl |GET |POST |PUT |DELETE )/.test(code)) return 'bash'
115
+ return 'python'
116
+ }
117
+
118
+ /** Metadata lines of the vuln markdown header block (writer.py :222-259 order). */
119
+ function renderMetadataLines(report: Record<string, unknown>): string[] {
120
+ const lines: string[] = [`**ID:** ${String(report['id'])}`, `**Severity:** ${String(report['severity']).toUpperCase()}`, `**Found:** ${String(report['timestamp'])}`]
121
+ const depMeta = (report['dependency_metadata'] as Record<string, unknown> | null | undefined) ?? {}
122
+ const cvss = report['cvss']
123
+ const metadata: Array<[string, unknown]> = [
124
+ ['Target', report['target']],
125
+ ['Package', depMeta['package_name']],
126
+ ['Ecosystem', depMeta['package_ecosystem']],
127
+ ['Installed Version', depMeta['installed_version']],
128
+ ['Fixed Version', depMeta['fixed_version']],
129
+ ['Introduced By', depMeta['introduced_by']],
130
+ ['Dependency Chain', depMeta['dependency_path']],
131
+ ['Endpoint', report['endpoint']],
132
+ ['Method', report['method']],
133
+ ['CVE', report['cve']],
134
+ ['CWE', report['cwe']],
135
+ ]
136
+ if (cvss !== null && cvss !== undefined) metadata.push(['CVSS', cvss])
137
+ const advisory = depMeta['advisory_cvss']
138
+ if (advisory !== null && advisory !== undefined && advisory !== cvss) metadata.push(['Advisory CVSS', advisory])
139
+ if (depMeta['contextual_cvss_vector'] !== undefined && depMeta['contextual_cvss_vector'] !== null && depMeta['contextual_cvss_vector'] !== '') {
140
+ metadata.push(['Contextual CVSS Vector', depMeta['contextual_cvss_vector']])
141
+ }
142
+ if (report['confidence'] !== undefined && report['confidence'] !== null && report['confidence'] !== '') {
143
+ metadata.push(['Confidence', titleCase(String(report['confidence']))])
144
+ }
145
+ if (report['fix_effort'] !== undefined && report['fix_effort'] !== null && report['fix_effort'] !== '') {
146
+ metadata.push(['Fix Effort', titleCase(String(report['fix_effort']))])
147
+ }
148
+ for (const [label, value] of metadata) {
149
+ if (value !== null && value !== undefined && value !== '') lines.push(`**${label}:** ${String(value)}`)
150
+ }
151
+ return lines
152
+ }
153
+
154
+ /** One code-location section (writer.py :315-342, 2-space indents verbatim). */
155
+ function renderCodeLocation(location: Record<string, unknown>, index: number): string[] {
156
+ const lines: string[] = ['## Code Analysis', '']
157
+ const file = String(location['file'] ?? 'unknown')
158
+ const start = location['start_line']
159
+ const end = location['end_line']
160
+ let lineLabel = ''
161
+ if (start !== null && start !== undefined) {
162
+ lineLabel = end !== undefined && end !== null && end !== start ? ` (lines ${String(start)}-${String(end)})` : ` (line ${String(start)})`
163
+ }
164
+ lines.push(`**Location ${String(index + 1)}:** \`${file}\`${lineLabel}`)
165
+ const label = location['label']
166
+ if (typeof label === 'string' && label !== '') lines.push(` ${label}`)
167
+ const snippet = location['snippet']
168
+ if (typeof snippet === 'string' && snippet !== '') {
169
+ const fence = safeFence(snippet)
170
+ lines.push(` ${fence}`)
171
+ for (const line of snippet.split('\n')) lines.push(` ${line}`)
172
+ lines.push(` ${fence}`)
173
+ }
174
+ const fixBefore = location['fix_before']
175
+ const fixAfter = location['fix_after']
176
+ if ((typeof fixBefore === 'string' && fixBefore !== '') || (typeof fixAfter === 'string' && fixAfter !== '')) {
177
+ lines.push('')
178
+ lines.push(' **Suggested Fix:**')
179
+ lines.push('```diff')
180
+ if (typeof fixBefore === 'string' && fixBefore !== '') for (const line of fixBefore.split('\n')) lines.push(`- ${line}`)
181
+ if (typeof fixAfter === 'string' && fixAfter !== '') for (const line of fixAfter.split('\n')) lines.push(`+ ${line}`)
182
+ lines.push('```')
183
+ }
184
+ lines.push('')
185
+ return lines
186
+ }
187
+
188
+ /** Update history section (writer.py `render_update_history` :364-396). */
189
+ export function renderUpdateHistory(report: Record<string, unknown>): string[] {
190
+ const history = report['update_history'] as ReadonlyArray<Record<string, unknown>> | undefined
191
+ if (history === undefined || history.length === 0) return []
192
+ const lines: string[] = ['## Update History', '']
193
+ for (const entry of history) {
194
+ const who = (entry['agent_name'] as string | undefined) ?? (entry['agent_id'] as string | undefined) ?? 'an agent'
195
+ const fields = (entry['fields'] as readonly string[] | undefined)?.join(', ') ?? ''
196
+ lines.push(`**${String(entry['timestamp'])}** — ${who} updated: ${fields}`)
197
+ const dropped = entry['dropped_fields'] as readonly string[] | undefined
198
+ if (dropped !== undefined && dropped.length > 0) lines.push(` Dropped as superseded: ${dropped.join(', ')}`)
199
+ const previousSeverity = entry['previous_severity']
200
+ if (typeof previousSeverity === 'string') lines.push(` Previous severity: ${previousSeverity}`)
201
+ const previousCvss = entry['previous_cvss']
202
+ if (previousCvss !== undefined && previousCvss !== null) lines.push(` Previous CVSS: ${String(previousCvss)}`)
203
+ const previousConfidence = entry['previous_confidence']
204
+ if (typeof previousConfidence === 'string') lines.push(` Previous confidence: ${previousConfidence}`)
205
+ const reason = entry['reason']
206
+ if (typeof reason === 'string' && reason !== '') lines.push(` Reason: ${reason}`)
207
+ lines.push('')
208
+ }
209
+ return lines
210
+ }
211
+
212
+ /**
213
+ * Render one vulnerability markdown document (writer.py
214
+ * `render_vulnerability_md` :223-361 section order).
215
+ * @param report - the stored report dict.
216
+ */
217
+ export function renderVulnerabilityMd(report: VulnerabilityReport): string {
218
+ const record = report as unknown as Record<string, unknown>
219
+ const lines: string[] = [`# ${String(record['title'])}`, '']
220
+ lines.push(...renderMetadataLines(record), '')
221
+
222
+ const section = (heading: string, field: string): void => {
223
+ const value = record[field]
224
+ if (typeof value === 'string' && value !== '') lines.push(`## ${heading}`, '', value, '')
225
+ }
226
+ section('Description', 'description')
227
+ section('Evidence', 'evidence')
228
+ section('Impact', 'impact')
229
+ section('Counterevidence', 'counterevidence')
230
+ section('Confidence Rationale', 'confidence_rationale')
231
+ section('What Would Change This Severity', 'severity_change_conditions')
232
+ section('Technical Analysis', 'technical_analysis')
233
+
234
+ const metadata = record['dependency_metadata'] as Record<string, unknown> | undefined
235
+ const contextualReasoning = metadata?.['contextual_cvss_reasoning']
236
+ if (typeof contextualReasoning === 'string' && contextualReasoning !== '') {
237
+ lines.push('## Contextual CVSS', '', contextualReasoning, '')
238
+ }
239
+
240
+ const pocDescription = record['poc_description']
241
+ const pocScript = record['poc_script_code']
242
+ if ((typeof pocDescription === 'string' && pocDescription !== '') || (typeof pocScript === 'string' && pocScript !== '')) {
243
+ lines.push('## Proof of Concept', '')
244
+ if (typeof pocDescription === 'string' && pocDescription !== '') lines.push(pocDescription, '')
245
+ if (typeof pocScript === 'string' && pocScript !== '') {
246
+ const fenced = parseFencedCode(pocScript)
247
+ const language = fenced.language !== '' ? fenced.language : guessLanguageName(fenced.body)
248
+ const fence = safeFence(fenced.body)
249
+ lines.push(`${fence}${language}`, fenced.body, fence, '')
250
+ }
251
+ }
252
+
253
+ const locations = record['code_locations'] as ReadonlyArray<Record<string, unknown>> | undefined
254
+ if (locations !== undefined && locations.length > 0) {
255
+ for (const [index, location] of locations.entries()) lines.push(...renderCodeLocation(location, index))
256
+ }
257
+
258
+ section('Remediation', 'remediation_steps')
259
+ section('Fix Verification', 'fix_verification')
260
+ section('Assumptions', 'assumptions')
261
+ lines.push(...renderUpdateHistory(record))
262
+ return lines.join('\n')
263
+ }
264
+
265
+ /**
266
+ * Write the per-run artifacts handled outside SARIF (writer.py parity):
267
+ * vulnerabilities/*.md (incremental), vulnerabilities.csv,
268
+ * vulnerabilities.json.
269
+ * @param runDir - the run directory.
270
+ * @param reports - all stored reports.
271
+ * @param savedIds - ids whose MD is already on disk (updated ids must be re-rendered by the caller removing them).
272
+ */
273
+ export async function writeVulnerabilities(
274
+ runDir: string,
275
+ reports: readonly VulnerabilityReport[],
276
+ savedIds: ReadonlySet<string>,
277
+ ): Promise<void> {
278
+ for (const report of reports) {
279
+ if (savedIds.has(report.id)) continue
280
+ await atomicWriteText(join(runDir, 'vulnerabilities', `${report.id}.md`), renderVulnerabilityMd(report))
281
+ }
282
+ await atomicWriteText(join(runDir, 'vulnerabilities.csv'), renderVulnerabilitiesCsv(reports))
283
+ await atomicWriteText(join(runDir, 'vulnerabilities.json'), dumpsIndent(reports))
284
+ }
285
+
286
+ /**
287
+ * Write the assembled coverage document (coverage.py `write_coverage`).
288
+ * @param runDir - the run directory.
289
+ * @param document - the assembled coverage document.
290
+ */
291
+ export async function writeCoverage(runDir: string, document: Record<string, unknown>): Promise<void> {
292
+ await atomicWriteText(join(runDir, 'coverage.json'), dumpsIndent(document))
293
+ }
294
+
295
+ /**
296
+ * Write run.json last (state.py ordering).
297
+ * @param runDir - the run directory.
298
+ * @param runRecord - the run record dict.
299
+ */
300
+ export async function writeRunRecord(runDir: string, runRecord: Record<string, unknown>): Promise<void> {
301
+ await atomicWriteText(join(runDir, 'run.json'), dumpsIndent(runRecord))
302
+ }
303
+
304
+ /**
305
+ * Write the executive report (plain truncate-write, writer.py :139-145).
306
+ * @param runDir - the run directory.
307
+ * @param finalScanResult - the composed final report body.
308
+ * @param generatedAt - display timestamp.
309
+ */
310
+ export async function writeExecutiveReport(runDir: string, finalScanResult: string, generatedAt: string): Promise<void> {
311
+ await mkdir(runDir, { recursive: true })
312
+ await writeFile(join(runDir, 'penetration_test_report.md'), `# Security Penetration Test Report\n\n**Generated:** ${generatedAt}\n\n${finalScanResult}`, 'utf8')
313
+ }
314
+
315
+ /** Severity list used by callers that need the canonical order. */
316
+ export const severityOrder = SEVERITY_ORDER