@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/sarif.ts ADDED
@@ -0,0 +1,531 @@
1
+ /**
2
+ * SARIF 2.1.0 writer — port of strix report/sarif.py: rule ids normalized
3
+ * CWE→CVE→id→slug, GitHub security-severity, STRIDE tags from the CWE map,
4
+ * physical + synthetic (SECURITY.md anchor) locations with endpoint logical
5
+ * locations, PR-suggestion fixes, deterministic partial fingerprints
6
+ * (sha256), and the strix-namespaced properties (PoC script body never
7
+ * exported). Key insertion order matches Python dict construction
8
+ * byte-for-byte (golden-diff locked).
9
+ * @module @gpzhang2001/sharpkit-reporting/sarif
10
+ */
11
+
12
+ import { createHash } from 'node:crypto'
13
+ import { rename, writeFile, rm } from 'node:fs/promises'
14
+ import { join } from 'node:path'
15
+ import { dumpsIndent } from './writers.ts'
16
+ import type { VulnerabilityReport } from './state.ts'
17
+
18
+ export const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json'
19
+ export const SARIF_VERSION = '2.1.0'
20
+ export const TOOL_NAME = 'sharpkit'
21
+ export const TOOL_INFORMATION_URI = 'https://github.com/gpzhang2001/sharpkit'
22
+ const SYNTHETIC_LOCATION_URI = 'SECURITY.md'
23
+ const DEFAULT_STRIDE_LEGS: readonly string[] = ['T', 'I']
24
+
25
+ /** CWE → STRIDE legs (sarif.py `_CWE_TO_STRIDE`, verbatim). */
26
+ const CWE_TO_STRIDE: Readonly<Record<string, readonly string[]>> = {
27
+ '287': ['S'], '290': ['S'], '294': ['S'], '306': ['S', 'E'], '345': ['S', 'T'], '346': ['S'],
28
+ '352': ['T', 'S'], '384': ['S'], '521': ['S'], '613': ['S'], '640': ['S'],
29
+ '259': ['S', 'I'], '798': ['S', 'I'], '1391': ['S'],
30
+ '20': ['T'], '73': ['T', 'I'], '78': ['T', 'E'], '79': ['T', 'I'], '89': ['T'], '91': ['T'],
31
+ '94': ['T', 'E'], '434': ['T'], '502': ['T', 'E'], '915': ['E', 'T'], '918': ['T', 'I'], '1336': ['T', 'E'],
32
+ '117': ['R'], '223': ['R'], '778': ['R'],
33
+ '200': ['I'], '201': ['I'], '209': ['I'], '256': ['I'], '311': ['I'], '319': ['I'], '327': ['I'],
34
+ '328': ['I'], '522': ['I'], '525': ['I'], '532': ['I'], '538': ['I'], '598': ['I'],
35
+ '400': ['D'], '770': ['D'], '1333': ['D'],
36
+ '269': ['E'], '284': ['E'], '285': ['E'], '639': ['E'], '732': ['E'], '862': ['E'], '863': ['E'], '1220': ['E'],
37
+ '22': ['T', 'I'], '611': ['I', 'T'],
38
+ }
39
+
40
+ /** Curated vulnerability-class keywords (sarif.py `_VULN_CLASS_KEYWORDS`). */
41
+ const VULN_CLASS_KEYWORDS: readonly string[] = [
42
+ 'missing authentication', 'missing authorization', 'broken access control', 'incorrect authorization',
43
+ 'default credentials', 'hardcoded credentials', 'hardcoded secret', 'hardcoded password', 'default admin',
44
+ 'default password', 'session fixation', 'open redirect', 'path traversal', 'directory traversal',
45
+ 'command injection', 'sql injection', 'code injection', 'template injection', 'xpath injection',
46
+ 'ldap injection', 'log injection', 'header injection', 'csv injection', 'prompt injection',
47
+ 'deserialization', 'ssrf', 'xss', 'csrf', 'xxe', 'race condition', 'toctou', 'information disclosure',
48
+ 'insecure direct object reference', 'idor', 'bola', 'bfla', 'cross-tenant', 'cross-project', 'tenant bypass',
49
+ ]
50
+
51
+ const SEVERITY_TO_LEVEL: Readonly<Record<string, string>> = {
52
+ critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'note', informational: 'note',
53
+ }
54
+
55
+ const SEVERITY_TO_SCORE: Readonly<Record<string, string>> = {
56
+ critical: '9.5', high: '8.0', medium: '5.5', low: '3.0', info: '1.0', informational: '1.0',
57
+ }
58
+
59
+ type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
60
+
61
+ function stringValue(value: unknown): string | null {
62
+ if (typeof value === 'string') {
63
+ const stripped = value.trim()
64
+ return stripped === '' ? null : stripped
65
+ }
66
+ return null
67
+ }
68
+
69
+ function sha256(text: string): string {
70
+ return createHash('sha256').update(text, 'utf8').digest('hex')
71
+ }
72
+
73
+ /** CWE variants (`CWE-89` / `cwe: 89` / `89`) → `CWE-89`. */
74
+ function normalizeCwe(value: string): string | null {
75
+ const digits = value.replace(/\D/g, '')
76
+ return digits === '' ? null : `CWE-${digits}`
77
+ }
78
+
79
+ /** Stable rule id: CWE → CVE → finding id → slug → sharpkit-finding. */
80
+ export function ruleIdOf(report: VulnerabilityReport): string {
81
+ const cwe = stringValue(report.cwe)
82
+ if (cwe !== null) {
83
+ const normalized = normalizeCwe(cwe)
84
+ if (normalized !== null) return normalized
85
+ }
86
+ const cve = stringValue(report.cve)
87
+ if (cve !== null) return cve
88
+ const id = stringValue(report.id)
89
+ if (id !== null) return id
90
+ const title = stringValue(report.title)
91
+ return title === null ? 'sharpkit-finding' : slugify(title)
92
+ }
93
+
94
+ /** Lowercase slug joined by dashes (sarif.py `_slugify`). */
95
+ export function slugify(value: string): string {
96
+ const chars = [...value.toLowerCase()].map(char => (/[a-z0-9]/.test(char) ? char : '-')).join('')
97
+ const slug = chars.split('-').filter(part => part !== '').join('-')
98
+ return slug === '' ? 'sharpkit-finding' : slug
99
+ }
100
+
101
+ /** STRIDE legs for a CWE, default legs when unmapped (every finding gets ≥1). */
102
+ export function strideLegsForCwe(cwe: unknown): readonly string[] {
103
+ if (typeof cwe !== 'string' || cwe === '') return DEFAULT_STRIDE_LEGS
104
+ const digits = cwe.replace(/\D/g, '')
105
+ if (digits === '') return DEFAULT_STRIDE_LEGS
106
+ return CWE_TO_STRIDE[digits] ?? DEFAULT_STRIDE_LEGS
107
+ }
108
+
109
+ /** First curated keyword in the title, else the first 5 alphanumeric words. */
110
+ export function classKeyword(title: string): string {
111
+ const lower = title.toLowerCase()
112
+ for (const keyword of VULN_CLASS_KEYWORDS) {
113
+ if (lower.includes(keyword)) return keyword
114
+ }
115
+ const words = lower.match(/[a-z0-9]+/g)?.slice(0, 5) ?? []
116
+ return words.join(' ')
117
+ }
118
+
119
+ /** SARIF level mapping. */
120
+ export function sarifLevel(severity: unknown): string {
121
+ const normalized = (typeof severity === 'string' ? severity : '').toLowerCase()
122
+ return SEVERITY_TO_LEVEL[normalized] ?? 'note'
123
+ }
124
+
125
+ /** GitHub security-severity: "%.1f" CVSS else the label score. */
126
+ export function securitySeverity(report: VulnerabilityReport): string {
127
+ if (report.cvss !== null && report.cvss !== undefined) {
128
+ const score = Number(report.cvss)
129
+ if (!Number.isNaN(score)) return score.toFixed(1)
130
+ }
131
+ const normalized = (typeof report.severity === 'string' ? report.severity : 'info').toLowerCase()
132
+ return SEVERITY_TO_SCORE[normalized] ?? '1.0'
133
+ }
134
+
135
+ /** Reject unsafe SARIF artifact URIs; normalize backslashes (sarif.py `_sarif_uri`). */
136
+ export function sarifUri(file: string): string | null {
137
+ const uri = file.replace(/\\/g, '/')
138
+ if (uri.startsWith('/')) return null
139
+ const first = uri.split('/')[0] ?? ''
140
+ if (/^[A-Za-z]:$/.test(first)) return null
141
+ if (uri.split('/').some(part => part === '..')) return null
142
+ return uri
143
+ }
144
+
145
+ /** Help text: description + impact + remediation joined by blank lines. */
146
+ function helpText(report: VulnerabilityReport, fallback: string): string {
147
+ const sections = [report.description, report.impact, report.remediation_steps]
148
+ .filter((value): value is string => typeof value === 'string' && value.trim() !== '')
149
+ return sections.length > 0 ? sections.join('\n\n') : fallback
150
+ }
151
+
152
+ interface PhysicalLocationInput {
153
+ readonly file: unknown
154
+ readonly start_line: unknown
155
+ readonly end_line?: unknown
156
+ readonly snippet?: unknown
157
+ readonly label?: unknown
158
+ }
159
+
160
+ /** Validated physical locations + dropped count (sarif.py `_build_physical_locations`). */
161
+ function buildPhysicalLocations(rawLocations: unknown): { readonly locations: Json[]; readonly dropped: number } {
162
+ const locations: Json[] = []
163
+ let dropped = 0
164
+ if (!Array.isArray(rawLocations)) return { locations, dropped }
165
+ for (const raw of rawLocations) {
166
+ if (typeof raw !== 'object' || raw === null) continue
167
+ const location = raw as PhysicalLocationInput
168
+ const file = stringValue(location.file)
169
+ const startLine = location.start_line
170
+ if (file === null || typeof startLine !== 'number' || !Number.isInteger(startLine) || startLine < 1) {
171
+ dropped++
172
+ continue
173
+ }
174
+ const uri = sarifUri(file)
175
+ if (uri === null) {
176
+ dropped++
177
+ continue
178
+ }
179
+ const physical: { [key: string]: Json } = {
180
+ artifactLocation: { uri },
181
+ }
182
+ const region: { [key: string]: Json } = { startLine }
183
+ const endLine = location.end_line
184
+ if (typeof endLine === 'number' && Number.isInteger(endLine) && endLine >= startLine) region['endLine'] = endLine
185
+ const snippet = stringValue(location.snippet)
186
+ if (snippet !== null) region['snippet'] = { text: snippet }
187
+ physical['region'] = region
188
+ const entry: { [key: string]: Json } = { physicalLocation: physical }
189
+ const label = stringValue(location.label)
190
+ if (label !== null) entry['message'] = { text: label }
191
+ locations.push(entry)
192
+ }
193
+ return { locations, dropped }
194
+ }
195
+
196
+ /** Locations with the synthetic anchor and endpoint/resource logical entries. */
197
+ function buildLocations(report: VulnerabilityReport): { readonly locations: Json[]; readonly isSynthetic: boolean; readonly dropped: number } {
198
+ const physical = buildPhysicalLocations(report.code_locations)
199
+ const isSynthetic = physical.locations.length === 0
200
+ const locations: Json[] = isSynthetic ? [{ physicalLocation: { artifactLocation: { uri: SYNTHETIC_LOCATION_URI } } }] : [...physical.locations]
201
+ const endpoint = stringValue(report.endpoint)
202
+ if (endpoint !== null) {
203
+ locations.push({ logicalLocations: [{ fullyQualifiedName: endpoint, kind: 'endpoint' }] })
204
+ } else if (isSynthetic) {
205
+ const resource = stringValue(report.target) ?? stringValue(report.title)
206
+ if (resource !== null) locations.push({ logicalLocations: [{ fullyQualifiedName: resource, kind: 'resource' }] })
207
+ }
208
+ return { locations, isSynthetic, dropped: physical.dropped }
209
+ }
210
+
211
+ /** Deterministic per-finding fingerprint (sarif.py `_primary_fingerprint`). */
212
+ function primaryFingerprint(ruleId: string, report: VulnerabilityReport, locations: readonly Json[], isSynthetic: boolean): string | null {
213
+ let uri = ''
214
+ let startLine: number | null = null
215
+ const first = locations.find(location => typeof location === 'object' && location !== null && 'physicalLocation' in location) as { physicalLocation?: { artifactLocation?: { uri?: unknown }; region?: { startLine?: unknown } } } | undefined
216
+ if (first?.physicalLocation !== undefined) {
217
+ uri = typeof first.physicalLocation.artifactLocation?.uri === 'string' ? first.physicalLocation.artifactLocation.uri : ''
218
+ const line = first.physicalLocation.region?.startLine
219
+ if (typeof line === 'number' && Number.isInteger(line) && line >= 1) startLine = line
220
+ }
221
+ const method = stringValue(report.method) ?? ''
222
+ const endpoint = stringValue(report.endpoint) ?? ''
223
+ const route = method !== '' || endpoint !== '' ? `${method.toUpperCase()} ${endpoint}`.trim() : ''
224
+ if (uri === '' && route === '') return null
225
+ const parts: string[] = [`rule:${ruleId}`]
226
+ if (uri !== '') {
227
+ parts.push(`uri:${uri}`)
228
+ if (startLine !== null) parts.push(`line:${String(startLine)}`)
229
+ }
230
+ if (route !== '') parts.push(`route:${route}`)
231
+ if (isSynthetic) {
232
+ const title = stringValue(report.title)
233
+ if (title !== null) parts.push(`synth_class:${classKeyword(title)}`)
234
+ }
235
+ return sha256(parts.join('|'))
236
+ }
237
+
238
+ /** File-independent class fingerprint (sarif.py `_class_fingerprint`). */
239
+ function classFingerprint(ruleId: string, report: VulnerabilityReport): string | null {
240
+ const title = stringValue(report.title)
241
+ if (title === null) return null
242
+ const keyword = classKeyword(title)
243
+ if (keyword === '') return null
244
+ return sha256(`rule:${ruleId}|class:${keyword}`)
245
+ }
246
+
247
+ /** PR-suggestion fixes from fix-bearing code locations (sarif.py `_build_fixes`). */
248
+ function buildFixes(report: VulnerabilityReport): Json[] | null {
249
+ const artifactChanges: Json[] = []
250
+ if (!Array.isArray(report.code_locations)) return null
251
+ for (const raw of report.code_locations) {
252
+ if (typeof raw !== 'object' || raw === null) continue
253
+ const location = raw as Record<string, unknown>
254
+ const file = stringValue(location['file'])
255
+ const fixBefore = stringValue(location['fix_before'])
256
+ const fixAfter = stringValue(location['fix_after'])
257
+ const startLine = location['start_line']
258
+ if (file === null || fixBefore === null || fixAfter === null) continue
259
+ if (typeof startLine !== 'number' || !Number.isInteger(startLine) || startLine < 1) continue
260
+ const uri = sarifUri(file)
261
+ if (uri === null) continue
262
+ const deletedRegion: { [key: string]: Json } = { startLine }
263
+ const endLine = location['end_line']
264
+ if (typeof endLine === 'number' && Number.isInteger(endLine) && endLine >= startLine) deletedRegion['endLine'] = endLine
265
+ artifactChanges.push({
266
+ artifactLocation: { uri },
267
+ replacements: [{ deletedRegion, insertedContent: { text: fixAfter } }],
268
+ })
269
+ }
270
+ if (artifactChanges.length === 0) return null
271
+ const fix: { [key: string]: Json } = { artifactChanges }
272
+ const remediation = stringValue(report.remediation_steps)
273
+ if (remediation !== null) fix['description'] = { text: remediation, markdown: remediation }
274
+ return [fix]
275
+ }
276
+
277
+ /** Result properties (security-severity, class hash, synthetic flag, strix tree). */
278
+ function resultProperties(report: VulnerabilityReport, classFp: string | null, isSynthetic: boolean): { [key: string]: Json } {
279
+ const properties: { [key: string]: Json } = { 'security-severity': securitySeverity(report) }
280
+ if (classFp !== null) properties['sharpkit_vuln_class_hash'] = classFp
281
+ if (isSynthetic) properties['synthetic_location'] = true
282
+ const sharpkitProps: { [key: string]: Json } = {}
283
+ for (const key of [
284
+ 'id', 'severity', 'cvss', 'timestamp', 'target', 'endpoint', 'method', 'cve', 'cwe', 'impact',
285
+ 'technical_analysis', 'remediation_steps', 'counterevidence', 'confidence', 'confidence_rationale',
286
+ 'severity_change_conditions', 'fix_verification',
287
+ ]) {
288
+ const value = (report as unknown as Record<string, unknown>)[key]
289
+ if (value !== null && value !== undefined && value !== '') sharpkitProps[key] = value as Json
290
+ }
291
+ const metadata = (report as unknown as Record<string, unknown>)['dependency_metadata']
292
+ if (typeof metadata === 'object' && metadata !== null && Object.keys(metadata).length > 0) sharpkitProps['dependency_metadata'] = metadata as Json
293
+ const pocDescription = stringValue(report.poc_description)
294
+ const pocScript = stringValue(report.poc_script_code)
295
+ if (pocDescription !== null || pocScript !== null) {
296
+ const poc: { [key: string]: Json } = {}
297
+ if (pocDescription !== null) poc['description'] = pocDescription
298
+ if (pocScript !== null) poc['script_available'] = true
299
+ sharpkitProps['poc'] = poc
300
+ }
301
+ if (Object.keys(sharpkitProps).length > 0) properties['sharpkit'] = sharpkitProps
302
+ return properties
303
+ }
304
+
305
+ /** Build one rule descriptor (sarif.py `_build_rule` key order). */
306
+ function buildRule(ruleId: string, report: VulnerabilityReport): { [key: string]: Json } {
307
+ const title = stringValue(report.title) ?? ruleId
308
+ const fullDescription = stringValue(report.description) ?? title
309
+ const help = helpText(report, fullDescription)
310
+ const rule: { [key: string]: Json } = {
311
+ id: ruleId,
312
+ name: title !== '' ? title : ruleId.replace(/-/g, '_'),
313
+ shortDescription: { text: title },
314
+ fullDescription: { text: fullDescription },
315
+ defaultConfiguration: { level: sarifLevel(report.severity) },
316
+ help: { text: help, markdown: help },
317
+ }
318
+ const properties: { [key: string]: Json } = { 'security-severity': securitySeverity(report) }
319
+ const tags: string[] = ['security']
320
+ if (ruleId.startsWith('CWE-')) tags.push(ruleId)
321
+ const cve = stringValue(report.cve)
322
+ if (cve !== null && !tags.includes(cve)) tags.push(cve)
323
+ for (const leg of strideLegsForCwe(report.cwe)) {
324
+ const tag = `stride:${leg}`
325
+ if (!tags.includes(tag)) tags.push(tag)
326
+ }
327
+ properties['tags'] = tags
328
+ rule['properties'] = properties
329
+ if (ruleId.startsWith('CWE-')) rule['helpUri'] = `https://cwe.mitre.org/data/definitions/${ruleId.slice('CWE-'.length)}.html`
330
+ return rule
331
+ }
332
+
333
+ /** Build one result (sarif.py `_build_result` key order). */
334
+ function buildResult(ruleId: string, ruleIndex: number, report: VulnerabilityReport): { readonly result: { [key: string]: Json }; readonly synthetic: boolean; readonly dropped: number } {
335
+ const title = stringValue(report.title) ?? ruleId
336
+ const description = stringValue(report.description)
337
+ const messageText = description !== null ? `${title}\n\n${description}` : title
338
+ const { locations, isSynthetic, dropped } = buildLocations(report)
339
+ const result: { [key: string]: Json } = {
340
+ ruleId,
341
+ ruleIndex,
342
+ level: sarifLevel(report.severity),
343
+ message: { text: messageText },
344
+ }
345
+ if (locations.length > 0) result['locations'] = locations
346
+ const fixes = buildFixes(report)
347
+ if (fixes !== null) result['fixes'] = fixes
348
+ const fingerprint = primaryFingerprint(ruleId, report, locations, isSynthetic)
349
+ if (fingerprint !== null) result['partialFingerprints'] = { primaryLocationLineHash: fingerprint }
350
+ result['properties'] = resultProperties(report, classFingerprint(ruleId, report), isSynthetic)
351
+ return { result, synthetic: isSynthetic, dropped }
352
+ }
353
+
354
+ /** One coverage entry projected into SARIF (strix coverage.py shape). */
355
+ export interface SarifCoverageEntry {
356
+ readonly risk_area: string
357
+ readonly surface: string
358
+ readonly outcome: string
359
+ readonly evidence?: unknown
360
+ readonly recorded_by?: unknown
361
+ }
362
+
363
+ /** The coverage document subset the SARIF bridge consumes. */
364
+ export interface SarifCoverage {
365
+ readonly entries: readonly SarifCoverageEntry[]
366
+ readonly completeness?: { readonly complete?: boolean; readonly caveats?: readonly string[] } | undefined
367
+ }
368
+
369
+ /** Coverage outcome → SARIF result kind (`reported` deliberately absent). */
370
+ const OUTCOME_TO_KIND: Readonly<Record<string, string>> = {
371
+ no_issue_found: 'pass',
372
+ ruled_out: 'pass',
373
+ not_applicable: 'notApplicable',
374
+ needs_follow_up: 'open',
375
+ }
376
+
377
+ const OUTCOME_LABELS: Readonly<Record<string, string>> = {
378
+ reported: 'Finding reported',
379
+ no_issue_found: 'No issue identified',
380
+ ruled_out: 'Ruled out',
381
+ not_applicable: 'Not applicable',
382
+ needs_follow_up: 'Requires further review',
383
+ }
384
+
385
+ export interface SarifOptions {
386
+ readonly toolVersion: string
387
+ readonly coverage?: SarifCoverage | undefined
388
+ /** Exactly-one-repository provenance (strix passes None in our port for now). */
389
+ readonly repositoryContext?: {
390
+ readonly repositoryUri?: string
391
+ readonly repositoryFullName?: string
392
+ readonly commitSha?: string
393
+ readonly branch?: string
394
+ readonly ref?: string
395
+ } | undefined
396
+ }
397
+
398
+ /**
399
+ * Build the full SARIF document (top-level key order and run properties).
400
+ * Coverage results and invocations join when the coverage tool lands (批 4).
401
+ * @param reports - all stored reports.
402
+ * @param options - tool version and optional repository provenance.
403
+ */
404
+ export function buildSarif(reports: readonly VulnerabilityReport[], options: SarifOptions): { [key: string]: Json } {
405
+ const rules: { [key: string]: Json }[] = []
406
+ const ruleIndex = new Map<string, number>()
407
+ const results: Json[] = []
408
+ let syntheticCount = 0
409
+ const droppedFindings: { [key: string]: Json }[] = []
410
+ let droppedLocationCount = 0
411
+ for (const report of reports) {
412
+ const id = ruleIdOf(report)
413
+ let index = ruleIndex.get(id)
414
+ if (index === undefined) {
415
+ index = rules.length
416
+ ruleIndex.set(id, index)
417
+ rules.push(buildRule(id, report))
418
+ }
419
+ const { result, synthetic, dropped } = buildResult(id, index, report)
420
+ if (synthetic) syntheticCount++
421
+ if (dropped > 0) {
422
+ droppedLocationCount += dropped
423
+ droppedFindings.push({ droppedLocationCount: dropped, id: report.id, title: report.title })
424
+ }
425
+ results.push(result)
426
+ }
427
+ const driver: { [key: string]: Json } = {
428
+ name: TOOL_NAME,
429
+ informationUri: TOOL_INFORMATION_URI,
430
+ rules,
431
+ version: options.toolVersion,
432
+ }
433
+ const run: { [key: string]: Json } = { tool: { driver }, results }
434
+ if (options.coverage !== undefined) appendCoverage(run, options.coverage, ruleIndex, rules)
435
+ const runProperties: { [key: string]: Json } = {}
436
+ if (syntheticCount > 0) runProperties['syntheticLocationCount'] = syntheticCount
437
+ if (droppedLocationCount > 0) {
438
+ runProperties['droppedUnsafeLocationCount'] = droppedLocationCount
439
+ runProperties['droppedUnsafeLocationFindings'] = droppedFindings
440
+ }
441
+ const repo = options.repositoryContext
442
+ if (repo !== undefined) {
443
+ const provenance: { [key: string]: Json } = {}
444
+ if (repo.repositoryUri !== undefined) provenance['repositoryUri'] = repo.repositoryUri
445
+ if (repo.commitSha !== undefined) provenance['revisionId'] = repo.commitSha
446
+ if (repo.branch !== undefined) provenance['branch'] = repo.branch
447
+ if (Object.keys(provenance).length > 0) run['versionControlProvenance'] = [provenance]
448
+ if (repo.repositoryFullName !== undefined) runProperties['repository'] = repo.repositoryFullName
449
+ if (repo.ref !== undefined) runProperties['ref'] = repo.ref
450
+ if (repo.commitSha !== undefined) runProperties['commit_sha'] = repo.commitSha
451
+ }
452
+ if (Object.keys(runProperties).length > 0) run['properties'] = runProperties
453
+ return { version: SARIF_VERSION, $schema: SARIF_SCHEMA, runs: [run] }
454
+ }
455
+
456
+ /** Coverage rule + result builders and the run invocation (sarif.py :641-747). */
457
+ function appendCoverage(run: { [key: string]: Json }, coverage: SarifCoverage, ruleIndex: Map<string, number>, rules: { [key: string]: Json }[]): void {
458
+ const coverageResults: Json[] = []
459
+ for (const entry of coverage.entries) {
460
+ const outcome = typeof entry.outcome === 'string' ? entry.outcome : ''
461
+ const kind = OUTCOME_TO_KIND[outcome]
462
+ if (kind === undefined) continue
463
+ const riskArea = typeof entry.risk_area === 'string' ? entry.risk_area : ''
464
+ const ruleId = `sharpkit-coverage/${riskArea === '' ? 'unspecified' : slugify(riskArea)}`
465
+ let index = ruleIndex.get(ruleId)
466
+ if (index === undefined) {
467
+ index = rules.length
468
+ ruleIndex.set(ruleId, index)
469
+ const name = riskArea !== '' ? riskArea : ruleId.replaceAll('-', '_')
470
+ const description = `Coverage: ${riskArea}`
471
+ rules.push({
472
+ id: ruleId,
473
+ name,
474
+ shortDescription: { text: description },
475
+ fullDescription: { text: description },
476
+ defaultConfiguration: { level: 'none' },
477
+ help: { text: description, markdown: description },
478
+ properties: { tags: ['coverage'] },
479
+ })
480
+ }
481
+ const label = OUTCOME_LABELS[outcome] ?? outcome
482
+ const surface = typeof entry.surface === 'string' ? entry.surface : ''
483
+ let messageText = `${riskArea} — ${label}: ${surface}`
484
+ const evidence = typeof entry.evidence === 'string' && entry.evidence !== '' ? entry.evidence : null
485
+ if (evidence !== null) messageText += `\n\n${evidence}`
486
+ const sharpkitProps: { [key: string]: Json } = { coverage_outcome: outcome, risk_area: riskArea, surface }
487
+ if (entry.recorded_by !== undefined && entry.recorded_by !== null) sharpkitProps['recorded_by'] = entry.recorded_by as Json
488
+ sharpkitProps['source'] = 'agent_reported'
489
+ coverageResults.push({
490
+ ruleId,
491
+ ruleIndex: index,
492
+ kind,
493
+ level: 'none',
494
+ message: { text: messageText },
495
+ locations: [{ logicalLocations: [{ fullyQualifiedName: surface }] }],
496
+ properties: { sharpkit: sharpkitProps },
497
+ })
498
+ }
499
+ if (coverageResults.length > 0) {
500
+ const existing = run['results']
501
+ run['results'] = [...(Array.isArray(existing) ? existing : []), ...coverageResults]
502
+ }
503
+ const invocation: { [key: string]: Json } = { executionSuccessful: coverage.completeness?.complete ?? true }
504
+ const caveats = coverage.completeness?.caveats?.filter(caveat => caveat !== '')
505
+ if (caveats !== undefined && caveats.length > 0) {
506
+ invocation['toolExecutionNotifications'] = caveats.map(caveat => ({ level: 'warning', message: { text: caveat } }))
507
+ }
508
+ run['invocations'] = [invocation]
509
+ }
510
+
511
+ /**
512
+ * Write findings.sarif (temp sibling + rename, trailing newline; sarif.py
513
+ * `write_sarif_report`). Always emitted, even with zero findings, so a fresh
514
+ * empty doc overwrites stale results.
515
+ * @param runDir - the run directory.
516
+ * @param reports - all stored reports.
517
+ * @param options - tool version and provenance.
518
+ */
519
+ export async function writeSarif(runDir: string, reports: readonly VulnerabilityReport[], options: SarifOptions): Promise<void> {
520
+ const output = join(runDir, 'findings.sarif')
521
+ const temp = `${output}.${process.pid}.tmp`
522
+ try {
523
+ await writeFile(temp, `${dumpsIndent(buildSarif(reports, options))}\n`, 'utf8')
524
+ await rename(temp, output)
525
+ } finally {
526
+ await rm(temp, { force: true }).catch(() => {})
527
+ }
528
+ }
529
+
530
+ /** Reproduce a fingerprint for tests (exposed for golden diagnostics). */
531
+ export const internals = { primaryFingerprint, classFingerprint }