@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/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 }
|