@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/LICENSE +201 -0
- package/README.md +32 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +498 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +2789 -0
- package/lib/index.js.map +1 -0
- package/package.json +45 -0
- package/src/cvss.ts +141 -0
- package/src/dedupe.ts +136 -0
- package/src/index.ts +1045 -0
- package/src/sarif.ts +531 -0
- package/src/state.ts +250 -0
- package/src/writers.ts +316 -0
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
|