@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/cvss.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CVSS v3.1 base-score math, ported from strix's usage of the `cvss`
|
|
3
|
+
* package (tool.py `_calculate_cvss` :134-153): build the vector string,
|
|
4
|
+
* compute the base score and qualitative severity. The score uses the
|
|
5
|
+
* CVSS 3.1 spec formula with the spec's Roundup1 (ceil to one decimal with
|
|
6
|
+
* the IEEE-754 guard), and a "none" base severity is remapped to "info"
|
|
7
|
+
* (strix parity).
|
|
8
|
+
* @module @gpzhang2001/sharpkit-reporting/cvss
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The eight CVSS metrics with their allowed values (strix `_CVSS_VALID`). */
|
|
12
|
+
export const CVSS_VALID = {
|
|
13
|
+
attack_vector: ['N', 'A', 'L', 'P'],
|
|
14
|
+
attack_complexity: ['L', 'H'],
|
|
15
|
+
privileges_required: ['N', 'L', 'H'],
|
|
16
|
+
user_interaction: ['N', 'R'],
|
|
17
|
+
scope: ['U', 'C'],
|
|
18
|
+
confidentiality: ['N', 'L', 'H'],
|
|
19
|
+
integrity: ['N', 'L', 'H'],
|
|
20
|
+
availability: ['N', 'L', 'H'],
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type CvssMetricName = keyof typeof CVSS_VALID
|
|
24
|
+
|
|
25
|
+
/** The eight metrics in vector order (strix vector build order). */
|
|
26
|
+
const METRIC_ORDER: readonly CvssMetricName[] = [
|
|
27
|
+
'attack_vector', 'attack_complexity', 'privileges_required', 'user_interaction',
|
|
28
|
+
'scope', 'confidentiality', 'integrity', 'availability',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** Weight tables (CVSS v3.1 spec §3.1). */
|
|
32
|
+
const AV: Record<string, number | undefined> = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }
|
|
33
|
+
const AC: Record<string, number | undefined> = { L: 0.77, H: 0.44 }
|
|
34
|
+
const PR_UNCHANGED: Record<string, number | undefined> = { N: 0.85, L: 0.62, H: 0.27 }
|
|
35
|
+
const PR_CHANGED: Record<string, number | undefined> = { N: 0.85, L: 0.68, H: 0.5 }
|
|
36
|
+
const UI: Record<string, number | undefined> = { N: 0.85, R: 0.62 }
|
|
37
|
+
const CIA: Record<string, number | undefined> = { H: 0.56, L: 0.22, N: 0 }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* CVSS 3.1 Roundup1: smallest one-decimal value >= the input (the `cvss`
|
|
41
|
+
* package quantizes with Decimal ROUND_CEILING; the 5-decimal pre-round
|
|
42
|
+
* absorbs float artifacts the same way the spec intends).
|
|
43
|
+
*/
|
|
44
|
+
function roundup(value: number): number {
|
|
45
|
+
const quantized = Number(value.toFixed(5))
|
|
46
|
+
return Math.ceil(Number((quantized * 10).toFixed(6))) / 10
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a breakdown against the allowed metric/value sets.
|
|
51
|
+
* @param breakdown - the model-supplied 8-metric dict.
|
|
52
|
+
* @returns validation errors, empty when valid.
|
|
53
|
+
*/
|
|
54
|
+
export function validateCvssBreakdown(breakdown: Record<string, unknown>): string[] {
|
|
55
|
+
const errors: string[] = []
|
|
56
|
+
if (typeof breakdown !== 'object' || breakdown === null || Object.keys(breakdown).length === 0) {
|
|
57
|
+
return ['cvss_breakdown must be a non-empty object with all 8 metrics']
|
|
58
|
+
}
|
|
59
|
+
for (const metric of METRIC_ORDER) {
|
|
60
|
+
const value = breakdown[metric]
|
|
61
|
+
const allowed = CVSS_VALID[metric]
|
|
62
|
+
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
|
|
63
|
+
errors.push(`Invalid ${metric}: ${String(value)}. Must be one of: [${allowed.join(', ')}]`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return errors
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the vector string (strix format: CVSS:3.1/AV:../AC:../PR:../UI:../S:../C:../I:../A:..).
|
|
71
|
+
* @param breakdown - a validated breakdown.
|
|
72
|
+
*/
|
|
73
|
+
export function buildCvssVector(breakdown: Readonly<Record<CvssMetricName, string>>): string {
|
|
74
|
+
const parts = METRIC_ORDER.map(metric => {
|
|
75
|
+
const short = { attack_vector: 'AV', attack_complexity: 'AC', privileges_required: 'PR', user_interaction: 'UI', scope: 'S', confidentiality: 'C', integrity: 'I', availability: 'A' }[metric]
|
|
76
|
+
return `${short}:${breakdown[metric]}`
|
|
77
|
+
})
|
|
78
|
+
return `CVSS:3.1/${parts.join('/')}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compute the CVSS v3.1 base score for a breakdown (the `cvss` package's
|
|
83
|
+
* `compute_base_score`: Scope U ISC = 6.42×ISCBase; Scope C ISC = the 7.52
|
|
84
|
+
* formula; Scope C multiplies the total by 1.08 before the cap).
|
|
85
|
+
* @param breakdown - a validated breakdown.
|
|
86
|
+
* @returns the rounded base score (0.0 when impact is non-positive).
|
|
87
|
+
*/
|
|
88
|
+
export function cvssBaseScore(breakdown: Readonly<Record<CvssMetricName, string>>): number {
|
|
89
|
+
const scopeChanged = breakdown.scope === 'C'
|
|
90
|
+
const c = CIA[breakdown.confidentiality] ?? 0
|
|
91
|
+
const i = CIA[breakdown.integrity] ?? 0
|
|
92
|
+
const a = CIA[breakdown.availability] ?? 0
|
|
93
|
+
const iscBase = 1 - (1 - c) * (1 - i) * (1 - a)
|
|
94
|
+
const isc = scopeChanged
|
|
95
|
+
? 7.52 * (iscBase - 0.029) - 3.25 * (iscBase - 0.02) ** 15
|
|
96
|
+
: 6.42 * iscBase
|
|
97
|
+
if (isc <= 0) return 0
|
|
98
|
+
const pr = ((scopeChanged ? PR_CHANGED : PR_UNCHANGED)[breakdown.privileges_required]) ?? 0
|
|
99
|
+
const exploitability = 8.22 * (AV[breakdown.attack_vector] ?? 0) * (AC[breakdown.attack_complexity] ?? 0) * pr * (UI[breakdown.user_interaction] ?? 0)
|
|
100
|
+
const raw = scopeChanged ? Math.min(1.08 * (isc + exploitability), 10) : Math.min(isc + exploitability, 10)
|
|
101
|
+
return roundup(raw)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Qualitative severity banding (the `cvss` package's rating bands).
|
|
106
|
+
* @param score - the base score.
|
|
107
|
+
*/
|
|
108
|
+
export function cvssSeverity(score: number): 'none' | 'low' | 'medium' | 'high' | 'critical' {
|
|
109
|
+
if (score === 0) return 'none'
|
|
110
|
+
if (score <= 3.9) return 'low'
|
|
111
|
+
if (score <= 6.9) return 'medium'
|
|
112
|
+
if (score <= 8.9) return 'high'
|
|
113
|
+
return 'critical'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Full computation: vector + score + severity with the "none"→"info" remap
|
|
118
|
+
* (strix `_calculate_cvss` return contract).
|
|
119
|
+
* @param breakdown - a validated breakdown.
|
|
120
|
+
*/
|
|
121
|
+
export function calculateCvss(breakdown: Readonly<Record<CvssMetricName, string>>): { readonly vector: string; readonly score: number; readonly severity: string } {
|
|
122
|
+
const vector = buildCvssVector(breakdown)
|
|
123
|
+
const score = cvssBaseScore(breakdown)
|
|
124
|
+
const severity = cvssSeverity(score)
|
|
125
|
+
return { vector, score, severity: severity === 'none' ? 'info' : severity }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Dependency severity banding from an advisory score (strix
|
|
130
|
+
* `_DEP_SEVERITY_FROM_CVSS` :1363-1378, top band inclusive at 10.0).
|
|
131
|
+
* @param score - the advisory base score.
|
|
132
|
+
*/
|
|
133
|
+
export function dependencySeverity(score: number): string {
|
|
134
|
+
if (score === null || score === undefined || Number.isNaN(score)) return 'info'
|
|
135
|
+
const clamped = Math.min(10, Math.max(0, score))
|
|
136
|
+
if (clamped >= 9.0) return 'critical'
|
|
137
|
+
if (clamped >= 7.0) return 'high'
|
|
138
|
+
if (clamped >= 4.0) return 'medium'
|
|
139
|
+
if (clamped >= 0.0) return 'low'
|
|
140
|
+
return 'none'
|
|
141
|
+
}
|
package/src/dedupe.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedupe — port of strix report/dedupe.py decision flow: the deterministic
|
|
3
|
+
* dependency identity fast path (CVE × package × ecosystem, distinct
|
|
4
|
+
* manifests are separate findings, legacy word-bounded mention fallback) and
|
|
5
|
+
* an injectable LLM judge for dynamic findings. Every judge failure defaults
|
|
6
|
+
* to NOT duplicate (strix parity: dedupe failures never block a finding).
|
|
7
|
+
* @module @gpzhang2001/sharpkit-reporting/dedupe
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { VulnerabilityReport } from './state.ts'
|
|
11
|
+
|
|
12
|
+
/** Dependency identity fields (strix `_dependency_identity`). */
|
|
13
|
+
export interface DependencyIdentity {
|
|
14
|
+
readonly cve: string
|
|
15
|
+
readonly packageName: string
|
|
16
|
+
readonly ecosystem: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The dedupe verdict shape (strix `DuplicateCheckResult`). */
|
|
20
|
+
export interface DuplicateVerdict {
|
|
21
|
+
readonly isDuplicate: boolean
|
|
22
|
+
readonly duplicateId: string
|
|
23
|
+
readonly confidence: number
|
|
24
|
+
readonly reason: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pluggable LLM judge (Config-injected; dsh-side LLM wiring lands with M4). */
|
|
28
|
+
export type DedupeJudge = (candidate: Record<string, unknown>, existing: readonly VulnerabilityReport[]) => Promise<DuplicateVerdict>
|
|
29
|
+
|
|
30
|
+
/** Extract the dependency identity from metadata (strix :162-177). */
|
|
31
|
+
export function dependencyIdentity(metadata: unknown): DependencyIdentity | null {
|
|
32
|
+
if (typeof metadata !== 'object' || metadata === null) return null
|
|
33
|
+
const record = metadata as Record<string, unknown>
|
|
34
|
+
const cve = record['cve']
|
|
35
|
+
const packageName = record['package_name']
|
|
36
|
+
const ecosystem = record['package_ecosystem']
|
|
37
|
+
if (typeof cve !== 'string' || cve === '' || typeof packageName !== 'string' || packageName === '' || typeof ecosystem !== 'string' || ecosystem === '') {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
return { cve: cve.toUpperCase(), packageName: packageName.toLowerCase(), ecosystem: ecosystem.toLowerCase() }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether two manifest paths are both present AND different (strix `_distinct_manifest_paths`). */
|
|
44
|
+
export function distinctManifestPaths(a: unknown, b: unknown): boolean {
|
|
45
|
+
const first = typeof a === 'string' && a !== '' ? a : null
|
|
46
|
+
const second = typeof b === 'string' && b !== '' ? b : null
|
|
47
|
+
return first !== null && second !== null && first !== second
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Word-bounded regex mention check over prose fields (strix `_legacy_report_mentions_package`). */
|
|
51
|
+
export function legacyReportMentionsPackage(report: VulnerabilityReport, identity: DependencyIdentity): boolean {
|
|
52
|
+
const fields = ['title', 'description', 'impact', 'target', 'technical_analysis', 'poc_description', 'evidence']
|
|
53
|
+
// strix boundaries: [\w@./-] on both sides break the match (dedupe.py :218-224).
|
|
54
|
+
const packagePattern = new RegExp(`(?<![\\w@./-])${escapeRegExp(identity.packageName)}(?![\\w@./-])`, 'i')
|
|
55
|
+
const ecosystemPattern = new RegExp(`(?<![\\w@./-])${escapeRegExp(identity.ecosystem)}(?![\\w@./-])`, 'i')
|
|
56
|
+
for (const field of fields) {
|
|
57
|
+
const value = (report as unknown as Record<string, unknown>)[field]
|
|
58
|
+
if (typeof value !== 'string') continue
|
|
59
|
+
if (packagePattern.test(value) && ecosystemPattern.test(value)) return true
|
|
60
|
+
}
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeRegExp(value: string): string {
|
|
65
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The deterministic dependency fast path (strix `_check_dependency_duplicate`).
|
|
70
|
+
* @param candidateIdentity - the candidate's identity.
|
|
71
|
+
* @param candidateMetadata - the candidate's dependency metadata (manifest comparison).
|
|
72
|
+
* @param existing - current reports.
|
|
73
|
+
* @returns a verdict, or null to defer to the LLM judge.
|
|
74
|
+
*/
|
|
75
|
+
export function checkDependencyDuplicate(
|
|
76
|
+
candidateIdentity: DependencyIdentity,
|
|
77
|
+
candidateMetadata: Record<string, unknown> | undefined,
|
|
78
|
+
existing: readonly VulnerabilityReport[],
|
|
79
|
+
): DuplicateVerdict | null {
|
|
80
|
+
let sawLegacySameCve = false
|
|
81
|
+
for (const report of existing) {
|
|
82
|
+
const metadata = (report as unknown as Record<string, unknown>)['dependency_metadata']
|
|
83
|
+
const identity = dependencyIdentity(metadata)
|
|
84
|
+
if (identity === null) {
|
|
85
|
+
// Legacy report: CVE match + prose mention of the package.
|
|
86
|
+
if (String((report as unknown as Record<string, unknown>)['cve'] ?? '').toUpperCase() === candidateIdentity.cve) {
|
|
87
|
+
sawLegacySameCve = true
|
|
88
|
+
if (legacyReportMentionsPackage(report, candidateIdentity)) {
|
|
89
|
+
return { isDuplicate: true, duplicateId: report.id, confidence: 1.0, reason: 'Same dependency CVE/package identity (legacy report)' }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (identity.cve !== candidateIdentity.cve || identity.packageName !== candidateIdentity.packageName) continue
|
|
95
|
+
const existingMetadata = (report as unknown as Record<string, unknown>)['dependency_metadata'] as Record<string, unknown>
|
|
96
|
+
if (distinctManifestPaths(candidateMetadata?.['manifest_path'], existingMetadata['manifest_path'])) continue
|
|
97
|
+
if (identity.ecosystem === candidateIdentity.ecosystem) {
|
|
98
|
+
return { isDuplicate: true, duplicateId: report.id, confidence: 1.0, reason: 'Same dependency CVE/package identity' }
|
|
99
|
+
}
|
|
100
|
+
return { isDuplicate: true, duplicateId: report.id, confidence: 1.0, reason: 'Same dependency CVE/package identity with missing ecosystem' }
|
|
101
|
+
}
|
|
102
|
+
if (sawLegacySameCve) return null
|
|
103
|
+
return { isDuplicate: false, duplicateId: '', confidence: 1.0, reason: `No existing dependency report for ${candidateIdentity.cve} in ${candidateIdentity.ecosystem}/${candidateIdentity.packageName}` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Entry point (strix `check_duplicate`): fast path for dependency
|
|
108
|
+
* candidates, then the injected judge; every judge failure is NOT duplicate.
|
|
109
|
+
* @param candidate - the candidate fields sent for comparison.
|
|
110
|
+
* @param candidateMetadata - dependency metadata when present.
|
|
111
|
+
* @param existing - current reports.
|
|
112
|
+
* @param judge - the injected LLM judge (absent → not duplicate).
|
|
113
|
+
*/
|
|
114
|
+
export async function checkDuplicate(
|
|
115
|
+
candidate: Record<string, unknown>,
|
|
116
|
+
candidateMetadata: Record<string, unknown> | undefined,
|
|
117
|
+
existing: readonly VulnerabilityReport[],
|
|
118
|
+
judge: DedupeJudge | undefined,
|
|
119
|
+
): Promise<DuplicateVerdict> {
|
|
120
|
+
if (existing.length === 0) {
|
|
121
|
+
return { isDuplicate: false, duplicateId: '', confidence: 1.0, reason: 'No existing reports to compare against' }
|
|
122
|
+
}
|
|
123
|
+
const identity = dependencyIdentity(candidateMetadata)
|
|
124
|
+
if (identity !== null) {
|
|
125
|
+
const fastPath = checkDependencyDuplicate(identity, candidateMetadata, existing)
|
|
126
|
+
if (fastPath !== null) return fastPath
|
|
127
|
+
}
|
|
128
|
+
if (judge === undefined) {
|
|
129
|
+
return { isDuplicate: false, duplicateId: '', confidence: 0.0, reason: 'No dedupe judge is configured; defaulting to not duplicate' }
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return await judge(candidate, existing)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { isDuplicate: false, duplicateId: '', confidence: 0.0, reason: `Deduplication check failed: ${String(error instanceof Error ? error.message : error)}` }
|
|
135
|
+
}
|
|
136
|
+
}
|