@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/lib/index.js
ADDED
|
@@ -0,0 +1,2789 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
//#region src/cvss.ts
|
|
7
|
+
/**
|
|
8
|
+
* CVSS v3.1 base-score math, ported from strix's usage of the `cvss`
|
|
9
|
+
* package (tool.py `_calculate_cvss` :134-153): build the vector string,
|
|
10
|
+
* compute the base score and qualitative severity. The score uses the
|
|
11
|
+
* CVSS 3.1 spec formula with the spec's Roundup1 (ceil to one decimal with
|
|
12
|
+
* the IEEE-754 guard), and a "none" base severity is remapped to "info"
|
|
13
|
+
* (strix parity).
|
|
14
|
+
* @module @gpzhang2001/sharpkit-reporting/cvss
|
|
15
|
+
*/
|
|
16
|
+
/** The eight CVSS metrics with their allowed values (strix `_CVSS_VALID`). */
|
|
17
|
+
const CVSS_VALID = {
|
|
18
|
+
attack_vector: [
|
|
19
|
+
"N",
|
|
20
|
+
"A",
|
|
21
|
+
"L",
|
|
22
|
+
"P"
|
|
23
|
+
],
|
|
24
|
+
attack_complexity: ["L", "H"],
|
|
25
|
+
privileges_required: [
|
|
26
|
+
"N",
|
|
27
|
+
"L",
|
|
28
|
+
"H"
|
|
29
|
+
],
|
|
30
|
+
user_interaction: ["N", "R"],
|
|
31
|
+
scope: ["U", "C"],
|
|
32
|
+
confidentiality: [
|
|
33
|
+
"N",
|
|
34
|
+
"L",
|
|
35
|
+
"H"
|
|
36
|
+
],
|
|
37
|
+
integrity: [
|
|
38
|
+
"N",
|
|
39
|
+
"L",
|
|
40
|
+
"H"
|
|
41
|
+
],
|
|
42
|
+
availability: [
|
|
43
|
+
"N",
|
|
44
|
+
"L",
|
|
45
|
+
"H"
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
/** The eight metrics in vector order (strix vector build order). */
|
|
49
|
+
const METRIC_ORDER = [
|
|
50
|
+
"attack_vector",
|
|
51
|
+
"attack_complexity",
|
|
52
|
+
"privileges_required",
|
|
53
|
+
"user_interaction",
|
|
54
|
+
"scope",
|
|
55
|
+
"confidentiality",
|
|
56
|
+
"integrity",
|
|
57
|
+
"availability"
|
|
58
|
+
];
|
|
59
|
+
/** Weight tables (CVSS v3.1 spec §3.1). */
|
|
60
|
+
const AV = {
|
|
61
|
+
N: .85,
|
|
62
|
+
A: .62,
|
|
63
|
+
L: .55,
|
|
64
|
+
P: .2
|
|
65
|
+
};
|
|
66
|
+
const AC = {
|
|
67
|
+
L: .77,
|
|
68
|
+
H: .44
|
|
69
|
+
};
|
|
70
|
+
const PR_UNCHANGED = {
|
|
71
|
+
N: .85,
|
|
72
|
+
L: .62,
|
|
73
|
+
H: .27
|
|
74
|
+
};
|
|
75
|
+
const PR_CHANGED = {
|
|
76
|
+
N: .85,
|
|
77
|
+
L: .68,
|
|
78
|
+
H: .5
|
|
79
|
+
};
|
|
80
|
+
const UI = {
|
|
81
|
+
N: .85,
|
|
82
|
+
R: .62
|
|
83
|
+
};
|
|
84
|
+
const CIA = {
|
|
85
|
+
H: .56,
|
|
86
|
+
L: .22,
|
|
87
|
+
N: 0
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* CVSS 3.1 Roundup1: smallest one-decimal value >= the input (the `cvss`
|
|
91
|
+
* package quantizes with Decimal ROUND_CEILING; the 5-decimal pre-round
|
|
92
|
+
* absorbs float artifacts the same way the spec intends).
|
|
93
|
+
*/
|
|
94
|
+
function roundup(value) {
|
|
95
|
+
const quantized = Number(value.toFixed(5));
|
|
96
|
+
return Math.ceil(Number((quantized * 10).toFixed(6))) / 10;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate a breakdown against the allowed metric/value sets.
|
|
100
|
+
* @param breakdown - the model-supplied 8-metric dict.
|
|
101
|
+
* @returns validation errors, empty when valid.
|
|
102
|
+
*/
|
|
103
|
+
function validateCvssBreakdown(breakdown) {
|
|
104
|
+
const errors = [];
|
|
105
|
+
if (typeof breakdown !== "object" || breakdown === null || Object.keys(breakdown).length === 0) return ["cvss_breakdown must be a non-empty object with all 8 metrics"];
|
|
106
|
+
for (const metric of METRIC_ORDER) {
|
|
107
|
+
const value = breakdown[metric];
|
|
108
|
+
const allowed = CVSS_VALID[metric];
|
|
109
|
+
if (typeof value !== "string" || !allowed.includes(value)) errors.push(`Invalid ${metric}: ${String(value)}. Must be one of: [${allowed.join(", ")}]`);
|
|
110
|
+
}
|
|
111
|
+
return errors;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build the vector string (strix format: CVSS:3.1/AV:../AC:../PR:../UI:../S:../C:../I:../A:..).
|
|
115
|
+
* @param breakdown - a validated breakdown.
|
|
116
|
+
*/
|
|
117
|
+
function buildCvssVector(breakdown) {
|
|
118
|
+
return `CVSS:3.1/${METRIC_ORDER.map((metric) => {
|
|
119
|
+
return `${{
|
|
120
|
+
attack_vector: "AV",
|
|
121
|
+
attack_complexity: "AC",
|
|
122
|
+
privileges_required: "PR",
|
|
123
|
+
user_interaction: "UI",
|
|
124
|
+
scope: "S",
|
|
125
|
+
confidentiality: "C",
|
|
126
|
+
integrity: "I",
|
|
127
|
+
availability: "A"
|
|
128
|
+
}[metric]}:${breakdown[metric]}`;
|
|
129
|
+
}).join("/")}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compute the CVSS v3.1 base score for a breakdown (the `cvss` package's
|
|
133
|
+
* `compute_base_score`: Scope U ISC = 6.42×ISCBase; Scope C ISC = the 7.52
|
|
134
|
+
* formula; Scope C multiplies the total by 1.08 before the cap).
|
|
135
|
+
* @param breakdown - a validated breakdown.
|
|
136
|
+
* @returns the rounded base score (0.0 when impact is non-positive).
|
|
137
|
+
*/
|
|
138
|
+
function cvssBaseScore(breakdown) {
|
|
139
|
+
const scopeChanged = breakdown.scope === "C";
|
|
140
|
+
const c = CIA[breakdown.confidentiality] ?? 0;
|
|
141
|
+
const i = CIA[breakdown.integrity] ?? 0;
|
|
142
|
+
const a = CIA[breakdown.availability] ?? 0;
|
|
143
|
+
const iscBase = 1 - (1 - c) * (1 - i) * (1 - a);
|
|
144
|
+
const isc = scopeChanged ? 7.52 * (iscBase - .029) - 3.25 * (iscBase - .02) ** 15 : 6.42 * iscBase;
|
|
145
|
+
if (isc <= 0) return 0;
|
|
146
|
+
const pr = (scopeChanged ? PR_CHANGED : PR_UNCHANGED)[breakdown.privileges_required] ?? 0;
|
|
147
|
+
const exploitability = 8.22 * (AV[breakdown.attack_vector] ?? 0) * (AC[breakdown.attack_complexity] ?? 0) * pr * (UI[breakdown.user_interaction] ?? 0);
|
|
148
|
+
return roundup(scopeChanged ? Math.min(1.08 * (isc + exploitability), 10) : Math.min(isc + exploitability, 10));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Qualitative severity banding (the `cvss` package's rating bands).
|
|
152
|
+
* @param score - the base score.
|
|
153
|
+
*/
|
|
154
|
+
function cvssSeverity(score) {
|
|
155
|
+
if (score === 0) return "none";
|
|
156
|
+
if (score <= 3.9) return "low";
|
|
157
|
+
if (score <= 6.9) return "medium";
|
|
158
|
+
if (score <= 8.9) return "high";
|
|
159
|
+
return "critical";
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Full computation: vector + score + severity with the "none"→"info" remap
|
|
163
|
+
* (strix `_calculate_cvss` return contract).
|
|
164
|
+
* @param breakdown - a validated breakdown.
|
|
165
|
+
*/
|
|
166
|
+
function calculateCvss(breakdown) {
|
|
167
|
+
const vector = buildCvssVector(breakdown);
|
|
168
|
+
const score = cvssBaseScore(breakdown);
|
|
169
|
+
const severity = cvssSeverity(score);
|
|
170
|
+
return {
|
|
171
|
+
vector,
|
|
172
|
+
score,
|
|
173
|
+
severity: severity === "none" ? "info" : severity
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Dependency severity banding from an advisory score (strix
|
|
178
|
+
* `_DEP_SEVERITY_FROM_CVSS` :1363-1378, top band inclusive at 10.0).
|
|
179
|
+
* @param score - the advisory base score.
|
|
180
|
+
*/
|
|
181
|
+
function dependencySeverity(score) {
|
|
182
|
+
if (score === null || score === void 0 || Number.isNaN(score)) return "info";
|
|
183
|
+
const clamped = Math.min(10, Math.max(0, score));
|
|
184
|
+
if (clamped >= 9) return "critical";
|
|
185
|
+
if (clamped >= 7) return "high";
|
|
186
|
+
if (clamped >= 4) return "medium";
|
|
187
|
+
if (clamped >= 0) return "low";
|
|
188
|
+
return "none";
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/dedupe.ts
|
|
192
|
+
/** Extract the dependency identity from metadata (strix :162-177). */
|
|
193
|
+
function dependencyIdentity(metadata) {
|
|
194
|
+
if (typeof metadata !== "object" || metadata === null) return null;
|
|
195
|
+
const record = metadata;
|
|
196
|
+
const cve = record["cve"];
|
|
197
|
+
const packageName = record["package_name"];
|
|
198
|
+
const ecosystem = record["package_ecosystem"];
|
|
199
|
+
if (typeof cve !== "string" || cve === "" || typeof packageName !== "string" || packageName === "" || typeof ecosystem !== "string" || ecosystem === "") return null;
|
|
200
|
+
return {
|
|
201
|
+
cve: cve.toUpperCase(),
|
|
202
|
+
packageName: packageName.toLowerCase(),
|
|
203
|
+
ecosystem: ecosystem.toLowerCase()
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Whether two manifest paths are both present AND different (strix `_distinct_manifest_paths`). */
|
|
207
|
+
function distinctManifestPaths(a, b) {
|
|
208
|
+
const first = typeof a === "string" && a !== "" ? a : null;
|
|
209
|
+
const second = typeof b === "string" && b !== "" ? b : null;
|
|
210
|
+
return first !== null && second !== null && first !== second;
|
|
211
|
+
}
|
|
212
|
+
/** Word-bounded regex mention check over prose fields (strix `_legacy_report_mentions_package`). */
|
|
213
|
+
function legacyReportMentionsPackage(report, identity) {
|
|
214
|
+
const fields = [
|
|
215
|
+
"title",
|
|
216
|
+
"description",
|
|
217
|
+
"impact",
|
|
218
|
+
"target",
|
|
219
|
+
"technical_analysis",
|
|
220
|
+
"poc_description",
|
|
221
|
+
"evidence"
|
|
222
|
+
];
|
|
223
|
+
const packagePattern = new RegExp(`(?<![\\w@./-])${escapeRegExp(identity.packageName)}(?![\\w@./-])`, "i");
|
|
224
|
+
const ecosystemPattern = new RegExp(`(?<![\\w@./-])${escapeRegExp(identity.ecosystem)}(?![\\w@./-])`, "i");
|
|
225
|
+
for (const field of fields) {
|
|
226
|
+
const value = report[field];
|
|
227
|
+
if (typeof value !== "string") continue;
|
|
228
|
+
if (packagePattern.test(value) && ecosystemPattern.test(value)) return true;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
function escapeRegExp(value) {
|
|
233
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The deterministic dependency fast path (strix `_check_dependency_duplicate`).
|
|
237
|
+
* @param candidateIdentity - the candidate's identity.
|
|
238
|
+
* @param candidateMetadata - the candidate's dependency metadata (manifest comparison).
|
|
239
|
+
* @param existing - current reports.
|
|
240
|
+
* @returns a verdict, or null to defer to the LLM judge.
|
|
241
|
+
*/
|
|
242
|
+
function checkDependencyDuplicate(candidateIdentity, candidateMetadata, existing) {
|
|
243
|
+
let sawLegacySameCve = false;
|
|
244
|
+
for (const report of existing) {
|
|
245
|
+
const metadata = report["dependency_metadata"];
|
|
246
|
+
const identity = dependencyIdentity(metadata);
|
|
247
|
+
if (identity === null) {
|
|
248
|
+
if (String(report["cve"] ?? "").toUpperCase() === candidateIdentity.cve) {
|
|
249
|
+
sawLegacySameCve = true;
|
|
250
|
+
if (legacyReportMentionsPackage(report, candidateIdentity)) return {
|
|
251
|
+
isDuplicate: true,
|
|
252
|
+
duplicateId: report.id,
|
|
253
|
+
confidence: 1,
|
|
254
|
+
reason: "Same dependency CVE/package identity (legacy report)"
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (identity.cve !== candidateIdentity.cve || identity.packageName !== candidateIdentity.packageName) continue;
|
|
260
|
+
const existingMetadata = report["dependency_metadata"];
|
|
261
|
+
if (distinctManifestPaths(candidateMetadata?.["manifest_path"], existingMetadata["manifest_path"])) continue;
|
|
262
|
+
if (identity.ecosystem === candidateIdentity.ecosystem) return {
|
|
263
|
+
isDuplicate: true,
|
|
264
|
+
duplicateId: report.id,
|
|
265
|
+
confidence: 1,
|
|
266
|
+
reason: "Same dependency CVE/package identity"
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
isDuplicate: true,
|
|
270
|
+
duplicateId: report.id,
|
|
271
|
+
confidence: 1,
|
|
272
|
+
reason: "Same dependency CVE/package identity with missing ecosystem"
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (sawLegacySameCve) return null;
|
|
276
|
+
return {
|
|
277
|
+
isDuplicate: false,
|
|
278
|
+
duplicateId: "",
|
|
279
|
+
confidence: 1,
|
|
280
|
+
reason: `No existing dependency report for ${candidateIdentity.cve} in ${candidateIdentity.ecosystem}/${candidateIdentity.packageName}`
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Entry point (strix `check_duplicate`): fast path for dependency
|
|
285
|
+
* candidates, then the injected judge; every judge failure is NOT duplicate.
|
|
286
|
+
* @param candidate - the candidate fields sent for comparison.
|
|
287
|
+
* @param candidateMetadata - dependency metadata when present.
|
|
288
|
+
* @param existing - current reports.
|
|
289
|
+
* @param judge - the injected LLM judge (absent → not duplicate).
|
|
290
|
+
*/
|
|
291
|
+
async function checkDuplicate(candidate, candidateMetadata, existing, judge) {
|
|
292
|
+
if (existing.length === 0) return {
|
|
293
|
+
isDuplicate: false,
|
|
294
|
+
duplicateId: "",
|
|
295
|
+
confidence: 1,
|
|
296
|
+
reason: "No existing reports to compare against"
|
|
297
|
+
};
|
|
298
|
+
const identity = dependencyIdentity(candidateMetadata);
|
|
299
|
+
if (identity !== null) {
|
|
300
|
+
const fastPath = checkDependencyDuplicate(identity, candidateMetadata, existing);
|
|
301
|
+
if (fastPath !== null) return fastPath;
|
|
302
|
+
}
|
|
303
|
+
if (judge === void 0) return {
|
|
304
|
+
isDuplicate: false,
|
|
305
|
+
duplicateId: "",
|
|
306
|
+
confidence: 0,
|
|
307
|
+
reason: "No dedupe judge is configured; defaulting to not duplicate"
|
|
308
|
+
};
|
|
309
|
+
try {
|
|
310
|
+
return await judge(candidate, existing);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return {
|
|
313
|
+
isDuplicate: false,
|
|
314
|
+
duplicateId: "",
|
|
315
|
+
confidence: 0,
|
|
316
|
+
reason: `Deduplication check failed: ${String(error instanceof Error ? error.message : error)}`
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/state.ts
|
|
322
|
+
/** strix display timestamp format (state.py :349). */
|
|
323
|
+
function formatTimestamp(date) {
|
|
324
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
325
|
+
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
|
|
326
|
+
}
|
|
327
|
+
/** strix ISO instant format (start/end times). */
|
|
328
|
+
function formatIso(date) {
|
|
329
|
+
return date.toISOString().replace("Z", "+00:00");
|
|
330
|
+
}
|
|
331
|
+
/** Control-char → space + whitespace collapse (state.py `_clean_title`). */
|
|
332
|
+
function cleanTitle(title) {
|
|
333
|
+
return title.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
334
|
+
}
|
|
335
|
+
/** strix severity order (tool.py `_SEVERITY_ORDER`). */
|
|
336
|
+
const SEVERITY_ORDER = [
|
|
337
|
+
"critical",
|
|
338
|
+
"high",
|
|
339
|
+
"medium",
|
|
340
|
+
"low",
|
|
341
|
+
"info",
|
|
342
|
+
"none"
|
|
343
|
+
];
|
|
344
|
+
/** Severity rank with unknown → last (writer parity). */
|
|
345
|
+
function severityRank(severity) {
|
|
346
|
+
const index = SEVERITY_ORDER.indexOf(severity);
|
|
347
|
+
return index === -1 ? SEVERITY_ORDER.length : index;
|
|
348
|
+
}
|
|
349
|
+
/** Field insertion order for optional string fields (state.py :352-396). */
|
|
350
|
+
const OPTIONAL_STRING_FIELDS = [
|
|
351
|
+
"description",
|
|
352
|
+
"impact",
|
|
353
|
+
"target",
|
|
354
|
+
"technical_analysis",
|
|
355
|
+
"poc_description",
|
|
356
|
+
"poc_script_code",
|
|
357
|
+
"remediation_steps",
|
|
358
|
+
"evidence",
|
|
359
|
+
"assumptions",
|
|
360
|
+
"counterevidence",
|
|
361
|
+
"confidence_rationale",
|
|
362
|
+
"severity_change_conditions",
|
|
363
|
+
"fix_verification",
|
|
364
|
+
"fix_pr_body",
|
|
365
|
+
"endpoint",
|
|
366
|
+
"method",
|
|
367
|
+
"cve"
|
|
368
|
+
];
|
|
369
|
+
/** Lowercased optional fields (state.py `_LOWERCASE_REPORT_FIELDS` subset). */
|
|
370
|
+
const LOWERCASE_FIELDS = /* @__PURE__ */ new Set(["confidence", "fix_effort"]);
|
|
371
|
+
/** Updatable field whitelist (state.py `UPDATABLE_REPORT_FIELDS`). */
|
|
372
|
+
const UPDATABLE_REPORT_FIELDS = /* @__PURE__ */ new Set([
|
|
373
|
+
"title",
|
|
374
|
+
"dependency_metadata",
|
|
375
|
+
"severity",
|
|
376
|
+
"description",
|
|
377
|
+
"impact",
|
|
378
|
+
"target",
|
|
379
|
+
"technical_analysis",
|
|
380
|
+
"poc_description",
|
|
381
|
+
"poc_script_code",
|
|
382
|
+
"remediation_steps",
|
|
383
|
+
"evidence",
|
|
384
|
+
"assumptions",
|
|
385
|
+
"counterevidence",
|
|
386
|
+
"confidence",
|
|
387
|
+
"confidence_rationale",
|
|
388
|
+
"severity_change_conditions",
|
|
389
|
+
"fix_effort",
|
|
390
|
+
"cvss",
|
|
391
|
+
"cvss_breakdown",
|
|
392
|
+
"endpoint",
|
|
393
|
+
"method",
|
|
394
|
+
"cve",
|
|
395
|
+
"cwe",
|
|
396
|
+
"code_locations",
|
|
397
|
+
"fix_verification",
|
|
398
|
+
"fix_pr_body"
|
|
399
|
+
]);
|
|
400
|
+
/** Dependent fields dropped when their primary changes without replacement. */
|
|
401
|
+
const DEPENDENT_REPORT_FIELDS = {
|
|
402
|
+
confidence: "confidence_rationale",
|
|
403
|
+
severity: "severity_change_conditions",
|
|
404
|
+
cvss: "cvss_breakdown",
|
|
405
|
+
code_locations: "fix_verification"
|
|
406
|
+
};
|
|
407
|
+
/**
|
|
408
|
+
* One scan's report store. Not a process singleton — the suite runs one per
|
|
409
|
+
* scan id inside the tool package's closure (strix's module-global maps to a
|
|
410
|
+
* per-scan instance in dsh).
|
|
411
|
+
*/
|
|
412
|
+
var ReportState = class {
|
|
413
|
+
runId;
|
|
414
|
+
runName;
|
|
415
|
+
startTime;
|
|
416
|
+
endTime = null;
|
|
417
|
+
status = "running";
|
|
418
|
+
finalScanResult = null;
|
|
419
|
+
vulnerabilityReports = [];
|
|
420
|
+
/** Ids already rendered to markdown (incremental writer input). */
|
|
421
|
+
savedVulnIds = /* @__PURE__ */ new Set();
|
|
422
|
+
updateHistoryAgent;
|
|
423
|
+
clock;
|
|
424
|
+
constructor(options = {}) {
|
|
425
|
+
this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
426
|
+
this.runId = options.runId ?? `run-${Math.random().toString(16).slice(2, 10)}`;
|
|
427
|
+
this.runName = options.runName ?? null;
|
|
428
|
+
this.startTime = formatIso(this.clock());
|
|
429
|
+
}
|
|
430
|
+
/** Allocate the next sequential id (state.py :343 — length-derived). */
|
|
431
|
+
nextId() {
|
|
432
|
+
return `vuln-${String(this.vulnerabilityReports.length + 1).padStart(4, "0")}`;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Add one report with strix's exact field construction order.
|
|
436
|
+
* @param input - the validated/normalized create payload.
|
|
437
|
+
*/
|
|
438
|
+
addVulnerabilityReport(input) {
|
|
439
|
+
const report = {
|
|
440
|
+
id: this.nextId(),
|
|
441
|
+
title: cleanTitle(input.title),
|
|
442
|
+
severity: input.severity.toLowerCase().trim(),
|
|
443
|
+
timestamp: formatTimestamp(this.clock())
|
|
444
|
+
};
|
|
445
|
+
for (const field of OPTIONAL_STRING_FIELDS) {
|
|
446
|
+
const value = input.fields[field];
|
|
447
|
+
if (typeof value === "string" && value.trim() !== "") report[field] = value.trim();
|
|
448
|
+
}
|
|
449
|
+
const confidence = input.fields["confidence"];
|
|
450
|
+
if (typeof confidence === "string" && confidence.trim() !== "") report["confidence"] = confidence.trim().toLowerCase();
|
|
451
|
+
const fixEffort = input.fields["fix_effort"];
|
|
452
|
+
if (typeof fixEffort === "string" && fixEffort.trim() !== "") report["fix_effort"] = fixEffort.trim().toLowerCase();
|
|
453
|
+
const cvss = input.fields["cvss"];
|
|
454
|
+
if (cvss !== null && cvss !== void 0) report["cvss"] = cvss;
|
|
455
|
+
const breakdown = input.fields["cvss_breakdown"];
|
|
456
|
+
if (breakdown !== null && breakdown !== void 0 && Object.keys(breakdown).length > 0) report["cvss_breakdown"] = breakdown;
|
|
457
|
+
const cwe = input.fields["cwe"];
|
|
458
|
+
if (typeof cwe === "string" && cwe.trim() !== "") report["cwe"] = cwe.trim();
|
|
459
|
+
const codeLocations = input.fields["code_locations"];
|
|
460
|
+
if (codeLocations !== null && codeLocations !== void 0 && codeLocations.length > 0) report["code_locations"] = codeLocations;
|
|
461
|
+
report["finding_class"] = (input.findingClass ?? "dynamic").toLowerCase().trim();
|
|
462
|
+
if (input.dependencyMetadata !== void 0 && Object.keys(input.dependencyMetadata).length > 0) report["dependency_metadata"] = input.dependencyMetadata;
|
|
463
|
+
if (input.agentId !== void 0 && input.agentId !== "") report["agent_id"] = input.agentId;
|
|
464
|
+
if (input.agentName !== void 0 && input.agentName !== "") report["agent_name"] = input.agentName;
|
|
465
|
+
const frozen = report;
|
|
466
|
+
this.vulnerabilityReports.push(frozen);
|
|
467
|
+
return frozen;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Revise one report in place (state.py `update_vulnerability_report`):
|
|
471
|
+
* whitelist + strip/lowercase, unchanged-skip, dependent-field dropping,
|
|
472
|
+
* history append, and the MD invalidation marker.
|
|
473
|
+
* @param reportId - the `vuln-NNNN` id.
|
|
474
|
+
* @param changes - only the fields the model passed.
|
|
475
|
+
* @param reason - the update_reason (truncated to 500).
|
|
476
|
+
*/
|
|
477
|
+
updateVulnerabilityReport(reportId, changes, reason) {
|
|
478
|
+
const report = this.vulnerabilityReports.find((entry) => entry.id === reportId);
|
|
479
|
+
if (report === void 0) return { noop: true };
|
|
480
|
+
const mutable = report;
|
|
481
|
+
const changed = [];
|
|
482
|
+
const dropped = [];
|
|
483
|
+
const history = {
|
|
484
|
+
timestamp: formatTimestamp(this.clock()),
|
|
485
|
+
fields: [],
|
|
486
|
+
reason: reason.slice(0, 500),
|
|
487
|
+
...this.updateHistoryAgent?.agentId !== void 0 ? { agent_id: this.updateHistoryAgent.agentId } : {},
|
|
488
|
+
...this.updateHistoryAgent?.agentName !== void 0 ? { agent_name: this.updateHistoryAgent.agentName } : {}
|
|
489
|
+
};
|
|
490
|
+
const previous = {};
|
|
491
|
+
for (const [field, rawValue] of Object.entries(changes)) {
|
|
492
|
+
if (!UPDATABLE_REPORT_FIELDS.has(field)) continue;
|
|
493
|
+
let value = rawValue;
|
|
494
|
+
if (field === "title" && typeof value === "string") value = cleanTitle(value);
|
|
495
|
+
else if (typeof value === "string") value = value.trim();
|
|
496
|
+
if (LOWERCASE_FIELDS.has(field) && typeof value === "string") value = value.toLowerCase();
|
|
497
|
+
if (Object.is(mutable[field], value)) continue;
|
|
498
|
+
if (JSON.stringify(mutable[field]) === JSON.stringify(value)) continue;
|
|
499
|
+
if (mutable[field] !== void 0) {
|
|
500
|
+
if (field === "severity") previous.severity = mutable["severity"];
|
|
501
|
+
if (field === "cvss") previous.cvss = mutable["cvss"];
|
|
502
|
+
if (field === "confidence") previous.confidence = mutable["confidence"];
|
|
503
|
+
}
|
|
504
|
+
const dependent = DEPENDENT_REPORT_FIELDS[field];
|
|
505
|
+
if (dependent !== void 0 && mutable[dependent] !== void 0 && changes[dependent] === void 0) {
|
|
506
|
+
delete mutable[dependent];
|
|
507
|
+
dropped.push(dependent);
|
|
508
|
+
}
|
|
509
|
+
mutable[field] = value;
|
|
510
|
+
changed.push(field);
|
|
511
|
+
}
|
|
512
|
+
if (changed.length === 0 && dropped.length === 0) return { noop: true };
|
|
513
|
+
history.fields = [...changed].sort();
|
|
514
|
+
if (dropped.length > 0) history.dropped_fields = [...dropped].sort();
|
|
515
|
+
if (previous.severity !== void 0) history.previous_severity = previous.severity;
|
|
516
|
+
if (previous.cvss !== void 0) history.previous_cvss = previous.cvss;
|
|
517
|
+
if (previous.confidence !== void 0) history.previous_confidence = previous.confidence;
|
|
518
|
+
const historyList = mutable["update_history"] ?? [];
|
|
519
|
+
historyList.push(history);
|
|
520
|
+
mutable["update_history"] = historyList;
|
|
521
|
+
mutable["updated_at"] = history.timestamp;
|
|
522
|
+
this.savedVulnIds.delete(reportId);
|
|
523
|
+
return { report };
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Reload from a previous run's vulnerabilities.json so id allocation does
|
|
527
|
+
* not collide (state.py `hydrate_from_run_dir`; raises on corrupt JSON).
|
|
528
|
+
* @param reports - the parsed JSON array.
|
|
529
|
+
*/
|
|
530
|
+
hydrate(reports) {
|
|
531
|
+
if (!Array.isArray(reports)) throw new Error("corrupt vulnerabilities.json: expected a list of reports");
|
|
532
|
+
for (const entry of reports) {
|
|
533
|
+
const report = entry;
|
|
534
|
+
if (report["finding_class"] === void 0) report["finding_class"] = report["dependency_metadata"] !== void 0 ? "dependency_cve" : "dynamic";
|
|
535
|
+
if (typeof report["title"] === "string") report["title"] = cleanTitle(report["title"]);
|
|
536
|
+
this.vulnerabilityReports.push(report);
|
|
537
|
+
if (typeof report["id"] === "string") this.savedVulnIds.add(report["id"]);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Mark the run complete (status transition + end time). */
|
|
541
|
+
complete(exitStatus = "completed") {
|
|
542
|
+
this.endTime = formatIso(this.clock());
|
|
543
|
+
this.status = exitStatus;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/writers.ts
|
|
548
|
+
/**
|
|
549
|
+
* Artifact writers — port of strix report/writer.py: atomic writes
|
|
550
|
+
* (temp-in-same-dir + rename, no fsync), run.json, vulnerabilities.csv
|
|
551
|
+
* (formula-injection guard, \r\n terminators, uppercase severity,
|
|
552
|
+
* severity-then-timestamp ordering), vulnerabilities.json (byte-identical
|
|
553
|
+
* serialization), the vuln-NNNN.md renderer (exact section order), and the
|
|
554
|
+
* executive report template.
|
|
555
|
+
* @module @gpzhang2001/sharpkit-reporting/writers
|
|
556
|
+
*/
|
|
557
|
+
/** JSON.stringify with Python `json.dumps(ensure_ascii=False, indent=2)` parity. */
|
|
558
|
+
function dumpsIndent(value) {
|
|
559
|
+
return JSON.stringify(value, null, 2);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Atomic text write (writer.py `atomic_write_text` :201-220): temp file in
|
|
563
|
+
* the target's directory + rename; no fsync (strix parity).
|
|
564
|
+
* @param path - target file path.
|
|
565
|
+
* @param payload - exact bytes to write.
|
|
566
|
+
*/
|
|
567
|
+
async function atomicWriteText(path, payload) {
|
|
568
|
+
await mkdir(dirname(path), { recursive: true });
|
|
569
|
+
const temp = `${dirname(path)}/.${join("", basenameOf(path))}.${process.pid}.tmp`;
|
|
570
|
+
await writeFile(temp, payload, "utf8");
|
|
571
|
+
await rename(temp, path);
|
|
572
|
+
}
|
|
573
|
+
function basenameOf(path) {
|
|
574
|
+
const index = path.lastIndexOf("/");
|
|
575
|
+
return index === -1 ? path : path.slice(index + 1);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Formula-injection guard (writer.py `csv_safe` :35-53): prefix `'` when the
|
|
579
|
+
* rendered cell starts with `= + - @ \t \r`.
|
|
580
|
+
* @param value - the cell value.
|
|
581
|
+
*/
|
|
582
|
+
function csvSafe(value) {
|
|
583
|
+
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
584
|
+
}
|
|
585
|
+
/** CSV columns (writer.py :165-184). */
|
|
586
|
+
const CSV_COLUMNS = [
|
|
587
|
+
"id",
|
|
588
|
+
"title",
|
|
589
|
+
"severity",
|
|
590
|
+
"timestamp",
|
|
591
|
+
"file"
|
|
592
|
+
];
|
|
593
|
+
/** CSV-escape one cell per RFC 4180 as Python's csv module does. */
|
|
594
|
+
function csvCell(value) {
|
|
595
|
+
const safe = csvSafe(value);
|
|
596
|
+
if (safe.includes("\"") || safe.includes(",") || safe.includes("\r") || safe.includes("\n")) return `"${safe.replace(/"/g, "\"\"")}"`;
|
|
597
|
+
return safe;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Render vulnerabilities.csv: header + rows sorted by (severity rank,
|
|
601
|
+
* timestamp), uppercase severity, \r\n terminators.
|
|
602
|
+
* @param reports - the stored reports.
|
|
603
|
+
*/
|
|
604
|
+
function renderVulnerabilitiesCsv(reports) {
|
|
605
|
+
const sorted = [...reports].sort((a, b) => severityRank(String(a.severity)) - severityRank(String(b.severity)) || String(a.timestamp).localeCompare(String(b.timestamp)));
|
|
606
|
+
const lines = [CSV_COLUMNS.join(",")];
|
|
607
|
+
for (const report of sorted) {
|
|
608
|
+
const cells = [
|
|
609
|
+
csvCell(String(report.id)),
|
|
610
|
+
csvCell(String(report.title)),
|
|
611
|
+
csvCell(String(report.severity).toUpperCase()),
|
|
612
|
+
csvCell(String(report.timestamp)),
|
|
613
|
+
csvCell(`vulnerabilities/${String(report.id)}.md`)
|
|
614
|
+
];
|
|
615
|
+
lines.push(cells.join(","));
|
|
616
|
+
}
|
|
617
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
618
|
+
}
|
|
619
|
+
/** title-case one word (strix Confidence/Fix Effort display). */
|
|
620
|
+
function titleCase(value) {
|
|
621
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
622
|
+
}
|
|
623
|
+
/** Safe fence length: one longer than the longest backtick run (writer.py :56-66). */
|
|
624
|
+
function safeFence(code) {
|
|
625
|
+
let longest = 0;
|
|
626
|
+
let current = 0;
|
|
627
|
+
for (const char of code) if (char === "`") {
|
|
628
|
+
current++;
|
|
629
|
+
longest = Math.max(longest, current);
|
|
630
|
+
} else current = 0;
|
|
631
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
632
|
+
}
|
|
633
|
+
/** Unwrap an existing fence and return its language (writer.py :69-82). */
|
|
634
|
+
function parseFencedCode(code) {
|
|
635
|
+
const match = /^```([A-Za-z0-9_+-]*)\n([\s\S]*?)\n?```$/.exec(code.trim());
|
|
636
|
+
if (match === null) return {
|
|
637
|
+
language: "",
|
|
638
|
+
body: code
|
|
639
|
+
};
|
|
640
|
+
return {
|
|
641
|
+
language: match[1] ?? "",
|
|
642
|
+
body: match[2] ?? ""
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** Guess a fenced language for a PoC script (writer.py :107-116). */
|
|
646
|
+
function guessLanguageName(code) {
|
|
647
|
+
if (/^\s*(import |from |def |class |print\()/.test(code)) return "python";
|
|
648
|
+
if (/^\s*(const |let |var |function |require\()/.test(code)) return "javascript";
|
|
649
|
+
if (/^\s*(curl |GET |POST |PUT |DELETE )/.test(code)) return "bash";
|
|
650
|
+
return "python";
|
|
651
|
+
}
|
|
652
|
+
/** Metadata lines of the vuln markdown header block (writer.py :222-259 order). */
|
|
653
|
+
function renderMetadataLines(report) {
|
|
654
|
+
const lines = [
|
|
655
|
+
`**ID:** ${String(report["id"])}`,
|
|
656
|
+
`**Severity:** ${String(report["severity"]).toUpperCase()}`,
|
|
657
|
+
`**Found:** ${String(report["timestamp"])}`
|
|
658
|
+
];
|
|
659
|
+
const depMeta = report["dependency_metadata"] ?? {};
|
|
660
|
+
const cvss = report["cvss"];
|
|
661
|
+
const metadata = [
|
|
662
|
+
["Target", report["target"]],
|
|
663
|
+
["Package", depMeta["package_name"]],
|
|
664
|
+
["Ecosystem", depMeta["package_ecosystem"]],
|
|
665
|
+
["Installed Version", depMeta["installed_version"]],
|
|
666
|
+
["Fixed Version", depMeta["fixed_version"]],
|
|
667
|
+
["Introduced By", depMeta["introduced_by"]],
|
|
668
|
+
["Dependency Chain", depMeta["dependency_path"]],
|
|
669
|
+
["Endpoint", report["endpoint"]],
|
|
670
|
+
["Method", report["method"]],
|
|
671
|
+
["CVE", report["cve"]],
|
|
672
|
+
["CWE", report["cwe"]]
|
|
673
|
+
];
|
|
674
|
+
if (cvss !== null && cvss !== void 0) metadata.push(["CVSS", cvss]);
|
|
675
|
+
const advisory = depMeta["advisory_cvss"];
|
|
676
|
+
if (advisory !== null && advisory !== void 0 && advisory !== cvss) metadata.push(["Advisory CVSS", advisory]);
|
|
677
|
+
if (depMeta["contextual_cvss_vector"] !== void 0 && depMeta["contextual_cvss_vector"] !== null && depMeta["contextual_cvss_vector"] !== "") metadata.push(["Contextual CVSS Vector", depMeta["contextual_cvss_vector"]]);
|
|
678
|
+
if (report["confidence"] !== void 0 && report["confidence"] !== null && report["confidence"] !== "") metadata.push(["Confidence", titleCase(String(report["confidence"]))]);
|
|
679
|
+
if (report["fix_effort"] !== void 0 && report["fix_effort"] !== null && report["fix_effort"] !== "") metadata.push(["Fix Effort", titleCase(String(report["fix_effort"]))]);
|
|
680
|
+
for (const [label, value] of metadata) if (value !== null && value !== void 0 && value !== "") lines.push(`**${label}:** ${String(value)}`);
|
|
681
|
+
return lines;
|
|
682
|
+
}
|
|
683
|
+
/** One code-location section (writer.py :315-342, 2-space indents verbatim). */
|
|
684
|
+
function renderCodeLocation(location, index) {
|
|
685
|
+
const lines = ["## Code Analysis", ""];
|
|
686
|
+
const file = String(location["file"] ?? "unknown");
|
|
687
|
+
const start = location["start_line"];
|
|
688
|
+
const end = location["end_line"];
|
|
689
|
+
let lineLabel = "";
|
|
690
|
+
if (start !== null && start !== void 0) lineLabel = end !== void 0 && end !== null && end !== start ? ` (lines ${String(start)}-${String(end)})` : ` (line ${String(start)})`;
|
|
691
|
+
lines.push(`**Location ${String(index + 1)}:** \`${file}\`${lineLabel}`);
|
|
692
|
+
const label = location["label"];
|
|
693
|
+
if (typeof label === "string" && label !== "") lines.push(` ${label}`);
|
|
694
|
+
const snippet = location["snippet"];
|
|
695
|
+
if (typeof snippet === "string" && snippet !== "") {
|
|
696
|
+
const fence = safeFence(snippet);
|
|
697
|
+
lines.push(` ${fence}`);
|
|
698
|
+
for (const line of snippet.split("\n")) lines.push(` ${line}`);
|
|
699
|
+
lines.push(` ${fence}`);
|
|
700
|
+
}
|
|
701
|
+
const fixBefore = location["fix_before"];
|
|
702
|
+
const fixAfter = location["fix_after"];
|
|
703
|
+
if (typeof fixBefore === "string" && fixBefore !== "" || typeof fixAfter === "string" && fixAfter !== "") {
|
|
704
|
+
lines.push("");
|
|
705
|
+
lines.push(" **Suggested Fix:**");
|
|
706
|
+
lines.push("```diff");
|
|
707
|
+
if (typeof fixBefore === "string" && fixBefore !== "") for (const line of fixBefore.split("\n")) lines.push(`- ${line}`);
|
|
708
|
+
if (typeof fixAfter === "string" && fixAfter !== "") for (const line of fixAfter.split("\n")) lines.push(`+ ${line}`);
|
|
709
|
+
lines.push("```");
|
|
710
|
+
}
|
|
711
|
+
lines.push("");
|
|
712
|
+
return lines;
|
|
713
|
+
}
|
|
714
|
+
/** Update history section (writer.py `render_update_history` :364-396). */
|
|
715
|
+
function renderUpdateHistory(report) {
|
|
716
|
+
const history = report["update_history"];
|
|
717
|
+
if (history === void 0 || history.length === 0) return [];
|
|
718
|
+
const lines = ["## Update History", ""];
|
|
719
|
+
for (const entry of history) {
|
|
720
|
+
const who = entry["agent_name"] ?? entry["agent_id"] ?? "an agent";
|
|
721
|
+
const fields = entry["fields"]?.join(", ") ?? "";
|
|
722
|
+
lines.push(`**${String(entry["timestamp"])}** — ${who} updated: ${fields}`);
|
|
723
|
+
const dropped = entry["dropped_fields"];
|
|
724
|
+
if (dropped !== void 0 && dropped.length > 0) lines.push(` Dropped as superseded: ${dropped.join(", ")}`);
|
|
725
|
+
const previousSeverity = entry["previous_severity"];
|
|
726
|
+
if (typeof previousSeverity === "string") lines.push(` Previous severity: ${previousSeverity}`);
|
|
727
|
+
const previousCvss = entry["previous_cvss"];
|
|
728
|
+
if (previousCvss !== void 0 && previousCvss !== null) lines.push(` Previous CVSS: ${String(previousCvss)}`);
|
|
729
|
+
const previousConfidence = entry["previous_confidence"];
|
|
730
|
+
if (typeof previousConfidence === "string") lines.push(` Previous confidence: ${previousConfidence}`);
|
|
731
|
+
const reason = entry["reason"];
|
|
732
|
+
if (typeof reason === "string" && reason !== "") lines.push(` Reason: ${reason}`);
|
|
733
|
+
lines.push("");
|
|
734
|
+
}
|
|
735
|
+
return lines;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Render one vulnerability markdown document (writer.py
|
|
739
|
+
* `render_vulnerability_md` :223-361 section order).
|
|
740
|
+
* @param report - the stored report dict.
|
|
741
|
+
*/
|
|
742
|
+
function renderVulnerabilityMd(report) {
|
|
743
|
+
const record = report;
|
|
744
|
+
const lines = [`# ${String(record["title"])}`, ""];
|
|
745
|
+
lines.push(...renderMetadataLines(record), "");
|
|
746
|
+
const section = (heading, field) => {
|
|
747
|
+
const value = record[field];
|
|
748
|
+
if (typeof value === "string" && value !== "") lines.push(`## ${heading}`, "", value, "");
|
|
749
|
+
};
|
|
750
|
+
section("Description", "description");
|
|
751
|
+
section("Evidence", "evidence");
|
|
752
|
+
section("Impact", "impact");
|
|
753
|
+
section("Counterevidence", "counterevidence");
|
|
754
|
+
section("Confidence Rationale", "confidence_rationale");
|
|
755
|
+
section("What Would Change This Severity", "severity_change_conditions");
|
|
756
|
+
section("Technical Analysis", "technical_analysis");
|
|
757
|
+
const contextualReasoning = record["dependency_metadata"]?.["contextual_cvss_reasoning"];
|
|
758
|
+
if (typeof contextualReasoning === "string" && contextualReasoning !== "") lines.push("## Contextual CVSS", "", contextualReasoning, "");
|
|
759
|
+
const pocDescription = record["poc_description"];
|
|
760
|
+
const pocScript = record["poc_script_code"];
|
|
761
|
+
if (typeof pocDescription === "string" && pocDescription !== "" || typeof pocScript === "string" && pocScript !== "") {
|
|
762
|
+
lines.push("## Proof of Concept", "");
|
|
763
|
+
if (typeof pocDescription === "string" && pocDescription !== "") lines.push(pocDescription, "");
|
|
764
|
+
if (typeof pocScript === "string" && pocScript !== "") {
|
|
765
|
+
const fenced = parseFencedCode(pocScript);
|
|
766
|
+
const language = fenced.language !== "" ? fenced.language : guessLanguageName(fenced.body);
|
|
767
|
+
const fence = safeFence(fenced.body);
|
|
768
|
+
lines.push(`${fence}${language}`, fenced.body, fence, "");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const locations = record["code_locations"];
|
|
772
|
+
if (locations !== void 0 && locations.length > 0) for (const [index, location] of locations.entries()) lines.push(...renderCodeLocation(location, index));
|
|
773
|
+
section("Remediation", "remediation_steps");
|
|
774
|
+
section("Fix Verification", "fix_verification");
|
|
775
|
+
section("Assumptions", "assumptions");
|
|
776
|
+
lines.push(...renderUpdateHistory(record));
|
|
777
|
+
return lines.join("\n");
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Write the per-run artifacts handled outside SARIF (writer.py parity):
|
|
781
|
+
* vulnerabilities/*.md (incremental), vulnerabilities.csv,
|
|
782
|
+
* vulnerabilities.json.
|
|
783
|
+
* @param runDir - the run directory.
|
|
784
|
+
* @param reports - all stored reports.
|
|
785
|
+
* @param savedIds - ids whose MD is already on disk (updated ids must be re-rendered by the caller removing them).
|
|
786
|
+
*/
|
|
787
|
+
async function writeVulnerabilities(runDir, reports, savedIds) {
|
|
788
|
+
for (const report of reports) {
|
|
789
|
+
if (savedIds.has(report.id)) continue;
|
|
790
|
+
await atomicWriteText(join(runDir, "vulnerabilities", `${report.id}.md`), renderVulnerabilityMd(report));
|
|
791
|
+
}
|
|
792
|
+
await atomicWriteText(join(runDir, "vulnerabilities.csv"), renderVulnerabilitiesCsv(reports));
|
|
793
|
+
await atomicWriteText(join(runDir, "vulnerabilities.json"), dumpsIndent(reports));
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Write the assembled coverage document (coverage.py `write_coverage`).
|
|
797
|
+
* @param runDir - the run directory.
|
|
798
|
+
* @param document - the assembled coverage document.
|
|
799
|
+
*/
|
|
800
|
+
async function writeCoverage(runDir, document) {
|
|
801
|
+
await atomicWriteText(join(runDir, "coverage.json"), dumpsIndent(document));
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Write run.json last (state.py ordering).
|
|
805
|
+
* @param runDir - the run directory.
|
|
806
|
+
* @param runRecord - the run record dict.
|
|
807
|
+
*/
|
|
808
|
+
async function writeRunRecord(runDir, runRecord) {
|
|
809
|
+
await atomicWriteText(join(runDir, "run.json"), dumpsIndent(runRecord));
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Write the executive report (plain truncate-write, writer.py :139-145).
|
|
813
|
+
* @param runDir - the run directory.
|
|
814
|
+
* @param finalScanResult - the composed final report body.
|
|
815
|
+
* @param generatedAt - display timestamp.
|
|
816
|
+
*/
|
|
817
|
+
async function writeExecutiveReport(runDir, finalScanResult, generatedAt) {
|
|
818
|
+
await mkdir(runDir, { recursive: true });
|
|
819
|
+
await writeFile(join(runDir, "penetration_test_report.md"), `# Security Penetration Test Report\n\n**Generated:** ${generatedAt}\n\n${finalScanResult}`, "utf8");
|
|
820
|
+
}
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region src/sarif.ts
|
|
823
|
+
/**
|
|
824
|
+
* SARIF 2.1.0 writer — port of strix report/sarif.py: rule ids normalized
|
|
825
|
+
* CWE→CVE→id→slug, GitHub security-severity, STRIDE tags from the CWE map,
|
|
826
|
+
* physical + synthetic (SECURITY.md anchor) locations with endpoint logical
|
|
827
|
+
* locations, PR-suggestion fixes, deterministic partial fingerprints
|
|
828
|
+
* (sha256), and the strix-namespaced properties (PoC script body never
|
|
829
|
+
* exported). Key insertion order matches Python dict construction
|
|
830
|
+
* byte-for-byte (golden-diff locked).
|
|
831
|
+
* @module @gpzhang2001/sharpkit-reporting/sarif
|
|
832
|
+
*/
|
|
833
|
+
const SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
|
|
834
|
+
const SARIF_VERSION = "2.1.0";
|
|
835
|
+
const TOOL_NAME = "sharpkit";
|
|
836
|
+
const TOOL_INFORMATION_URI = "https://github.com/gpzhang2001/sharpkit";
|
|
837
|
+
const SYNTHETIC_LOCATION_URI = "SECURITY.md";
|
|
838
|
+
const DEFAULT_STRIDE_LEGS = ["T", "I"];
|
|
839
|
+
/** CWE → STRIDE legs (sarif.py `_CWE_TO_STRIDE`, verbatim). */
|
|
840
|
+
const CWE_TO_STRIDE = {
|
|
841
|
+
"287": ["S"],
|
|
842
|
+
"290": ["S"],
|
|
843
|
+
"294": ["S"],
|
|
844
|
+
"306": ["S", "E"],
|
|
845
|
+
"345": ["S", "T"],
|
|
846
|
+
"346": ["S"],
|
|
847
|
+
"352": ["T", "S"],
|
|
848
|
+
"384": ["S"],
|
|
849
|
+
"521": ["S"],
|
|
850
|
+
"613": ["S"],
|
|
851
|
+
"640": ["S"],
|
|
852
|
+
"259": ["S", "I"],
|
|
853
|
+
"798": ["S", "I"],
|
|
854
|
+
"1391": ["S"],
|
|
855
|
+
"20": ["T"],
|
|
856
|
+
"73": ["T", "I"],
|
|
857
|
+
"78": ["T", "E"],
|
|
858
|
+
"79": ["T", "I"],
|
|
859
|
+
"89": ["T"],
|
|
860
|
+
"91": ["T"],
|
|
861
|
+
"94": ["T", "E"],
|
|
862
|
+
"434": ["T"],
|
|
863
|
+
"502": ["T", "E"],
|
|
864
|
+
"915": ["E", "T"],
|
|
865
|
+
"918": ["T", "I"],
|
|
866
|
+
"1336": ["T", "E"],
|
|
867
|
+
"117": ["R"],
|
|
868
|
+
"223": ["R"],
|
|
869
|
+
"778": ["R"],
|
|
870
|
+
"200": ["I"],
|
|
871
|
+
"201": ["I"],
|
|
872
|
+
"209": ["I"],
|
|
873
|
+
"256": ["I"],
|
|
874
|
+
"311": ["I"],
|
|
875
|
+
"319": ["I"],
|
|
876
|
+
"327": ["I"],
|
|
877
|
+
"328": ["I"],
|
|
878
|
+
"522": ["I"],
|
|
879
|
+
"525": ["I"],
|
|
880
|
+
"532": ["I"],
|
|
881
|
+
"538": ["I"],
|
|
882
|
+
"598": ["I"],
|
|
883
|
+
"400": ["D"],
|
|
884
|
+
"770": ["D"],
|
|
885
|
+
"1333": ["D"],
|
|
886
|
+
"269": ["E"],
|
|
887
|
+
"284": ["E"],
|
|
888
|
+
"285": ["E"],
|
|
889
|
+
"639": ["E"],
|
|
890
|
+
"732": ["E"],
|
|
891
|
+
"862": ["E"],
|
|
892
|
+
"863": ["E"],
|
|
893
|
+
"1220": ["E"],
|
|
894
|
+
"22": ["T", "I"],
|
|
895
|
+
"611": ["I", "T"]
|
|
896
|
+
};
|
|
897
|
+
/** Curated vulnerability-class keywords (sarif.py `_VULN_CLASS_KEYWORDS`). */
|
|
898
|
+
const VULN_CLASS_KEYWORDS = [
|
|
899
|
+
"missing authentication",
|
|
900
|
+
"missing authorization",
|
|
901
|
+
"broken access control",
|
|
902
|
+
"incorrect authorization",
|
|
903
|
+
"default credentials",
|
|
904
|
+
"hardcoded credentials",
|
|
905
|
+
"hardcoded secret",
|
|
906
|
+
"hardcoded password",
|
|
907
|
+
"default admin",
|
|
908
|
+
"default password",
|
|
909
|
+
"session fixation",
|
|
910
|
+
"open redirect",
|
|
911
|
+
"path traversal",
|
|
912
|
+
"directory traversal",
|
|
913
|
+
"command injection",
|
|
914
|
+
"sql injection",
|
|
915
|
+
"code injection",
|
|
916
|
+
"template injection",
|
|
917
|
+
"xpath injection",
|
|
918
|
+
"ldap injection",
|
|
919
|
+
"log injection",
|
|
920
|
+
"header injection",
|
|
921
|
+
"csv injection",
|
|
922
|
+
"prompt injection",
|
|
923
|
+
"deserialization",
|
|
924
|
+
"ssrf",
|
|
925
|
+
"xss",
|
|
926
|
+
"csrf",
|
|
927
|
+
"xxe",
|
|
928
|
+
"race condition",
|
|
929
|
+
"toctou",
|
|
930
|
+
"information disclosure",
|
|
931
|
+
"insecure direct object reference",
|
|
932
|
+
"idor",
|
|
933
|
+
"bola",
|
|
934
|
+
"bfla",
|
|
935
|
+
"cross-tenant",
|
|
936
|
+
"cross-project",
|
|
937
|
+
"tenant bypass"
|
|
938
|
+
];
|
|
939
|
+
const SEVERITY_TO_LEVEL = {
|
|
940
|
+
critical: "error",
|
|
941
|
+
high: "error",
|
|
942
|
+
medium: "warning",
|
|
943
|
+
low: "note",
|
|
944
|
+
info: "note",
|
|
945
|
+
informational: "note"
|
|
946
|
+
};
|
|
947
|
+
const SEVERITY_TO_SCORE = {
|
|
948
|
+
critical: "9.5",
|
|
949
|
+
high: "8.0",
|
|
950
|
+
medium: "5.5",
|
|
951
|
+
low: "3.0",
|
|
952
|
+
info: "1.0",
|
|
953
|
+
informational: "1.0"
|
|
954
|
+
};
|
|
955
|
+
function stringValue(value) {
|
|
956
|
+
if (typeof value === "string") {
|
|
957
|
+
const stripped = value.trim();
|
|
958
|
+
return stripped === "" ? null : stripped;
|
|
959
|
+
}
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
function sha256(text) {
|
|
963
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
964
|
+
}
|
|
965
|
+
/** CWE variants (`CWE-89` / `cwe: 89` / `89`) → `CWE-89`. */
|
|
966
|
+
function normalizeCwe$1(value) {
|
|
967
|
+
const digits = value.replace(/\D/g, "");
|
|
968
|
+
return digits === "" ? null : `CWE-${digits}`;
|
|
969
|
+
}
|
|
970
|
+
/** Stable rule id: CWE → CVE → finding id → slug → sharpkit-finding. */
|
|
971
|
+
function ruleIdOf(report) {
|
|
972
|
+
const cwe = stringValue(report.cwe);
|
|
973
|
+
if (cwe !== null) {
|
|
974
|
+
const normalized = normalizeCwe$1(cwe);
|
|
975
|
+
if (normalized !== null) return normalized;
|
|
976
|
+
}
|
|
977
|
+
const cve = stringValue(report.cve);
|
|
978
|
+
if (cve !== null) return cve;
|
|
979
|
+
const id = stringValue(report.id);
|
|
980
|
+
if (id !== null) return id;
|
|
981
|
+
const title = stringValue(report.title);
|
|
982
|
+
return title === null ? "sharpkit-finding" : slugify(title);
|
|
983
|
+
}
|
|
984
|
+
/** Lowercase slug joined by dashes (sarif.py `_slugify`). */
|
|
985
|
+
function slugify(value) {
|
|
986
|
+
const slug = [...value.toLowerCase()].map((char) => /[a-z0-9]/.test(char) ? char : "-").join("").split("-").filter((part) => part !== "").join("-");
|
|
987
|
+
return slug === "" ? "sharpkit-finding" : slug;
|
|
988
|
+
}
|
|
989
|
+
/** STRIDE legs for a CWE, default legs when unmapped (every finding gets ≥1). */
|
|
990
|
+
function strideLegsForCwe(cwe) {
|
|
991
|
+
if (typeof cwe !== "string" || cwe === "") return DEFAULT_STRIDE_LEGS;
|
|
992
|
+
const digits = cwe.replace(/\D/g, "");
|
|
993
|
+
if (digits === "") return DEFAULT_STRIDE_LEGS;
|
|
994
|
+
return CWE_TO_STRIDE[digits] ?? DEFAULT_STRIDE_LEGS;
|
|
995
|
+
}
|
|
996
|
+
/** First curated keyword in the title, else the first 5 alphanumeric words. */
|
|
997
|
+
function classKeyword(title) {
|
|
998
|
+
const lower = title.toLowerCase();
|
|
999
|
+
for (const keyword of VULN_CLASS_KEYWORDS) if (lower.includes(keyword)) return keyword;
|
|
1000
|
+
return (lower.match(/[a-z0-9]+/g)?.slice(0, 5) ?? []).join(" ");
|
|
1001
|
+
}
|
|
1002
|
+
/** SARIF level mapping. */
|
|
1003
|
+
function sarifLevel(severity) {
|
|
1004
|
+
const normalized = (typeof severity === "string" ? severity : "").toLowerCase();
|
|
1005
|
+
return SEVERITY_TO_LEVEL[normalized] ?? "note";
|
|
1006
|
+
}
|
|
1007
|
+
/** GitHub security-severity: "%.1f" CVSS else the label score. */
|
|
1008
|
+
function securitySeverity(report) {
|
|
1009
|
+
if (report.cvss !== null && report.cvss !== void 0) {
|
|
1010
|
+
const score = Number(report.cvss);
|
|
1011
|
+
if (!Number.isNaN(score)) return score.toFixed(1);
|
|
1012
|
+
}
|
|
1013
|
+
const normalized = (typeof report.severity === "string" ? report.severity : "info").toLowerCase();
|
|
1014
|
+
return SEVERITY_TO_SCORE[normalized] ?? "1.0";
|
|
1015
|
+
}
|
|
1016
|
+
/** Reject unsafe SARIF artifact URIs; normalize backslashes (sarif.py `_sarif_uri`). */
|
|
1017
|
+
function sarifUri(file) {
|
|
1018
|
+
const uri = file.replace(/\\/g, "/");
|
|
1019
|
+
if (uri.startsWith("/")) return null;
|
|
1020
|
+
const first = uri.split("/")[0] ?? "";
|
|
1021
|
+
if (/^[A-Za-z]:$/.test(first)) return null;
|
|
1022
|
+
if (uri.split("/").some((part) => part === "..")) return null;
|
|
1023
|
+
return uri;
|
|
1024
|
+
}
|
|
1025
|
+
/** Help text: description + impact + remediation joined by blank lines. */
|
|
1026
|
+
function helpText(report, fallback) {
|
|
1027
|
+
const sections = [
|
|
1028
|
+
report.description,
|
|
1029
|
+
report.impact,
|
|
1030
|
+
report.remediation_steps
|
|
1031
|
+
].filter((value) => typeof value === "string" && value.trim() !== "");
|
|
1032
|
+
return sections.length > 0 ? sections.join("\n\n") : fallback;
|
|
1033
|
+
}
|
|
1034
|
+
/** Validated physical locations + dropped count (sarif.py `_build_physical_locations`). */
|
|
1035
|
+
function buildPhysicalLocations(rawLocations) {
|
|
1036
|
+
const locations = [];
|
|
1037
|
+
let dropped = 0;
|
|
1038
|
+
if (!Array.isArray(rawLocations)) return {
|
|
1039
|
+
locations,
|
|
1040
|
+
dropped
|
|
1041
|
+
};
|
|
1042
|
+
for (const raw of rawLocations) {
|
|
1043
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
1044
|
+
const location = raw;
|
|
1045
|
+
const file = stringValue(location.file);
|
|
1046
|
+
const startLine = location.start_line;
|
|
1047
|
+
if (file === null || typeof startLine !== "number" || !Number.isInteger(startLine) || startLine < 1) {
|
|
1048
|
+
dropped++;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const uri = sarifUri(file);
|
|
1052
|
+
if (uri === null) {
|
|
1053
|
+
dropped++;
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
const physical = { artifactLocation: { uri } };
|
|
1057
|
+
const region = { startLine };
|
|
1058
|
+
const endLine = location.end_line;
|
|
1059
|
+
if (typeof endLine === "number" && Number.isInteger(endLine) && endLine >= startLine) region["endLine"] = endLine;
|
|
1060
|
+
const snippet = stringValue(location.snippet);
|
|
1061
|
+
if (snippet !== null) region["snippet"] = { text: snippet };
|
|
1062
|
+
physical["region"] = region;
|
|
1063
|
+
const entry = { physicalLocation: physical };
|
|
1064
|
+
const label = stringValue(location.label);
|
|
1065
|
+
if (label !== null) entry["message"] = { text: label };
|
|
1066
|
+
locations.push(entry);
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
locations,
|
|
1070
|
+
dropped
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
/** Locations with the synthetic anchor and endpoint/resource logical entries. */
|
|
1074
|
+
function buildLocations(report) {
|
|
1075
|
+
const physical = buildPhysicalLocations(report.code_locations);
|
|
1076
|
+
const isSynthetic = physical.locations.length === 0;
|
|
1077
|
+
const locations = isSynthetic ? [{ physicalLocation: { artifactLocation: { uri: SYNTHETIC_LOCATION_URI } } }] : [...physical.locations];
|
|
1078
|
+
const endpoint = stringValue(report.endpoint);
|
|
1079
|
+
if (endpoint !== null) locations.push({ logicalLocations: [{
|
|
1080
|
+
fullyQualifiedName: endpoint,
|
|
1081
|
+
kind: "endpoint"
|
|
1082
|
+
}] });
|
|
1083
|
+
else if (isSynthetic) {
|
|
1084
|
+
const resource = stringValue(report.target) ?? stringValue(report.title);
|
|
1085
|
+
if (resource !== null) locations.push({ logicalLocations: [{
|
|
1086
|
+
fullyQualifiedName: resource,
|
|
1087
|
+
kind: "resource"
|
|
1088
|
+
}] });
|
|
1089
|
+
}
|
|
1090
|
+
return {
|
|
1091
|
+
locations,
|
|
1092
|
+
isSynthetic,
|
|
1093
|
+
dropped: physical.dropped
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
/** Deterministic per-finding fingerprint (sarif.py `_primary_fingerprint`). */
|
|
1097
|
+
function primaryFingerprint(ruleId, report, locations, isSynthetic) {
|
|
1098
|
+
let uri = "";
|
|
1099
|
+
let startLine = null;
|
|
1100
|
+
const first = locations.find((location) => typeof location === "object" && location !== null && "physicalLocation" in location);
|
|
1101
|
+
if (first?.physicalLocation !== void 0) {
|
|
1102
|
+
uri = typeof first.physicalLocation.artifactLocation?.uri === "string" ? first.physicalLocation.artifactLocation.uri : "";
|
|
1103
|
+
const line = first.physicalLocation.region?.startLine;
|
|
1104
|
+
if (typeof line === "number" && Number.isInteger(line) && line >= 1) startLine = line;
|
|
1105
|
+
}
|
|
1106
|
+
const method = stringValue(report.method) ?? "";
|
|
1107
|
+
const endpoint = stringValue(report.endpoint) ?? "";
|
|
1108
|
+
const route = method !== "" || endpoint !== "" ? `${method.toUpperCase()} ${endpoint}`.trim() : "";
|
|
1109
|
+
if (uri === "" && route === "") return null;
|
|
1110
|
+
const parts = [`rule:${ruleId}`];
|
|
1111
|
+
if (uri !== "") {
|
|
1112
|
+
parts.push(`uri:${uri}`);
|
|
1113
|
+
if (startLine !== null) parts.push(`line:${String(startLine)}`);
|
|
1114
|
+
}
|
|
1115
|
+
if (route !== "") parts.push(`route:${route}`);
|
|
1116
|
+
if (isSynthetic) {
|
|
1117
|
+
const title = stringValue(report.title);
|
|
1118
|
+
if (title !== null) parts.push(`synth_class:${classKeyword(title)}`);
|
|
1119
|
+
}
|
|
1120
|
+
return sha256(parts.join("|"));
|
|
1121
|
+
}
|
|
1122
|
+
/** File-independent class fingerprint (sarif.py `_class_fingerprint`). */
|
|
1123
|
+
function classFingerprint(ruleId, report) {
|
|
1124
|
+
const title = stringValue(report.title);
|
|
1125
|
+
if (title === null) return null;
|
|
1126
|
+
const keyword = classKeyword(title);
|
|
1127
|
+
if (keyword === "") return null;
|
|
1128
|
+
return sha256(`rule:${ruleId}|class:${keyword}`);
|
|
1129
|
+
}
|
|
1130
|
+
/** PR-suggestion fixes from fix-bearing code locations (sarif.py `_build_fixes`). */
|
|
1131
|
+
function buildFixes(report) {
|
|
1132
|
+
const artifactChanges = [];
|
|
1133
|
+
if (!Array.isArray(report.code_locations)) return null;
|
|
1134
|
+
for (const raw of report.code_locations) {
|
|
1135
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
1136
|
+
const location = raw;
|
|
1137
|
+
const file = stringValue(location["file"]);
|
|
1138
|
+
const fixBefore = stringValue(location["fix_before"]);
|
|
1139
|
+
const fixAfter = stringValue(location["fix_after"]);
|
|
1140
|
+
const startLine = location["start_line"];
|
|
1141
|
+
if (file === null || fixBefore === null || fixAfter === null) continue;
|
|
1142
|
+
if (typeof startLine !== "number" || !Number.isInteger(startLine) || startLine < 1) continue;
|
|
1143
|
+
const uri = sarifUri(file);
|
|
1144
|
+
if (uri === null) continue;
|
|
1145
|
+
const deletedRegion = { startLine };
|
|
1146
|
+
const endLine = location["end_line"];
|
|
1147
|
+
if (typeof endLine === "number" && Number.isInteger(endLine) && endLine >= startLine) deletedRegion["endLine"] = endLine;
|
|
1148
|
+
artifactChanges.push({
|
|
1149
|
+
artifactLocation: { uri },
|
|
1150
|
+
replacements: [{
|
|
1151
|
+
deletedRegion,
|
|
1152
|
+
insertedContent: { text: fixAfter }
|
|
1153
|
+
}]
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
if (artifactChanges.length === 0) return null;
|
|
1157
|
+
const fix = { artifactChanges };
|
|
1158
|
+
const remediation = stringValue(report.remediation_steps);
|
|
1159
|
+
if (remediation !== null) fix["description"] = {
|
|
1160
|
+
text: remediation,
|
|
1161
|
+
markdown: remediation
|
|
1162
|
+
};
|
|
1163
|
+
return [fix];
|
|
1164
|
+
}
|
|
1165
|
+
/** Result properties (security-severity, class hash, synthetic flag, strix tree). */
|
|
1166
|
+
function resultProperties(report, classFp, isSynthetic) {
|
|
1167
|
+
const properties = { "security-severity": securitySeverity(report) };
|
|
1168
|
+
if (classFp !== null) properties["sharpkit_vuln_class_hash"] = classFp;
|
|
1169
|
+
if (isSynthetic) properties["synthetic_location"] = true;
|
|
1170
|
+
const sharpkitProps = {};
|
|
1171
|
+
for (const key of [
|
|
1172
|
+
"id",
|
|
1173
|
+
"severity",
|
|
1174
|
+
"cvss",
|
|
1175
|
+
"timestamp",
|
|
1176
|
+
"target",
|
|
1177
|
+
"endpoint",
|
|
1178
|
+
"method",
|
|
1179
|
+
"cve",
|
|
1180
|
+
"cwe",
|
|
1181
|
+
"impact",
|
|
1182
|
+
"technical_analysis",
|
|
1183
|
+
"remediation_steps",
|
|
1184
|
+
"counterevidence",
|
|
1185
|
+
"confidence",
|
|
1186
|
+
"confidence_rationale",
|
|
1187
|
+
"severity_change_conditions",
|
|
1188
|
+
"fix_verification"
|
|
1189
|
+
]) {
|
|
1190
|
+
const value = report[key];
|
|
1191
|
+
if (value !== null && value !== void 0 && value !== "") sharpkitProps[key] = value;
|
|
1192
|
+
}
|
|
1193
|
+
const metadata = report["dependency_metadata"];
|
|
1194
|
+
if (typeof metadata === "object" && metadata !== null && Object.keys(metadata).length > 0) sharpkitProps["dependency_metadata"] = metadata;
|
|
1195
|
+
const pocDescription = stringValue(report.poc_description);
|
|
1196
|
+
const pocScript = stringValue(report.poc_script_code);
|
|
1197
|
+
if (pocDescription !== null || pocScript !== null) {
|
|
1198
|
+
const poc = {};
|
|
1199
|
+
if (pocDescription !== null) poc["description"] = pocDescription;
|
|
1200
|
+
if (pocScript !== null) poc["script_available"] = true;
|
|
1201
|
+
sharpkitProps["poc"] = poc;
|
|
1202
|
+
}
|
|
1203
|
+
if (Object.keys(sharpkitProps).length > 0) properties["sharpkit"] = sharpkitProps;
|
|
1204
|
+
return properties;
|
|
1205
|
+
}
|
|
1206
|
+
/** Build one rule descriptor (sarif.py `_build_rule` key order). */
|
|
1207
|
+
function buildRule(ruleId, report) {
|
|
1208
|
+
const title = stringValue(report.title) ?? ruleId;
|
|
1209
|
+
const fullDescription = stringValue(report.description) ?? title;
|
|
1210
|
+
const help = helpText(report, fullDescription);
|
|
1211
|
+
const rule = {
|
|
1212
|
+
id: ruleId,
|
|
1213
|
+
name: title !== "" ? title : ruleId.replace(/-/g, "_"),
|
|
1214
|
+
shortDescription: { text: title },
|
|
1215
|
+
fullDescription: { text: fullDescription },
|
|
1216
|
+
defaultConfiguration: { level: sarifLevel(report.severity) },
|
|
1217
|
+
help: {
|
|
1218
|
+
text: help,
|
|
1219
|
+
markdown: help
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
const properties = { "security-severity": securitySeverity(report) };
|
|
1223
|
+
const tags = ["security"];
|
|
1224
|
+
if (ruleId.startsWith("CWE-")) tags.push(ruleId);
|
|
1225
|
+
const cve = stringValue(report.cve);
|
|
1226
|
+
if (cve !== null && !tags.includes(cve)) tags.push(cve);
|
|
1227
|
+
for (const leg of strideLegsForCwe(report.cwe)) {
|
|
1228
|
+
const tag = `stride:${leg}`;
|
|
1229
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
1230
|
+
}
|
|
1231
|
+
properties["tags"] = tags;
|
|
1232
|
+
rule["properties"] = properties;
|
|
1233
|
+
if (ruleId.startsWith("CWE-")) rule["helpUri"] = `https://cwe.mitre.org/data/definitions/${ruleId.slice(4)}.html`;
|
|
1234
|
+
return rule;
|
|
1235
|
+
}
|
|
1236
|
+
/** Build one result (sarif.py `_build_result` key order). */
|
|
1237
|
+
function buildResult(ruleId, ruleIndex, report) {
|
|
1238
|
+
const title = stringValue(report.title) ?? ruleId;
|
|
1239
|
+
const description = stringValue(report.description);
|
|
1240
|
+
const messageText = description !== null ? `${title}\n\n${description}` : title;
|
|
1241
|
+
const { locations, isSynthetic, dropped } = buildLocations(report);
|
|
1242
|
+
const result = {
|
|
1243
|
+
ruleId,
|
|
1244
|
+
ruleIndex,
|
|
1245
|
+
level: sarifLevel(report.severity),
|
|
1246
|
+
message: { text: messageText }
|
|
1247
|
+
};
|
|
1248
|
+
if (locations.length > 0) result["locations"] = locations;
|
|
1249
|
+
const fixes = buildFixes(report);
|
|
1250
|
+
if (fixes !== null) result["fixes"] = fixes;
|
|
1251
|
+
const fingerprint = primaryFingerprint(ruleId, report, locations, isSynthetic);
|
|
1252
|
+
if (fingerprint !== null) result["partialFingerprints"] = { primaryLocationLineHash: fingerprint };
|
|
1253
|
+
result["properties"] = resultProperties(report, classFingerprint(ruleId, report), isSynthetic);
|
|
1254
|
+
return {
|
|
1255
|
+
result,
|
|
1256
|
+
synthetic: isSynthetic,
|
|
1257
|
+
dropped
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
/** Coverage outcome → SARIF result kind (`reported` deliberately absent). */
|
|
1261
|
+
const OUTCOME_TO_KIND = {
|
|
1262
|
+
no_issue_found: "pass",
|
|
1263
|
+
ruled_out: "pass",
|
|
1264
|
+
not_applicable: "notApplicable",
|
|
1265
|
+
needs_follow_up: "open"
|
|
1266
|
+
};
|
|
1267
|
+
const OUTCOME_LABELS = {
|
|
1268
|
+
reported: "Finding reported",
|
|
1269
|
+
no_issue_found: "No issue identified",
|
|
1270
|
+
ruled_out: "Ruled out",
|
|
1271
|
+
not_applicable: "Not applicable",
|
|
1272
|
+
needs_follow_up: "Requires further review"
|
|
1273
|
+
};
|
|
1274
|
+
/**
|
|
1275
|
+
* Build the full SARIF document (top-level key order and run properties).
|
|
1276
|
+
* Coverage results and invocations join when the coverage tool lands (批 4).
|
|
1277
|
+
* @param reports - all stored reports.
|
|
1278
|
+
* @param options - tool version and optional repository provenance.
|
|
1279
|
+
*/
|
|
1280
|
+
function buildSarif(reports, options) {
|
|
1281
|
+
const rules = [];
|
|
1282
|
+
const ruleIndex = /* @__PURE__ */ new Map();
|
|
1283
|
+
const results = [];
|
|
1284
|
+
let syntheticCount = 0;
|
|
1285
|
+
const droppedFindings = [];
|
|
1286
|
+
let droppedLocationCount = 0;
|
|
1287
|
+
for (const report of reports) {
|
|
1288
|
+
const id = ruleIdOf(report);
|
|
1289
|
+
let index = ruleIndex.get(id);
|
|
1290
|
+
if (index === void 0) {
|
|
1291
|
+
index = rules.length;
|
|
1292
|
+
ruleIndex.set(id, index);
|
|
1293
|
+
rules.push(buildRule(id, report));
|
|
1294
|
+
}
|
|
1295
|
+
const { result, synthetic, dropped } = buildResult(id, index, report);
|
|
1296
|
+
if (synthetic) syntheticCount++;
|
|
1297
|
+
if (dropped > 0) {
|
|
1298
|
+
droppedLocationCount += dropped;
|
|
1299
|
+
droppedFindings.push({
|
|
1300
|
+
droppedLocationCount: dropped,
|
|
1301
|
+
id: report.id,
|
|
1302
|
+
title: report.title
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
results.push(result);
|
|
1306
|
+
}
|
|
1307
|
+
const run = {
|
|
1308
|
+
tool: { driver: {
|
|
1309
|
+
name: TOOL_NAME,
|
|
1310
|
+
informationUri: TOOL_INFORMATION_URI,
|
|
1311
|
+
rules,
|
|
1312
|
+
version: options.toolVersion
|
|
1313
|
+
} },
|
|
1314
|
+
results
|
|
1315
|
+
};
|
|
1316
|
+
if (options.coverage !== void 0) appendCoverage(run, options.coverage, ruleIndex, rules);
|
|
1317
|
+
const runProperties = {};
|
|
1318
|
+
if (syntheticCount > 0) runProperties["syntheticLocationCount"] = syntheticCount;
|
|
1319
|
+
if (droppedLocationCount > 0) {
|
|
1320
|
+
runProperties["droppedUnsafeLocationCount"] = droppedLocationCount;
|
|
1321
|
+
runProperties["droppedUnsafeLocationFindings"] = droppedFindings;
|
|
1322
|
+
}
|
|
1323
|
+
const repo = options.repositoryContext;
|
|
1324
|
+
if (repo !== void 0) {
|
|
1325
|
+
const provenance = {};
|
|
1326
|
+
if (repo.repositoryUri !== void 0) provenance["repositoryUri"] = repo.repositoryUri;
|
|
1327
|
+
if (repo.commitSha !== void 0) provenance["revisionId"] = repo.commitSha;
|
|
1328
|
+
if (repo.branch !== void 0) provenance["branch"] = repo.branch;
|
|
1329
|
+
if (Object.keys(provenance).length > 0) run["versionControlProvenance"] = [provenance];
|
|
1330
|
+
if (repo.repositoryFullName !== void 0) runProperties["repository"] = repo.repositoryFullName;
|
|
1331
|
+
if (repo.ref !== void 0) runProperties["ref"] = repo.ref;
|
|
1332
|
+
if (repo.commitSha !== void 0) runProperties["commit_sha"] = repo.commitSha;
|
|
1333
|
+
}
|
|
1334
|
+
if (Object.keys(runProperties).length > 0) run["properties"] = runProperties;
|
|
1335
|
+
return {
|
|
1336
|
+
version: SARIF_VERSION,
|
|
1337
|
+
$schema: SARIF_SCHEMA,
|
|
1338
|
+
runs: [run]
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
/** Coverage rule + result builders and the run invocation (sarif.py :641-747). */
|
|
1342
|
+
function appendCoverage(run, coverage, ruleIndex, rules) {
|
|
1343
|
+
const coverageResults = [];
|
|
1344
|
+
for (const entry of coverage.entries) {
|
|
1345
|
+
const outcome = typeof entry.outcome === "string" ? entry.outcome : "";
|
|
1346
|
+
const kind = OUTCOME_TO_KIND[outcome];
|
|
1347
|
+
if (kind === void 0) continue;
|
|
1348
|
+
const riskArea = typeof entry.risk_area === "string" ? entry.risk_area : "";
|
|
1349
|
+
const ruleId = `sharpkit-coverage/${riskArea === "" ? "unspecified" : slugify(riskArea)}`;
|
|
1350
|
+
let index = ruleIndex.get(ruleId);
|
|
1351
|
+
if (index === void 0) {
|
|
1352
|
+
index = rules.length;
|
|
1353
|
+
ruleIndex.set(ruleId, index);
|
|
1354
|
+
const name = riskArea !== "" ? riskArea : ruleId.replaceAll("-", "_");
|
|
1355
|
+
const description = `Coverage: ${riskArea}`;
|
|
1356
|
+
rules.push({
|
|
1357
|
+
id: ruleId,
|
|
1358
|
+
name,
|
|
1359
|
+
shortDescription: { text: description },
|
|
1360
|
+
fullDescription: { text: description },
|
|
1361
|
+
defaultConfiguration: { level: "none" },
|
|
1362
|
+
help: {
|
|
1363
|
+
text: description,
|
|
1364
|
+
markdown: description
|
|
1365
|
+
},
|
|
1366
|
+
properties: { tags: ["coverage"] }
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
const label = OUTCOME_LABELS[outcome] ?? outcome;
|
|
1370
|
+
const surface = typeof entry.surface === "string" ? entry.surface : "";
|
|
1371
|
+
let messageText = `${riskArea} — ${label}: ${surface}`;
|
|
1372
|
+
const evidence = typeof entry.evidence === "string" && entry.evidence !== "" ? entry.evidence : null;
|
|
1373
|
+
if (evidence !== null) messageText += `\n\n${evidence}`;
|
|
1374
|
+
const sharpkitProps = {
|
|
1375
|
+
coverage_outcome: outcome,
|
|
1376
|
+
risk_area: riskArea,
|
|
1377
|
+
surface
|
|
1378
|
+
};
|
|
1379
|
+
if (entry.recorded_by !== void 0 && entry.recorded_by !== null) sharpkitProps["recorded_by"] = entry.recorded_by;
|
|
1380
|
+
sharpkitProps["source"] = "agent_reported";
|
|
1381
|
+
coverageResults.push({
|
|
1382
|
+
ruleId,
|
|
1383
|
+
ruleIndex: index,
|
|
1384
|
+
kind,
|
|
1385
|
+
level: "none",
|
|
1386
|
+
message: { text: messageText },
|
|
1387
|
+
locations: [{ logicalLocations: [{ fullyQualifiedName: surface }] }],
|
|
1388
|
+
properties: { sharpkit: sharpkitProps }
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
if (coverageResults.length > 0) {
|
|
1392
|
+
const existing = run["results"];
|
|
1393
|
+
run["results"] = [...Array.isArray(existing) ? existing : [], ...coverageResults];
|
|
1394
|
+
}
|
|
1395
|
+
const invocation = { executionSuccessful: coverage.completeness?.complete ?? true };
|
|
1396
|
+
const caveats = coverage.completeness?.caveats?.filter((caveat) => caveat !== "");
|
|
1397
|
+
if (caveats !== void 0 && caveats.length > 0) invocation["toolExecutionNotifications"] = caveats.map((caveat) => ({
|
|
1398
|
+
level: "warning",
|
|
1399
|
+
message: { text: caveat }
|
|
1400
|
+
}));
|
|
1401
|
+
run["invocations"] = [invocation];
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Write findings.sarif (temp sibling + rename, trailing newline; sarif.py
|
|
1405
|
+
* `write_sarif_report`). Always emitted, even with zero findings, so a fresh
|
|
1406
|
+
* empty doc overwrites stale results.
|
|
1407
|
+
* @param runDir - the run directory.
|
|
1408
|
+
* @param reports - all stored reports.
|
|
1409
|
+
* @param options - tool version and provenance.
|
|
1410
|
+
*/
|
|
1411
|
+
async function writeSarif(runDir, reports, options) {
|
|
1412
|
+
const output = join(runDir, "findings.sarif");
|
|
1413
|
+
const temp = `${output}.${process.pid}.tmp`;
|
|
1414
|
+
try {
|
|
1415
|
+
await writeFile(temp, `${dumpsIndent(buildSarif(reports, options))}\n`, "utf8");
|
|
1416
|
+
await rename(temp, output);
|
|
1417
|
+
} finally {
|
|
1418
|
+
await rm(temp, { force: true }).catch(() => {});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
//#endregion
|
|
1422
|
+
//#region src/index.ts
|
|
1423
|
+
/**
|
|
1424
|
+
* Vulnerability / dependency reporting tools — port of strix
|
|
1425
|
+
* tools/reporting/tool.py: create_vulnerability_report,
|
|
1426
|
+
* create_dependency_report, update_vulnerability_report, list_reports,
|
|
1427
|
+
* get_report. Validation parity (runtime-validated value sets, CVSS
|
|
1428
|
+
* computation, cross-class update guards), strix's exact response JSON
|
|
1429
|
+
* shapes, and the artifacts fan-out on every mutation (md/csv/json/sarif +
|
|
1430
|
+
* run.json last, atomic writes; SARIF always emitted even when empty).
|
|
1431
|
+
* The dedupe LLM judge is a Config callback (deterministic dependency fast
|
|
1432
|
+
* path runs regardless; judge failures default to not-duplicate).
|
|
1433
|
+
* @module @gpzhang2001/sharpkit-reporting
|
|
1434
|
+
*/
|
|
1435
|
+
const name = "pentest-tool-reporting";
|
|
1436
|
+
const inject = ["tools"];
|
|
1437
|
+
const Config = z.object({
|
|
1438
|
+
runName: z.string(),
|
|
1439
|
+
runsRoot: z.string().default("sharpkit_runs"),
|
|
1440
|
+
toolVersion: z.string().default("0.1.0"),
|
|
1441
|
+
scanMode: z.string().default("quick"),
|
|
1442
|
+
targetsInfo: z.array(z.object({})),
|
|
1443
|
+
strictCwe: z.boolean().default(true),
|
|
1444
|
+
authMode: z.string().default("none"),
|
|
1445
|
+
instruction: z.string(),
|
|
1446
|
+
diffScope: z.string(),
|
|
1447
|
+
nonInteractive: z.boolean(),
|
|
1448
|
+
localSources: z.array(z.object({})),
|
|
1449
|
+
scopeMode: z.string().default("auto"),
|
|
1450
|
+
diffBase: z.string(),
|
|
1451
|
+
mcpConnections: z.array(z.string())
|
|
1452
|
+
});
|
|
1453
|
+
/** strix-clean an optional string: literal null-words and empties → undefined. */
|
|
1454
|
+
function cleanOptional(value) {
|
|
1455
|
+
if (value === void 0) return void 0;
|
|
1456
|
+
const trimmed = value.trim();
|
|
1457
|
+
if (trimmed === "" || /^(null|none|nil|undefined)$/i.test(trimmed)) return void 0;
|
|
1458
|
+
return trimmed;
|
|
1459
|
+
}
|
|
1460
|
+
/** CVSS metric enum value sets (schema-level, matching strix runtime validation). */
|
|
1461
|
+
const CVSS_METRIC_SCHEMAS = {
|
|
1462
|
+
attack_vector: {
|
|
1463
|
+
type: "string",
|
|
1464
|
+
required: true,
|
|
1465
|
+
enum: [
|
|
1466
|
+
"N",
|
|
1467
|
+
"A",
|
|
1468
|
+
"L",
|
|
1469
|
+
"P"
|
|
1470
|
+
]
|
|
1471
|
+
},
|
|
1472
|
+
attack_complexity: {
|
|
1473
|
+
type: "string",
|
|
1474
|
+
required: true,
|
|
1475
|
+
enum: ["L", "H"]
|
|
1476
|
+
},
|
|
1477
|
+
privileges_required: {
|
|
1478
|
+
type: "string",
|
|
1479
|
+
required: true,
|
|
1480
|
+
enum: [
|
|
1481
|
+
"N",
|
|
1482
|
+
"L",
|
|
1483
|
+
"H"
|
|
1484
|
+
]
|
|
1485
|
+
},
|
|
1486
|
+
user_interaction: {
|
|
1487
|
+
type: "string",
|
|
1488
|
+
required: true,
|
|
1489
|
+
enum: ["N", "R"]
|
|
1490
|
+
},
|
|
1491
|
+
scope: {
|
|
1492
|
+
type: "string",
|
|
1493
|
+
required: true,
|
|
1494
|
+
enum: ["U", "C"]
|
|
1495
|
+
},
|
|
1496
|
+
confidentiality: {
|
|
1497
|
+
type: "string",
|
|
1498
|
+
required: true,
|
|
1499
|
+
enum: [
|
|
1500
|
+
"N",
|
|
1501
|
+
"L",
|
|
1502
|
+
"H"
|
|
1503
|
+
]
|
|
1504
|
+
},
|
|
1505
|
+
integrity: {
|
|
1506
|
+
type: "string",
|
|
1507
|
+
required: true,
|
|
1508
|
+
enum: [
|
|
1509
|
+
"N",
|
|
1510
|
+
"L",
|
|
1511
|
+
"H"
|
|
1512
|
+
]
|
|
1513
|
+
},
|
|
1514
|
+
availability: {
|
|
1515
|
+
type: "string",
|
|
1516
|
+
required: true,
|
|
1517
|
+
enum: [
|
|
1518
|
+
"N",
|
|
1519
|
+
"L",
|
|
1520
|
+
"H"
|
|
1521
|
+
]
|
|
1522
|
+
}
|
|
1523
|
+
};
|
|
1524
|
+
const CODE_LOCATION_SCHEMA = {
|
|
1525
|
+
type: "object",
|
|
1526
|
+
properties: {
|
|
1527
|
+
file: {
|
|
1528
|
+
type: "string",
|
|
1529
|
+
required: true
|
|
1530
|
+
},
|
|
1531
|
+
start_line: {
|
|
1532
|
+
type: "integer",
|
|
1533
|
+
required: true
|
|
1534
|
+
},
|
|
1535
|
+
end_line: {
|
|
1536
|
+
type: "integer",
|
|
1537
|
+
required: true
|
|
1538
|
+
},
|
|
1539
|
+
snippet: { type: "string" },
|
|
1540
|
+
label: { type: "string" },
|
|
1541
|
+
fix_before: { type: "string" },
|
|
1542
|
+
fix_after: { type: "string" }
|
|
1543
|
+
},
|
|
1544
|
+
additionalProperties: false
|
|
1545
|
+
};
|
|
1546
|
+
/** Normalize + validate code_locations (tool.py `_normalize_code_locations`). */
|
|
1547
|
+
function normalizeCodeLocations(raw) {
|
|
1548
|
+
if (!Array.isArray(raw) || raw.length === 0) return { errors: [] };
|
|
1549
|
+
const errors = [];
|
|
1550
|
+
const locations = [];
|
|
1551
|
+
for (const [index, item] of raw.entries()) {
|
|
1552
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1553
|
+
const entry = item;
|
|
1554
|
+
const normalized = {};
|
|
1555
|
+
const file = entry["file"];
|
|
1556
|
+
if (typeof file === "string" && file !== "") normalized["file"] = file.trim();
|
|
1557
|
+
const startLine = entry["start_line"];
|
|
1558
|
+
if (typeof startLine === "number" && Number.isInteger(startLine)) normalized["start_line"] = startLine;
|
|
1559
|
+
else if (typeof startLine === "string" && startLine !== "" && Number.isInteger(Number(startLine))) normalized["start_line"] = Number(startLine);
|
|
1560
|
+
const endLine = entry["end_line"];
|
|
1561
|
+
if (typeof endLine === "number" && Number.isInteger(endLine)) normalized["end_line"] = endLine;
|
|
1562
|
+
else if (typeof endLine === "string" && endLine !== "" && Number.isInteger(Number(endLine))) normalized["end_line"] = Number(endLine);
|
|
1563
|
+
for (const field of [
|
|
1564
|
+
"snippet",
|
|
1565
|
+
"fix_before",
|
|
1566
|
+
"fix_after"
|
|
1567
|
+
]) {
|
|
1568
|
+
const value = entry[field];
|
|
1569
|
+
if (typeof value === "string" && value.trim() !== "") normalized[field] = value.replace(/^\n+|\n+$/g, "");
|
|
1570
|
+
}
|
|
1571
|
+
for (const field of ["label"]) {
|
|
1572
|
+
const value = entry[field];
|
|
1573
|
+
if (typeof value === "string" && value.trim() !== "") normalized[field] = value.trim();
|
|
1574
|
+
}
|
|
1575
|
+
if (normalized["file"] === void 0 || normalized["start_line"] === void 0) continue;
|
|
1576
|
+
if (typeof normalized["file"] === "string" && normalized["file"].startsWith("/")) errors.push(`code_locations[${String(index)}]: file must be a repo-relative path (no leading '/')`);
|
|
1577
|
+
if (typeof normalized["start_line"] !== "number" || normalized["start_line"] < 1) errors.push(`code_locations[${String(index)}]: start_line must be an integer >= 1`);
|
|
1578
|
+
if (normalized["end_line"] === void 0) errors.push(`code_locations[${String(index)}]: end_line is required`);
|
|
1579
|
+
else {
|
|
1580
|
+
const start = normalized["start_line"];
|
|
1581
|
+
const end = normalized["end_line"];
|
|
1582
|
+
if (typeof end !== "number" || end < 1) errors.push(`code_locations[${String(index)}]: end_line must be an integer >= 1`);
|
|
1583
|
+
else if (end < start) errors.push(`code_locations[${String(index)}]: end_line (${String(end)}) must be >= start_line (${String(start)})`);
|
|
1584
|
+
}
|
|
1585
|
+
locations.push(normalized);
|
|
1586
|
+
}
|
|
1587
|
+
return {
|
|
1588
|
+
...locations.length > 0 ? { locations } : {},
|
|
1589
|
+
errors
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
/** CVE normalization (tool.py `_extract_cve` + `_validate_cve`). */
|
|
1593
|
+
function normalizeCve(value) {
|
|
1594
|
+
const cleaned = cleanOptional(value);
|
|
1595
|
+
if (cleaned === void 0) return void 0;
|
|
1596
|
+
const match = /CVE-\d{4}-\d{4,}/.exec(cleaned);
|
|
1597
|
+
if (match === null) return void 0;
|
|
1598
|
+
return match[0];
|
|
1599
|
+
}
|
|
1600
|
+
/** CWE normalization (tool.py `_extract_cwe`). */
|
|
1601
|
+
function normalizeCwe(value) {
|
|
1602
|
+
const cleaned = cleanOptional(value);
|
|
1603
|
+
if (cleaned === void 0) return void 0;
|
|
1604
|
+
const match = /CWE-\d+/.exec(cleaned);
|
|
1605
|
+
if (match === null) return void 0;
|
|
1606
|
+
return match[0];
|
|
1607
|
+
}
|
|
1608
|
+
/** Dependency metadata builder (tool.py `_build_dependency_metadata` order). */
|
|
1609
|
+
function buildDependencyMetadata(fields) {
|
|
1610
|
+
const metadata = {
|
|
1611
|
+
package_name: fields.packageName,
|
|
1612
|
+
installed_version: fields.installedVersion
|
|
1613
|
+
};
|
|
1614
|
+
if (fields.advisoryCvss !== void 0) metadata["advisory_cvss"] = fields.advisoryCvss;
|
|
1615
|
+
metadata["package_ecosystem"] = fields.packageEcosystem;
|
|
1616
|
+
metadata["manifest_path"] = fields.manifestPath;
|
|
1617
|
+
if (fields.fixedVersion !== void 0) metadata["fixed_version"] = fields.fixedVersion;
|
|
1618
|
+
if (fields.introducedBy !== void 0) metadata["introduced_by"] = fields.introducedBy;
|
|
1619
|
+
if (fields.dependencyPath !== void 0) metadata["dependency_path"] = fields.dependencyPath;
|
|
1620
|
+
metadata["reachability"] = fields.reachability;
|
|
1621
|
+
if (fields.reachabilityEvidence !== void 0) metadata["reachability_evidence"] = fields.reachabilityEvidence;
|
|
1622
|
+
if (fields.contextual !== void 0) {
|
|
1623
|
+
metadata["contextual_cvss_breakdown"] = fields.contextual.breakdown;
|
|
1624
|
+
metadata["contextual_cvss_score"] = fields.contextual.score;
|
|
1625
|
+
metadata["contextual_cvss_vector"] = fields.contextual.vector;
|
|
1626
|
+
metadata["contextual_cvss_reasoning"] = fields.contextual.reasoning.slice(0, 2e3);
|
|
1627
|
+
}
|
|
1628
|
+
return metadata;
|
|
1629
|
+
}
|
|
1630
|
+
/** Valid severities (strix `_VALID_SEVERITIES`). */
|
|
1631
|
+
const VALID_SEVERITIES = /* @__PURE__ */ new Set([
|
|
1632
|
+
"critical",
|
|
1633
|
+
"high",
|
|
1634
|
+
"medium",
|
|
1635
|
+
"low",
|
|
1636
|
+
"info",
|
|
1637
|
+
"none"
|
|
1638
|
+
]);
|
|
1639
|
+
const VALID_FIX_EFFORT = /* @__PURE__ */ new Set([
|
|
1640
|
+
"trivial",
|
|
1641
|
+
"low",
|
|
1642
|
+
"medium",
|
|
1643
|
+
"high"
|
|
1644
|
+
]);
|
|
1645
|
+
const VALID_CONFIDENCE = /* @__PURE__ */ new Set([
|
|
1646
|
+
"high",
|
|
1647
|
+
"medium",
|
|
1648
|
+
"low"
|
|
1649
|
+
]);
|
|
1650
|
+
const VALID_FINDING_CLASSES = /* @__PURE__ */ new Set(["dynamic", "dependency_cve"]);
|
|
1651
|
+
const VALID_REACHABILITY = /* @__PURE__ */ new Set([
|
|
1652
|
+
"not_imported",
|
|
1653
|
+
"imported",
|
|
1654
|
+
"vulnerable_symbol_used",
|
|
1655
|
+
"reachable_call_path",
|
|
1656
|
+
"unknown"
|
|
1657
|
+
]);
|
|
1658
|
+
/** Broad CWEs strix's guidance forbids (tool.py docstring; enforced lightly). */
|
|
1659
|
+
const BROADCWES = /* @__PURE__ */ new Set([
|
|
1660
|
+
"CWE-74",
|
|
1661
|
+
"CWE-20",
|
|
1662
|
+
"CWE-200",
|
|
1663
|
+
"CWE-284",
|
|
1664
|
+
"CWE-693"
|
|
1665
|
+
]);
|
|
1666
|
+
/** Fields only a dynamic finding may carry on update (strix `_DYNAMIC_ONLY_UPDATE_FIELDS` inverse). */
|
|
1667
|
+
const DEPENDENCY_ONLY_UPDATE_FIELDS = /* @__PURE__ */ new Set(["contextual_cvss_reasoning"]);
|
|
1668
|
+
const DYNAMIC_ONLY_UPDATE_FIELDS = /* @__PURE__ */ new Set([
|
|
1669
|
+
"endpoint",
|
|
1670
|
+
"method",
|
|
1671
|
+
"poc_description",
|
|
1672
|
+
"poc_script_code"
|
|
1673
|
+
]);
|
|
1674
|
+
function apply(ctx, config = {}) {
|
|
1675
|
+
const runsRoot = config.runsRoot ?? "sharpkit_runs";
|
|
1676
|
+
const runName = config.runName ?? `pentest-${Math.random().toString(16).slice(2, 6)}`;
|
|
1677
|
+
const runDir = resolve(join(resolve(runsRoot), runName));
|
|
1678
|
+
const state = new ReportState({ runName });
|
|
1679
|
+
const toolVersion = config.toolVersion ?? "0.1.0";
|
|
1680
|
+
/** scan_results block, set by finishScan and appended to run.json. */
|
|
1681
|
+
let scanResults;
|
|
1682
|
+
const usageLedger = {
|
|
1683
|
+
requests: 0,
|
|
1684
|
+
inputTokens: 0,
|
|
1685
|
+
outputTokens: 0,
|
|
1686
|
+
totalTokens: 0
|
|
1687
|
+
};
|
|
1688
|
+
ctx.on("session/event", (_session, event) => {
|
|
1689
|
+
const record = event;
|
|
1690
|
+
if (record.type !== "assistant/message") return;
|
|
1691
|
+
const usage = record.data?.usage;
|
|
1692
|
+
if (usage === void 0 || usage === null) return;
|
|
1693
|
+
usageLedger.requests += 1;
|
|
1694
|
+
usageLedger.inputTokens += usage.inputTokens ?? 0;
|
|
1695
|
+
usageLedger.outputTokens += usage.outputTokens ?? 0;
|
|
1696
|
+
usageLedger.totalTokens += usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
1697
|
+
});
|
|
1698
|
+
const llmUsageRecord = () => ({
|
|
1699
|
+
requests: usageLedger.requests,
|
|
1700
|
+
input_tokens: usageLedger.inputTokens,
|
|
1701
|
+
output_tokens: usageLedger.outputTokens,
|
|
1702
|
+
total_tokens: usageLedger.totalTokens,
|
|
1703
|
+
cost: null,
|
|
1704
|
+
agents: []
|
|
1705
|
+
});
|
|
1706
|
+
const ensureRunDir = async () => {
|
|
1707
|
+
await mkdir(runDir, { recursive: true });
|
|
1708
|
+
return runDir;
|
|
1709
|
+
};
|
|
1710
|
+
/** Resolve the coverage source: Config hook first, else the analysis package's service. */
|
|
1711
|
+
const coverageSource = () => {
|
|
1712
|
+
if (config.coverageSource !== void 0) return config.coverageSource;
|
|
1713
|
+
const analysis = ctx.get("pentestAnalysis");
|
|
1714
|
+
return analysis === void 0 ? void 0 : {
|
|
1715
|
+
entries: () => analysis.coverageEntries(),
|
|
1716
|
+
outcomeCounts: () => analysis.outcomeCounts()
|
|
1717
|
+
};
|
|
1718
|
+
};
|
|
1719
|
+
/** Assemble the coverage document (state.py `_coverage_document`, minus agent-graph gaps). */
|
|
1720
|
+
const coverageDocument = () => {
|
|
1721
|
+
const source = coverageSource();
|
|
1722
|
+
const entries = source?.entries() ?? [];
|
|
1723
|
+
const outcomes = source?.outcomeCounts() ?? {};
|
|
1724
|
+
return {
|
|
1725
|
+
schema_version: 1,
|
|
1726
|
+
generated_at: formatTimestamp(/* @__PURE__ */ new Date()),
|
|
1727
|
+
run_id: state.runId,
|
|
1728
|
+
run_name: state.runName,
|
|
1729
|
+
scope: {
|
|
1730
|
+
targets: config.targetsInfo ?? [],
|
|
1731
|
+
scan_mode: config.scanMode ?? null,
|
|
1732
|
+
scope_mode: null,
|
|
1733
|
+
diff_scope: null,
|
|
1734
|
+
instruction: ""
|
|
1735
|
+
},
|
|
1736
|
+
summary: {
|
|
1737
|
+
surfaces_reviewed: entries.length,
|
|
1738
|
+
outcomes,
|
|
1739
|
+
findings_filed: state.vulnerabilityReports.length,
|
|
1740
|
+
gaps: entries.filter((entry) => entry["outcome"] === "needs_follow_up").length
|
|
1741
|
+
},
|
|
1742
|
+
completeness: {
|
|
1743
|
+
complete: state.status === "completed",
|
|
1744
|
+
scan_status: state.status,
|
|
1745
|
+
exit_reason: null,
|
|
1746
|
+
caveats: state.status === "running" ? ["scan still running"] : []
|
|
1747
|
+
},
|
|
1748
|
+
entries: entries.map((entry) => ({
|
|
1749
|
+
surface: entry["surface"],
|
|
1750
|
+
risk_area: entry["risk_area"],
|
|
1751
|
+
outcome: entry["outcome"],
|
|
1752
|
+
outcome_label: {
|
|
1753
|
+
reported: "Finding reported",
|
|
1754
|
+
no_issue_found: "No issue identified",
|
|
1755
|
+
ruled_out: "Ruled out",
|
|
1756
|
+
not_applicable: "Not applicable",
|
|
1757
|
+
needs_follow_up: "Requires further review"
|
|
1758
|
+
}[String(entry["outcome"])] ?? String(entry["outcome"]),
|
|
1759
|
+
...entry["evidence"] !== void 0 ? { evidence: entry["evidence"] } : {},
|
|
1760
|
+
recorded_by: entry["agent_name"] ?? null,
|
|
1761
|
+
recorded_at: entry["created_at"],
|
|
1762
|
+
...entry["updated_at"] !== void 0 ? { updated_at: entry["updated_at"] } : {},
|
|
1763
|
+
previous_outcomes: entry["history"]?.map((item) => item["outcome"]) ?? [],
|
|
1764
|
+
source: "agent_reported"
|
|
1765
|
+
})),
|
|
1766
|
+
gaps: entries.filter((entry) => entry["outcome"] === "needs_follow_up").map((entry) => ({
|
|
1767
|
+
kind: "needs_follow_up",
|
|
1768
|
+
surface: entry["surface"],
|
|
1769
|
+
risk_area: entry["risk_area"],
|
|
1770
|
+
detail: `'${String(entry["surface"])}' (${String(entry["risk_area"])}) still needs follow-up.`
|
|
1771
|
+
}))
|
|
1772
|
+
};
|
|
1773
|
+
};
|
|
1774
|
+
/** Full artifacts fan-out (state.py `_save_artifacts` order; run.json LAST). */
|
|
1775
|
+
const saveArtifacts = async () => {
|
|
1776
|
+
try {
|
|
1777
|
+
const dir = await ensureRunDir();
|
|
1778
|
+
const coverage = coverageDocument();
|
|
1779
|
+
await writeCoverage(dir, coverage);
|
|
1780
|
+
await writeVulnerabilities(dir, state.vulnerabilityReports, state.savedVulnIds);
|
|
1781
|
+
const sarifCoverage = coverageSource() === void 0 ? void 0 : {
|
|
1782
|
+
entries: coverage["entries"],
|
|
1783
|
+
completeness: coverage["completeness"]
|
|
1784
|
+
};
|
|
1785
|
+
await writeSarif(dir, state.vulnerabilityReports, {
|
|
1786
|
+
toolVersion,
|
|
1787
|
+
coverage: sarifCoverage
|
|
1788
|
+
});
|
|
1789
|
+
const runRecord = {
|
|
1790
|
+
run_id: state.runId,
|
|
1791
|
+
run_name: state.runName,
|
|
1792
|
+
start_time: state.startTime,
|
|
1793
|
+
end_time: state.endTime,
|
|
1794
|
+
status: state.status,
|
|
1795
|
+
auth_mode: config.authMode ?? "none",
|
|
1796
|
+
targets_info: config.targetsInfo ?? [],
|
|
1797
|
+
llm_usage: llmUsageRecord(),
|
|
1798
|
+
instruction: config.instruction ?? null,
|
|
1799
|
+
scan_mode: config.scanMode ?? "quick",
|
|
1800
|
+
diff_scope: config.diffScope ?? null,
|
|
1801
|
+
non_interactive: config.nonInteractive ?? false,
|
|
1802
|
+
local_sources: config.localSources ?? [],
|
|
1803
|
+
scope_mode: config.scopeMode ?? "auto",
|
|
1804
|
+
diff_base: config.diffBase ?? null
|
|
1805
|
+
};
|
|
1806
|
+
if (config.mcpConnections !== void 0 && config.mcpConnections.length > 0) runRecord["mcp_connections"] = config.mcpConnections;
|
|
1807
|
+
if (scanResults !== void 0) runRecord["scan_results"] = scanResults;
|
|
1808
|
+
await writeRunRecord(dir, runRecord);
|
|
1809
|
+
} catch (error) {
|
|
1810
|
+
ctx.logger.warn(`pentest-reporting: artifact save failed: ${String(error instanceof Error ? error.message : error)}`);
|
|
1811
|
+
}
|
|
1812
|
+
};
|
|
1813
|
+
/** The create-side validation + dedupe + persistence shared by both create tools. */
|
|
1814
|
+
const persistCreate = async (input, dedupeCandidate) => {
|
|
1815
|
+
const verdict = await checkDuplicate(dedupeCandidate, input.dependencyMetadata, state.vulnerabilityReports, config.dedupeJudge);
|
|
1816
|
+
if (verdict.isDuplicate) return {
|
|
1817
|
+
success: false,
|
|
1818
|
+
error: `Potential duplicate of '${state.vulnerabilityReports.find((report) => report.id === verdict.duplicateId)?.title ?? ""}' (id=${verdict.duplicateId.slice(0, 8)}...) — do not re-report the same vulnerability`,
|
|
1819
|
+
duplicate_of: verdict.duplicateId,
|
|
1820
|
+
confidence: verdict.confidence,
|
|
1821
|
+
reason: verdict.reason
|
|
1822
|
+
};
|
|
1823
|
+
try {
|
|
1824
|
+
const report = state.addVulnerabilityReport(input);
|
|
1825
|
+
await saveArtifacts();
|
|
1826
|
+
return {
|
|
1827
|
+
success: true,
|
|
1828
|
+
message: `${input.findingClass === "dependency_cve" ? "Dependency finding" : "Vulnerability report"} '${input.title}' created successfully`,
|
|
1829
|
+
report_id: report.id,
|
|
1830
|
+
severity: report.severity,
|
|
1831
|
+
...report.cvss !== void 0 ? { cvss_score: report.cvss } : {},
|
|
1832
|
+
...input.findingClass === "dependency_cve" && report.cve !== void 0 ? { cve: report.cve } : {}
|
|
1833
|
+
};
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
return {
|
|
1836
|
+
success: false,
|
|
1837
|
+
error: `Failed to create ${input.findingClass === "dependency_cve" ? "dependency" : "vulnerability"} report: ${String(error instanceof Error ? error.message : error)}`
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
ctx.tools.register(defineTool({
|
|
1842
|
+
name: "create_vulnerability_report",
|
|
1843
|
+
description: "File a vulnerability report — one report per fully-verified finding, with a working PoC. Severity and CVSS are computed from the 8-metric cvss_breakdown you provide. On a duplicate verdict, revise the existing report via update_vulnerability_report instead of retrying. Never for known-CVE dependency findings — use create_dependency_report.",
|
|
1844
|
+
parameters: {
|
|
1845
|
+
title: {
|
|
1846
|
+
type: "string",
|
|
1847
|
+
required: true,
|
|
1848
|
+
description: "Specific finding title (e.g. \"SQL Injection in /api/users login parameter\")."
|
|
1849
|
+
},
|
|
1850
|
+
description: {
|
|
1851
|
+
type: "string",
|
|
1852
|
+
required: true,
|
|
1853
|
+
description: "Concise, non-technical TL;DR (1-3 sentences)."
|
|
1854
|
+
},
|
|
1855
|
+
impact: {
|
|
1856
|
+
type: "string",
|
|
1857
|
+
required: true,
|
|
1858
|
+
description: "The unauthorized result demonstrated by the PoC and its scope."
|
|
1859
|
+
},
|
|
1860
|
+
target: {
|
|
1861
|
+
type: "string",
|
|
1862
|
+
required: true,
|
|
1863
|
+
description: "Affected URL / domain / repository."
|
|
1864
|
+
},
|
|
1865
|
+
technical_analysis: {
|
|
1866
|
+
type: "string",
|
|
1867
|
+
required: true,
|
|
1868
|
+
description: "The mechanism and root cause."
|
|
1869
|
+
},
|
|
1870
|
+
poc_description: {
|
|
1871
|
+
type: "string",
|
|
1872
|
+
required: true,
|
|
1873
|
+
description: "Step-by-step reproduction (steps only, no code)."
|
|
1874
|
+
},
|
|
1875
|
+
poc_script_code: {
|
|
1876
|
+
type: "string",
|
|
1877
|
+
required: true,
|
|
1878
|
+
description: "Working PoC (Python preferred)."
|
|
1879
|
+
},
|
|
1880
|
+
remediation_steps: {
|
|
1881
|
+
type: "string",
|
|
1882
|
+
required: true,
|
|
1883
|
+
description: "Specific, actionable fix (prose, no code)."
|
|
1884
|
+
},
|
|
1885
|
+
evidence: {
|
|
1886
|
+
type: "string",
|
|
1887
|
+
required: true,
|
|
1888
|
+
description: "Concrete proof: request/response excerpts, observed behavior, tool output."
|
|
1889
|
+
},
|
|
1890
|
+
assumptions: {
|
|
1891
|
+
type: "string",
|
|
1892
|
+
required: true,
|
|
1893
|
+
description: "Assumptions/prerequisites that make this finding impactful."
|
|
1894
|
+
},
|
|
1895
|
+
counterevidence: {
|
|
1896
|
+
type: "string",
|
|
1897
|
+
required: true,
|
|
1898
|
+
description: "REQUIRED: the strongest case against this finding, after actively looking for it."
|
|
1899
|
+
},
|
|
1900
|
+
confidence: {
|
|
1901
|
+
type: "string",
|
|
1902
|
+
required: true,
|
|
1903
|
+
enum: [
|
|
1904
|
+
"high",
|
|
1905
|
+
"medium",
|
|
1906
|
+
"low"
|
|
1907
|
+
],
|
|
1908
|
+
description: "Calibrated confidence."
|
|
1909
|
+
},
|
|
1910
|
+
confidence_rationale: {
|
|
1911
|
+
type: "string",
|
|
1912
|
+
description: "Required when confidence is not high: name the specific gap."
|
|
1913
|
+
},
|
|
1914
|
+
severity_change_conditions: {
|
|
1915
|
+
type: "string",
|
|
1916
|
+
required: true,
|
|
1917
|
+
description: "One concrete sentence on what evidence would raise or lower severity."
|
|
1918
|
+
},
|
|
1919
|
+
fix_effort: {
|
|
1920
|
+
type: "string",
|
|
1921
|
+
required: true,
|
|
1922
|
+
enum: [
|
|
1923
|
+
"trivial",
|
|
1924
|
+
"low",
|
|
1925
|
+
"medium",
|
|
1926
|
+
"high"
|
|
1927
|
+
],
|
|
1928
|
+
description: "Estimated fix effort."
|
|
1929
|
+
},
|
|
1930
|
+
cvss_breakdown: {
|
|
1931
|
+
type: "object",
|
|
1932
|
+
required: true,
|
|
1933
|
+
properties: CVSS_METRIC_SCHEMAS,
|
|
1934
|
+
additionalProperties: false,
|
|
1935
|
+
description: "All 8 CVSS v3.1 metrics; score and severity are computed from it."
|
|
1936
|
+
},
|
|
1937
|
+
endpoint: {
|
|
1938
|
+
type: "string",
|
|
1939
|
+
description: "API path / Git path (e.g. /api/login)."
|
|
1940
|
+
},
|
|
1941
|
+
method: {
|
|
1942
|
+
type: "string",
|
|
1943
|
+
description: "HTTP method when relevant."
|
|
1944
|
+
},
|
|
1945
|
+
cve: {
|
|
1946
|
+
type: "string",
|
|
1947
|
+
description: "CVE-YYYY-NNNNN if certain, else omit."
|
|
1948
|
+
},
|
|
1949
|
+
cwe: {
|
|
1950
|
+
type: "string",
|
|
1951
|
+
description: "CWE-NNN (most specific child) if certain, else omit."
|
|
1952
|
+
},
|
|
1953
|
+
code_locations: {
|
|
1954
|
+
type: "array",
|
|
1955
|
+
items: CODE_LOCATION_SCHEMA,
|
|
1956
|
+
description: "White-box findings: file/start_line/end_line/snippet/label/fix_before/fix_after."
|
|
1957
|
+
},
|
|
1958
|
+
fix_verification: {
|
|
1959
|
+
type: "string",
|
|
1960
|
+
description: "Required whenever any code_locations entry carries fix_after: the 4 ordered verification gates."
|
|
1961
|
+
},
|
|
1962
|
+
fix_pr_body: {
|
|
1963
|
+
type: "string",
|
|
1964
|
+
description: "Optional markdown PR-description body proposing the fix."
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
output: {
|
|
1968
|
+
schema: {
|
|
1969
|
+
type: "object",
|
|
1970
|
+
properties: {
|
|
1971
|
+
success: {
|
|
1972
|
+
type: "boolean",
|
|
1973
|
+
required: true
|
|
1974
|
+
},
|
|
1975
|
+
message: { type: "string" },
|
|
1976
|
+
report_id: { type: "string" },
|
|
1977
|
+
severity: { type: "string" },
|
|
1978
|
+
cvss_score: { type: "number" },
|
|
1979
|
+
error: { type: "string" },
|
|
1980
|
+
errors: {
|
|
1981
|
+
type: "array",
|
|
1982
|
+
items: { type: "string" }
|
|
1983
|
+
},
|
|
1984
|
+
duplicate_of: { type: "string" },
|
|
1985
|
+
confidence: { type: "number" },
|
|
1986
|
+
reason: { type: "string" },
|
|
1987
|
+
warning: { type: "string" }
|
|
1988
|
+
},
|
|
1989
|
+
additionalProperties: false
|
|
1990
|
+
},
|
|
1991
|
+
render: (_args, value) => {
|
|
1992
|
+
const result = value;
|
|
1993
|
+
if (!result.success) return [{
|
|
1994
|
+
type: "text",
|
|
1995
|
+
text: `create_vulnerability_report failed: ${result.error ?? "unknown"}`
|
|
1996
|
+
}];
|
|
1997
|
+
return [{
|
|
1998
|
+
type: "text",
|
|
1999
|
+
text: `filed ${result.report_id} (${String(result.severity)}, CVSS ${String(result.cvss_score)})`
|
|
2000
|
+
}];
|
|
2001
|
+
},
|
|
2002
|
+
presentationMeta: (args, value) => findingPresentationMeta(args, value)
|
|
2003
|
+
},
|
|
2004
|
+
execute: (async (rawArgs, rawExec) => {
|
|
2005
|
+
const args = rawArgs;
|
|
2006
|
+
const errors = [];
|
|
2007
|
+
const breakdown = args.cvss_breakdown;
|
|
2008
|
+
errors.push(...validateCvssBreakdown(breakdown));
|
|
2009
|
+
const confidence = args.confidence.toLowerCase();
|
|
2010
|
+
if (!VALID_CONFIDENCE.has(confidence)) errors.push(`Invalid confidence: ${args.confidence}. Must be one of: [high, low, medium]`);
|
|
2011
|
+
const fixEffort = args.fix_effort.toLowerCase();
|
|
2012
|
+
if (!VALID_FIX_EFFORT.has(fixEffort)) errors.push(`Invalid fix_effort: ${args.fix_effort}. Must be one of: [high, low, medium, trivial]`);
|
|
2013
|
+
const cve = normalizeCve(args.cve);
|
|
2014
|
+
if (args.cve !== void 0 && cve === void 0) errors.push(`Invalid cve: ${args.cve}. Must match CVE-YYYY-NNNNN`);
|
|
2015
|
+
const cwe = normalizeCwe(args.cwe);
|
|
2016
|
+
if (args.cwe !== void 0 && cwe === void 0) errors.push(`Invalid cwe: ${args.cwe}. Must match CWE-NNN`);
|
|
2017
|
+
if (cwe !== void 0 && config.strictCwe === true && BROADCWES.has(cwe)) errors.push(`${cwe} is too broad — file the most specific child CWE`);
|
|
2018
|
+
const locations = normalizeCodeLocations(args.code_locations);
|
|
2019
|
+
errors.push(...locations.errors);
|
|
2020
|
+
if ((locations.locations?.some((location) => typeof location["fix_after"] === "string" && location["fix_after"] !== "") ?? false) && cleanOptional(args.fix_verification) === void 0) errors.push("fix_verification is required when any code_locations entry carries fix_after");
|
|
2021
|
+
if (args.confidence !== "high" && cleanOptional(args.confidence_rationale) === void 0) errors.push("confidence_rationale is required when confidence is not high");
|
|
2022
|
+
if (errors.length > 0) return {
|
|
2023
|
+
success: false,
|
|
2024
|
+
error: "Validation failed",
|
|
2025
|
+
errors
|
|
2026
|
+
};
|
|
2027
|
+
let cvssScore;
|
|
2028
|
+
let severity;
|
|
2029
|
+
try {
|
|
2030
|
+
const computed = calculateCvss(breakdown);
|
|
2031
|
+
cvssScore = computed.score;
|
|
2032
|
+
severity = computed.severity;
|
|
2033
|
+
} catch (error) {
|
|
2034
|
+
return {
|
|
2035
|
+
success: false,
|
|
2036
|
+
error: "Validation failed",
|
|
2037
|
+
errors: [String(error instanceof Error ? error.message : error)]
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
const fields = {
|
|
2041
|
+
description: args.description,
|
|
2042
|
+
impact: args.impact,
|
|
2043
|
+
target: args.target,
|
|
2044
|
+
technical_analysis: args.technical_analysis,
|
|
2045
|
+
poc_description: args.poc_description,
|
|
2046
|
+
poc_script_code: args.poc_script_code,
|
|
2047
|
+
remediation_steps: args.remediation_steps,
|
|
2048
|
+
evidence: args.evidence,
|
|
2049
|
+
assumptions: args.assumptions,
|
|
2050
|
+
counterevidence: args.counterevidence,
|
|
2051
|
+
confidence,
|
|
2052
|
+
confidence_rationale: args.confidence_rationale,
|
|
2053
|
+
severity_change_conditions: args.severity_change_conditions,
|
|
2054
|
+
fix_effort: fixEffort,
|
|
2055
|
+
cvss: cvssScore,
|
|
2056
|
+
cvss_breakdown: breakdown,
|
|
2057
|
+
endpoint: args.endpoint,
|
|
2058
|
+
method: args.method,
|
|
2059
|
+
cve,
|
|
2060
|
+
cwe,
|
|
2061
|
+
code_locations: locations.locations,
|
|
2062
|
+
fix_verification: args.fix_verification,
|
|
2063
|
+
fix_pr_body: args.fix_pr_body
|
|
2064
|
+
};
|
|
2065
|
+
return persistCreate({
|
|
2066
|
+
title: args.title,
|
|
2067
|
+
severity,
|
|
2068
|
+
findingClass: "dynamic",
|
|
2069
|
+
fields
|
|
2070
|
+
}, {
|
|
2071
|
+
title: args.title,
|
|
2072
|
+
description: args.description,
|
|
2073
|
+
impact: args.impact,
|
|
2074
|
+
target: args.target,
|
|
2075
|
+
technical_analysis: args.technical_analysis,
|
|
2076
|
+
poc_description: args.poc_description,
|
|
2077
|
+
poc_script_code: args.poc_script_code,
|
|
2078
|
+
endpoint: args.endpoint,
|
|
2079
|
+
method: args.method
|
|
2080
|
+
});
|
|
2081
|
+
})
|
|
2082
|
+
}));
|
|
2083
|
+
ctx.tools.register(defineTool({
|
|
2084
|
+
name: "update_vulnerability_report",
|
|
2085
|
+
description: "Revise a vulnerability report that is already filed, keeping its id. Pass only the fields to replace; create's reporting rules apply. cvss_breakdown replaces the whole vector (score + severity recomputed). Reports keep their id, author, and filing time; the revision is recorded as update history.",
|
|
2086
|
+
parameters: {
|
|
2087
|
+
report_id: {
|
|
2088
|
+
type: "string",
|
|
2089
|
+
required: true,
|
|
2090
|
+
description: "Id of the report to revise (format vuln-NNNN)."
|
|
2091
|
+
},
|
|
2092
|
+
update_reason: {
|
|
2093
|
+
type: "string",
|
|
2094
|
+
required: true,
|
|
2095
|
+
description: "What you learned that the report does not yet carry (1-2 sentences)."
|
|
2096
|
+
},
|
|
2097
|
+
title: { type: "string" },
|
|
2098
|
+
description: { type: "string" },
|
|
2099
|
+
impact: { type: "string" },
|
|
2100
|
+
target: { type: "string" },
|
|
2101
|
+
technical_analysis: { type: "string" },
|
|
2102
|
+
poc_description: { type: "string" },
|
|
2103
|
+
poc_script_code: { type: "string" },
|
|
2104
|
+
remediation_steps: { type: "string" },
|
|
2105
|
+
evidence: { type: "string" },
|
|
2106
|
+
assumptions: { type: "string" },
|
|
2107
|
+
counterevidence: { type: "string" },
|
|
2108
|
+
confidence: {
|
|
2109
|
+
type: "string",
|
|
2110
|
+
enum: [
|
|
2111
|
+
"high",
|
|
2112
|
+
"medium",
|
|
2113
|
+
"low"
|
|
2114
|
+
]
|
|
2115
|
+
},
|
|
2116
|
+
confidence_rationale: { type: "string" },
|
|
2117
|
+
severity_change_conditions: { type: "string" },
|
|
2118
|
+
fix_effort: {
|
|
2119
|
+
type: "string",
|
|
2120
|
+
enum: [
|
|
2121
|
+
"trivial",
|
|
2122
|
+
"low",
|
|
2123
|
+
"medium",
|
|
2124
|
+
"high"
|
|
2125
|
+
]
|
|
2126
|
+
},
|
|
2127
|
+
cvss_breakdown: {
|
|
2128
|
+
type: "object",
|
|
2129
|
+
properties: CVSS_METRIC_SCHEMAS,
|
|
2130
|
+
additionalProperties: false
|
|
2131
|
+
},
|
|
2132
|
+
endpoint: { type: "string" },
|
|
2133
|
+
method: { type: "string" },
|
|
2134
|
+
cve: { type: "string" },
|
|
2135
|
+
cwe: { type: "string" },
|
|
2136
|
+
code_locations: {
|
|
2137
|
+
type: "array",
|
|
2138
|
+
items: CODE_LOCATION_SCHEMA
|
|
2139
|
+
},
|
|
2140
|
+
fix_verification: { type: "string" },
|
|
2141
|
+
fix_pr_body: { type: "string" },
|
|
2142
|
+
contextual_cvss_reasoning: {
|
|
2143
|
+
type: "string",
|
|
2144
|
+
description: "Dependency findings only: what you observed in this codebase justifying the contextual cvss_breakdown."
|
|
2145
|
+
}
|
|
2146
|
+
},
|
|
2147
|
+
output: {
|
|
2148
|
+
schema: {
|
|
2149
|
+
type: "object",
|
|
2150
|
+
properties: {
|
|
2151
|
+
success: {
|
|
2152
|
+
type: "boolean",
|
|
2153
|
+
required: true
|
|
2154
|
+
},
|
|
2155
|
+
action: { type: "string" },
|
|
2156
|
+
message: { type: "string" },
|
|
2157
|
+
report_id: { type: "string" },
|
|
2158
|
+
updated_fields: {
|
|
2159
|
+
type: "array",
|
|
2160
|
+
items: { type: "string" }
|
|
2161
|
+
},
|
|
2162
|
+
severity: { type: "string" },
|
|
2163
|
+
cvss_score: { type: "number" },
|
|
2164
|
+
error: { type: "string" },
|
|
2165
|
+
errors: {
|
|
2166
|
+
type: "array",
|
|
2167
|
+
items: { type: "string" }
|
|
2168
|
+
},
|
|
2169
|
+
finding_class: { type: "string" },
|
|
2170
|
+
rejected_fields: {
|
|
2171
|
+
type: "array",
|
|
2172
|
+
items: { type: "string" }
|
|
2173
|
+
}
|
|
2174
|
+
},
|
|
2175
|
+
additionalProperties: false
|
|
2176
|
+
},
|
|
2177
|
+
render: (_args, value) => {
|
|
2178
|
+
const result = value;
|
|
2179
|
+
if (!result.success) return [{
|
|
2180
|
+
type: "text",
|
|
2181
|
+
text: `update_vulnerability_report failed: ${result.error ?? "unknown"}`
|
|
2182
|
+
}];
|
|
2183
|
+
return [{
|
|
2184
|
+
type: "text",
|
|
2185
|
+
text: `revised ${result.report_id}`
|
|
2186
|
+
}];
|
|
2187
|
+
},
|
|
2188
|
+
presentationMeta: (args, value) => findingPresentationMeta(args, value)
|
|
2189
|
+
},
|
|
2190
|
+
execute: (async (rawArgs, rawExec) => {
|
|
2191
|
+
const args = rawArgs;
|
|
2192
|
+
const reportId = cleanOptional(args.report_id);
|
|
2193
|
+
const reason = cleanOptional(args.update_reason);
|
|
2194
|
+
if (reportId === void 0 || reason === void 0) return {
|
|
2195
|
+
success: false,
|
|
2196
|
+
error: `${reportId === void 0 ? "report_id" : "update_reason"} cannot be empty - name the report you are revising and state what you learned that it does not yet carry`
|
|
2197
|
+
};
|
|
2198
|
+
const report = state.vulnerabilityReports.find((entry) => entry.id === reportId);
|
|
2199
|
+
if (report === void 0) return {
|
|
2200
|
+
success: false,
|
|
2201
|
+
error: `Report with id '${reportId}' not found`,
|
|
2202
|
+
report_id: reportId
|
|
2203
|
+
};
|
|
2204
|
+
const changes = {};
|
|
2205
|
+
for (const [key, value] of Object.entries(args)) {
|
|
2206
|
+
if (key === "report_id" || key === "update_reason") continue;
|
|
2207
|
+
const cleaned = typeof value === "string" ? cleanOptional(value) : value;
|
|
2208
|
+
if (cleaned === void 0) continue;
|
|
2209
|
+
changes[key] = cleaned;
|
|
2210
|
+
}
|
|
2211
|
+
if (Object.keys(changes).length === 0) return {
|
|
2212
|
+
success: false,
|
|
2213
|
+
error: "No fields to update - pass at least one field you want to replace",
|
|
2214
|
+
report_id: reportId
|
|
2215
|
+
};
|
|
2216
|
+
const findingClass = typeof report.finding_class === "string" ? report.finding_class : report.dependency_metadata !== void 0 ? "dependency_cve" : "dynamic";
|
|
2217
|
+
const rejected = [];
|
|
2218
|
+
if (findingClass === "dependency_cve") {
|
|
2219
|
+
for (const field of DYNAMIC_ONLY_UPDATE_FIELDS) if (changes[field] !== void 0) rejected.push(field);
|
|
2220
|
+
} else for (const field of DEPENDENCY_ONLY_UPDATE_FIELDS) if (changes[field] !== void 0) rejected.push(field);
|
|
2221
|
+
if (rejected.length > 0) return {
|
|
2222
|
+
success: false,
|
|
2223
|
+
error: `Report '${reportId}' is a ${findingClass} finding, so it cannot carry ${rejected.join(", ")}. File your proof as its own vulnerability report instead of writing it onto this one.`,
|
|
2224
|
+
report_id: reportId,
|
|
2225
|
+
finding_class: findingClass,
|
|
2226
|
+
rejected_fields: rejected
|
|
2227
|
+
};
|
|
2228
|
+
if (changes["cvss_breakdown"] !== void 0 && findingClass === "dependency_cve") {
|
|
2229
|
+
if (changes["contextual_cvss_reasoning"] === void 0 && report.dependency_metadata === void 0) return {
|
|
2230
|
+
success: false,
|
|
2231
|
+
error: "Validation failed",
|
|
2232
|
+
errors: ["contextual_cvss_reasoning is required when re-rating a dependency finding"],
|
|
2233
|
+
report_id: reportId
|
|
2234
|
+
};
|
|
2235
|
+
const errors = validateCvssBreakdown(changes["cvss_breakdown"]);
|
|
2236
|
+
if (errors.length > 0) return {
|
|
2237
|
+
success: false,
|
|
2238
|
+
error: "Validation failed",
|
|
2239
|
+
errors,
|
|
2240
|
+
report_id: reportId
|
|
2241
|
+
};
|
|
2242
|
+
const computed = calculateCvss(changes["cvss_breakdown"]);
|
|
2243
|
+
changes["cvss"] = computed.score;
|
|
2244
|
+
changes["severity"] = computed.severity;
|
|
2245
|
+
} else if (changes["cvss_breakdown"] !== void 0) {
|
|
2246
|
+
const errors = validateCvssBreakdown(changes["cvss_breakdown"]);
|
|
2247
|
+
if (errors.length > 0) return {
|
|
2248
|
+
success: false,
|
|
2249
|
+
error: "Validation failed",
|
|
2250
|
+
errors,
|
|
2251
|
+
report_id: reportId
|
|
2252
|
+
};
|
|
2253
|
+
const computed = calculateCvss(changes["cvss_breakdown"]);
|
|
2254
|
+
changes["cvss"] = computed.score;
|
|
2255
|
+
changes["severity"] = computed.severity;
|
|
2256
|
+
}
|
|
2257
|
+
const outcome = state.updateVulnerabilityReport(reportId, changes, reason);
|
|
2258
|
+
if ("noop" in outcome) return {
|
|
2259
|
+
success: false,
|
|
2260
|
+
error: `Report '${reportId}' already says this - nothing in your update changes it`,
|
|
2261
|
+
report_id: reportId
|
|
2262
|
+
};
|
|
2263
|
+
await saveArtifacts();
|
|
2264
|
+
const updated = outcome.report;
|
|
2265
|
+
return {
|
|
2266
|
+
success: true,
|
|
2267
|
+
action: "updated",
|
|
2268
|
+
message: `Report '${reportId}' now carries your revision. Do not file it again.`,
|
|
2269
|
+
report_id: reportId,
|
|
2270
|
+
updated_fields: updated["update_history"]?.at(-1)?.fields ?? [],
|
|
2271
|
+
severity: updated.severity,
|
|
2272
|
+
...updated.cvss !== void 0 ? { cvss_score: updated.cvss } : {}
|
|
2273
|
+
};
|
|
2274
|
+
})
|
|
2275
|
+
}));
|
|
2276
|
+
ctx.tools.register(defineTool({
|
|
2277
|
+
name: "create_dependency_report",
|
|
2278
|
+
description: "File a known-CVE dependency (SCA) finding — one report per CVE x package. For vulnerable third-party package versions pinned in a lockfile/manifest/SBOM; no live PoC needed. Severity comes from the contextual_cvss_breakdown when provided, else the advisory score. Never for dynamically-proven vulnerabilities — use create_vulnerability_report.",
|
|
2279
|
+
parameters: {
|
|
2280
|
+
title: {
|
|
2281
|
+
type: "string",
|
|
2282
|
+
required: true,
|
|
2283
|
+
description: "e.g. \"CVE-2024-1234 in lodash 4.17.20 (prototype pollution)\"."
|
|
2284
|
+
},
|
|
2285
|
+
description: {
|
|
2286
|
+
type: "string",
|
|
2287
|
+
required: true,
|
|
2288
|
+
description: "What the CVE is and why the pinned version is affected."
|
|
2289
|
+
},
|
|
2290
|
+
target: {
|
|
2291
|
+
type: "string",
|
|
2292
|
+
required: true,
|
|
2293
|
+
description: "Affected repository / project / manifest."
|
|
2294
|
+
},
|
|
2295
|
+
cve: {
|
|
2296
|
+
type: "string",
|
|
2297
|
+
required: true,
|
|
2298
|
+
description: "CVE-YYYY-NNNNN — required and verified."
|
|
2299
|
+
},
|
|
2300
|
+
package_name: {
|
|
2301
|
+
type: "string",
|
|
2302
|
+
required: true,
|
|
2303
|
+
description: "Affected package name (e.g. lodash)."
|
|
2304
|
+
},
|
|
2305
|
+
installed_version: {
|
|
2306
|
+
type: "string",
|
|
2307
|
+
required: true,
|
|
2308
|
+
description: "The version currently pinned/installed."
|
|
2309
|
+
},
|
|
2310
|
+
advisory_cvss: {
|
|
2311
|
+
type: "number",
|
|
2312
|
+
required: true,
|
|
2313
|
+
description: "Published advisory base score (0.0-10.0)."
|
|
2314
|
+
},
|
|
2315
|
+
impact: {
|
|
2316
|
+
type: "string",
|
|
2317
|
+
required: true,
|
|
2318
|
+
description: "What the CVE enables; business risk in this context."
|
|
2319
|
+
},
|
|
2320
|
+
remediation_steps: {
|
|
2321
|
+
type: "string",
|
|
2322
|
+
required: true,
|
|
2323
|
+
description: "How to fix (usually upgrade to a fixed version)."
|
|
2324
|
+
},
|
|
2325
|
+
assumptions: {
|
|
2326
|
+
type: "string",
|
|
2327
|
+
required: true,
|
|
2328
|
+
description: "Exploitability/reachability assumptions & confidence."
|
|
2329
|
+
},
|
|
2330
|
+
package_ecosystem: {
|
|
2331
|
+
type: "string",
|
|
2332
|
+
required: true,
|
|
2333
|
+
description: "e.g. npm / pypi / maven / go."
|
|
2334
|
+
},
|
|
2335
|
+
manifest_path: {
|
|
2336
|
+
type: "string",
|
|
2337
|
+
description: "Repo-relative lockfile/manifest path (required)."
|
|
2338
|
+
},
|
|
2339
|
+
fixed_version: {
|
|
2340
|
+
type: "string",
|
|
2341
|
+
description: "First non-vulnerable version, if known."
|
|
2342
|
+
},
|
|
2343
|
+
cwe: {
|
|
2344
|
+
type: "string",
|
|
2345
|
+
description: "CWE-NNN (most specific) if certain."
|
|
2346
|
+
},
|
|
2347
|
+
technical_analysis: {
|
|
2348
|
+
type: "string",
|
|
2349
|
+
description: "Optional deeper mechanism/root-cause detail."
|
|
2350
|
+
},
|
|
2351
|
+
fix_effort: {
|
|
2352
|
+
type: "string",
|
|
2353
|
+
enum: [
|
|
2354
|
+
"trivial",
|
|
2355
|
+
"low",
|
|
2356
|
+
"medium",
|
|
2357
|
+
"high"
|
|
2358
|
+
],
|
|
2359
|
+
description: "Default low."
|
|
2360
|
+
},
|
|
2361
|
+
introduced_by: {
|
|
2362
|
+
type: "string",
|
|
2363
|
+
description: "For a transitive dep, the direct dependency pulling it in (name@version)."
|
|
2364
|
+
},
|
|
2365
|
+
dependency_path: {
|
|
2366
|
+
type: "string",
|
|
2367
|
+
description: "Resolution chain joined with \" > \"."
|
|
2368
|
+
},
|
|
2369
|
+
reachability: {
|
|
2370
|
+
type: "string",
|
|
2371
|
+
enum: [
|
|
2372
|
+
"not_imported",
|
|
2373
|
+
"imported",
|
|
2374
|
+
"vulnerable_symbol_used",
|
|
2375
|
+
"reachable_call_path",
|
|
2376
|
+
"unknown"
|
|
2377
|
+
],
|
|
2378
|
+
description: "Usage-evidence level (default unknown)."
|
|
2379
|
+
},
|
|
2380
|
+
reachability_evidence: {
|
|
2381
|
+
type: "string",
|
|
2382
|
+
description: "Concrete proof for the level (required)."
|
|
2383
|
+
},
|
|
2384
|
+
contextual_cvss_breakdown: {
|
|
2385
|
+
type: "object",
|
|
2386
|
+
properties: CVSS_METRIC_SCHEMAS,
|
|
2387
|
+
additionalProperties: false,
|
|
2388
|
+
description: "Full CVSS v3.1 rating of this CVE in this codebase (required)."
|
|
2389
|
+
},
|
|
2390
|
+
contextual_cvss_reasoning: {
|
|
2391
|
+
type: "string",
|
|
2392
|
+
description: "2-4 verifiable sentences with file:line hops (required)."
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
output: {
|
|
2396
|
+
schema: {
|
|
2397
|
+
type: "object",
|
|
2398
|
+
properties: {
|
|
2399
|
+
success: {
|
|
2400
|
+
type: "boolean",
|
|
2401
|
+
required: true
|
|
2402
|
+
},
|
|
2403
|
+
message: { type: "string" },
|
|
2404
|
+
report_id: { type: "string" },
|
|
2405
|
+
severity: { type: "string" },
|
|
2406
|
+
cve: { type: "string" },
|
|
2407
|
+
error: { type: "string" },
|
|
2408
|
+
errors: {
|
|
2409
|
+
type: "array",
|
|
2410
|
+
items: { type: "string" }
|
|
2411
|
+
},
|
|
2412
|
+
duplicate_of: { type: "string" },
|
|
2413
|
+
confidence: { type: "number" },
|
|
2414
|
+
reason: { type: "string" },
|
|
2415
|
+
warning: { type: "string" }
|
|
2416
|
+
},
|
|
2417
|
+
additionalProperties: false
|
|
2418
|
+
},
|
|
2419
|
+
render: (_args, value) => {
|
|
2420
|
+
const result = value;
|
|
2421
|
+
if (!result.success) return [{
|
|
2422
|
+
type: "text",
|
|
2423
|
+
text: `create_dependency_report failed: ${result.error ?? "unknown"}`
|
|
2424
|
+
}];
|
|
2425
|
+
return [{
|
|
2426
|
+
type: "text",
|
|
2427
|
+
text: `filed ${result.report_id} (${String(result.severity)}) for ${result.cve ?? "CVE"}`
|
|
2428
|
+
}];
|
|
2429
|
+
},
|
|
2430
|
+
presentationMeta: (args, value) => findingPresentationMeta(args, value)
|
|
2431
|
+
},
|
|
2432
|
+
execute: (async (rawArgs, _rawExec) => {
|
|
2433
|
+
const args = rawArgs;
|
|
2434
|
+
const errors = [];
|
|
2435
|
+
const requireText = (value, name) => {
|
|
2436
|
+
const cleaned = cleanOptional(value);
|
|
2437
|
+
if (cleaned === void 0) errors.push(`${name} cannot be empty`);
|
|
2438
|
+
return cleaned;
|
|
2439
|
+
};
|
|
2440
|
+
const packageName = requireText(args.package_name, "package_name");
|
|
2441
|
+
const installedVersion = requireText(args.installed_version, "installed_version");
|
|
2442
|
+
const packageEcosystem = requireText(args.package_ecosystem, "package_ecosystem");
|
|
2443
|
+
const manifestPath = requireText(args.manifest_path, "manifest_path");
|
|
2444
|
+
const reachabilityEvidence = requireText(args.reachability_evidence, "reachability_evidence");
|
|
2445
|
+
const contextualReasoning = requireText(args.contextual_cvss_reasoning, "contextual_cvss_reasoning");
|
|
2446
|
+
const cve = normalizeCve(args.cve);
|
|
2447
|
+
if (cve === void 0) errors.push(`Invalid cve: ${String(args.cve)}. Must match CVE-YYYY-NNNNN`);
|
|
2448
|
+
const cwe = normalizeCwe(args.cwe);
|
|
2449
|
+
if (args.cwe !== void 0 && cwe === void 0) errors.push(`Invalid cwe: ${args.cwe}. Must match CWE-NNN`);
|
|
2450
|
+
const advisory = args.advisory_cvss;
|
|
2451
|
+
if (typeof advisory !== "number" || Number.isNaN(advisory) || advisory < 0 || advisory > 10) errors.push(`Invalid advisory_cvss: ${String(advisory)}. Must be between 0.0 and 10.0`);
|
|
2452
|
+
const reachability = (args.reachability ?? "unknown").toLowerCase();
|
|
2453
|
+
if (!VALID_REACHABILITY.has(reachability)) errors.push(`Invalid reachability: ${String(args.reachability)}. Must be one of: [imported, not_imported, reachable_call_path, unknown, vulnerable_symbol_used]`);
|
|
2454
|
+
const fixEffort = (args.fix_effort ?? "low").toLowerCase();
|
|
2455
|
+
if (!VALID_FIX_EFFORT.has(fixEffort)) errors.push(`Invalid fix_effort: ${String(args.fix_effort)}. Must be one of: [high, low, medium, trivial]`);
|
|
2456
|
+
if (manifestPath !== void 0 && (manifestPath.startsWith("/") || manifestPath.includes("\\") || manifestPath.split("/").some((part) => part === "" || part === "." || part === ".."))) errors.push(`Invalid manifest_path: ${manifestPath}. Must be a repo-relative path without absolute or traversal segments`);
|
|
2457
|
+
let contextual;
|
|
2458
|
+
const hasContextualBreakdown = args.contextual_cvss_breakdown !== void 0;
|
|
2459
|
+
if (hasContextualBreakdown || contextualReasoning !== void 0) {
|
|
2460
|
+
if (!hasContextualBreakdown) errors.push("contextual_cvss_breakdown is required when contextual_cvss_reasoning is given");
|
|
2461
|
+
if (contextualReasoning === void 0) errors.push("contextual_cvss_reasoning is required when contextual_cvss_breakdown is given");
|
|
2462
|
+
if (hasContextualBreakdown && contextualReasoning !== void 0) {
|
|
2463
|
+
const breakdown = args.contextual_cvss_breakdown;
|
|
2464
|
+
errors.push(...validateCvssBreakdown(breakdown));
|
|
2465
|
+
const computed = calculateCvss(breakdown);
|
|
2466
|
+
contextual = {
|
|
2467
|
+
breakdown,
|
|
2468
|
+
score: computed.score,
|
|
2469
|
+
vector: computed.vector,
|
|
2470
|
+
reasoning: contextualReasoning
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
if (errors.length > 0) return {
|
|
2475
|
+
success: false,
|
|
2476
|
+
error: "Validation failed",
|
|
2477
|
+
errors
|
|
2478
|
+
};
|
|
2479
|
+
const severity = contextual !== void 0 ? calculateCvss(contextual.breakdown).severity : dependencySeverity(advisory);
|
|
2480
|
+
const metadata = buildDependencyMetadata({
|
|
2481
|
+
packageName,
|
|
2482
|
+
installedVersion,
|
|
2483
|
+
advisoryCvss: advisory,
|
|
2484
|
+
packageEcosystem,
|
|
2485
|
+
manifestPath,
|
|
2486
|
+
fixedVersion: cleanOptional(args.fixed_version),
|
|
2487
|
+
introducedBy: cleanOptional(args.introduced_by),
|
|
2488
|
+
dependencyPath: cleanOptional(args.dependency_path),
|
|
2489
|
+
reachability,
|
|
2490
|
+
reachabilityEvidence,
|
|
2491
|
+
contextual
|
|
2492
|
+
});
|
|
2493
|
+
const fields = {
|
|
2494
|
+
description: args.description,
|
|
2495
|
+
impact: args.impact,
|
|
2496
|
+
target: args.target,
|
|
2497
|
+
technical_analysis: args.technical_analysis,
|
|
2498
|
+
remediation_steps: args.remediation_steps,
|
|
2499
|
+
assumptions: args.assumptions,
|
|
2500
|
+
fix_effort: fixEffort,
|
|
2501
|
+
cve,
|
|
2502
|
+
cwe,
|
|
2503
|
+
cvss: contextual !== void 0 ? contextual.score : advisory
|
|
2504
|
+
};
|
|
2505
|
+
return persistCreate({
|
|
2506
|
+
title: args.title,
|
|
2507
|
+
severity,
|
|
2508
|
+
findingClass: "dependency_cve",
|
|
2509
|
+
dependencyMetadata: metadata,
|
|
2510
|
+
fields
|
|
2511
|
+
}, {
|
|
2512
|
+
title: args.title,
|
|
2513
|
+
description: args.description,
|
|
2514
|
+
target: args.target,
|
|
2515
|
+
cve,
|
|
2516
|
+
dependency_metadata: metadata,
|
|
2517
|
+
technical_analysis: args.technical_analysis
|
|
2518
|
+
});
|
|
2519
|
+
})
|
|
2520
|
+
}));
|
|
2521
|
+
ctx.tools.register(defineTool({
|
|
2522
|
+
name: "list_reports",
|
|
2523
|
+
description: "List vulnerability reports filed so far in this scan — metadata-first. Read-only and shared across all agents. Filters compose (AND); compact entries by default, full bodies with include_details.",
|
|
2524
|
+
parameters: {
|
|
2525
|
+
severity: {
|
|
2526
|
+
type: "string",
|
|
2527
|
+
enum: [
|
|
2528
|
+
"critical",
|
|
2529
|
+
"high",
|
|
2530
|
+
"medium",
|
|
2531
|
+
"low",
|
|
2532
|
+
"info",
|
|
2533
|
+
"none"
|
|
2534
|
+
],
|
|
2535
|
+
description: "Filter to one severity."
|
|
2536
|
+
},
|
|
2537
|
+
finding_class: {
|
|
2538
|
+
type: "string",
|
|
2539
|
+
enum: ["dynamic", "dependency_cve"],
|
|
2540
|
+
description: "dynamic or dependency_cve."
|
|
2541
|
+
},
|
|
2542
|
+
target: {
|
|
2543
|
+
type: "string",
|
|
2544
|
+
description: "Substring match against target/endpoint."
|
|
2545
|
+
},
|
|
2546
|
+
search: {
|
|
2547
|
+
type: "string",
|
|
2548
|
+
description: "Substring match against title and description."
|
|
2549
|
+
},
|
|
2550
|
+
include_details: {
|
|
2551
|
+
type: "boolean",
|
|
2552
|
+
description: "Full report bodies instead of compact entries (default false)."
|
|
2553
|
+
}
|
|
2554
|
+
},
|
|
2555
|
+
output: {
|
|
2556
|
+
schema: {
|
|
2557
|
+
type: "object",
|
|
2558
|
+
properties: {
|
|
2559
|
+
success: {
|
|
2560
|
+
type: "boolean",
|
|
2561
|
+
required: true
|
|
2562
|
+
},
|
|
2563
|
+
reports: {
|
|
2564
|
+
type: "array",
|
|
2565
|
+
items: {
|
|
2566
|
+
type: "object",
|
|
2567
|
+
properties: {},
|
|
2568
|
+
additionalProperties: true
|
|
2569
|
+
}
|
|
2570
|
+
},
|
|
2571
|
+
filtered_count: {
|
|
2572
|
+
type: "integer",
|
|
2573
|
+
required: true
|
|
2574
|
+
},
|
|
2575
|
+
total_count: {
|
|
2576
|
+
type: "integer",
|
|
2577
|
+
required: true
|
|
2578
|
+
},
|
|
2579
|
+
severity_counts: {
|
|
2580
|
+
type: "object",
|
|
2581
|
+
properties: {},
|
|
2582
|
+
additionalProperties: true,
|
|
2583
|
+
required: true
|
|
2584
|
+
},
|
|
2585
|
+
warning: { type: "string" },
|
|
2586
|
+
error: { type: "string" }
|
|
2587
|
+
},
|
|
2588
|
+
additionalProperties: false
|
|
2589
|
+
},
|
|
2590
|
+
render: (_args, value) => {
|
|
2591
|
+
const result = value;
|
|
2592
|
+
return [{
|
|
2593
|
+
type: "text",
|
|
2594
|
+
text: `${String(result.reports.length)} of ${String(result.total_count)} report(s)`
|
|
2595
|
+
}];
|
|
2596
|
+
}
|
|
2597
|
+
},
|
|
2598
|
+
execute: (async (rawArgs, rawExec) => {
|
|
2599
|
+
const args = rawArgs;
|
|
2600
|
+
const severityFilter = cleanOptional(args.severity)?.toLowerCase();
|
|
2601
|
+
const classFilter = cleanOptional(args.finding_class)?.toLowerCase();
|
|
2602
|
+
const targetFilter = cleanOptional(args.target)?.toLowerCase();
|
|
2603
|
+
const searchFilter = cleanOptional(args.search)?.toLowerCase();
|
|
2604
|
+
if (severityFilter !== void 0 && !VALID_SEVERITIES.has(severityFilter)) return {
|
|
2605
|
+
success: false,
|
|
2606
|
+
reports: [],
|
|
2607
|
+
filtered_count: 0,
|
|
2608
|
+
total_count: 0,
|
|
2609
|
+
severity_counts: {},
|
|
2610
|
+
error: `Invalid severity: ${severityFilter}. Must be one of: [critical, high, info, low, medium, none]`
|
|
2611
|
+
};
|
|
2612
|
+
if (classFilter !== void 0 && !VALID_FINDING_CLASSES.has(classFilter)) return {
|
|
2613
|
+
success: false,
|
|
2614
|
+
reports: [],
|
|
2615
|
+
filtered_count: 0,
|
|
2616
|
+
total_count: 0,
|
|
2617
|
+
severity_counts: {},
|
|
2618
|
+
error: `Invalid finding_class: ${classFilter}. Must be one of: [dependency_cve, dynamic]`
|
|
2619
|
+
};
|
|
2620
|
+
const severityCounts = {};
|
|
2621
|
+
for (const report of state.vulnerabilityReports) {
|
|
2622
|
+
const key = String(report.severity);
|
|
2623
|
+
severityCounts[key] = (severityCounts[key] ?? 0) + 1;
|
|
2624
|
+
}
|
|
2625
|
+
const filtered = state.vulnerabilityReports.filter((report) => {
|
|
2626
|
+
if (severityFilter !== void 0 && report.severity !== severityFilter) return false;
|
|
2627
|
+
if (classFilter !== void 0 && String(report.finding_class) !== classFilter) return false;
|
|
2628
|
+
if (targetFilter !== void 0) {
|
|
2629
|
+
const target = String(report.target ?? "").toLowerCase();
|
|
2630
|
+
const endpoint = String(report.endpoint ?? "").toLowerCase();
|
|
2631
|
+
if (!target.includes(targetFilter) && !endpoint.includes(targetFilter)) return false;
|
|
2632
|
+
}
|
|
2633
|
+
if (searchFilter !== void 0) {
|
|
2634
|
+
const title = String(report.title ?? "").toLowerCase();
|
|
2635
|
+
const description = String(report.description ?? "").toLowerCase();
|
|
2636
|
+
if (!title.includes(searchFilter) && !description.includes(searchFilter)) return false;
|
|
2637
|
+
}
|
|
2638
|
+
return true;
|
|
2639
|
+
});
|
|
2640
|
+
filtered.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.id.localeCompare(b.id));
|
|
2641
|
+
return {
|
|
2642
|
+
success: true,
|
|
2643
|
+
reports: filtered.map((report) => args.include_details === true ? { ...report } : summarize(report)),
|
|
2644
|
+
filtered_count: filtered.length,
|
|
2645
|
+
total_count: state.vulnerabilityReports.length,
|
|
2646
|
+
severity_counts: severityCounts
|
|
2647
|
+
};
|
|
2648
|
+
})
|
|
2649
|
+
}));
|
|
2650
|
+
ctx.tools.register(defineTool({
|
|
2651
|
+
name: "get_report",
|
|
2652
|
+
description: "Fetch one vulnerability report by its id (e.g. vuln-0001). Read-only; use list_reports to find ids.",
|
|
2653
|
+
parameters: { report_id: {
|
|
2654
|
+
type: "string",
|
|
2655
|
+
required: true,
|
|
2656
|
+
description: "Report id from list_reports or a create response (format 'vuln-NNNN')."
|
|
2657
|
+
} },
|
|
2658
|
+
output: {
|
|
2659
|
+
schema: {
|
|
2660
|
+
type: "object",
|
|
2661
|
+
properties: {
|
|
2662
|
+
success: {
|
|
2663
|
+
type: "boolean",
|
|
2664
|
+
required: true
|
|
2665
|
+
},
|
|
2666
|
+
report: {
|
|
2667
|
+
type: "object",
|
|
2668
|
+
properties: {},
|
|
2669
|
+
additionalProperties: true
|
|
2670
|
+
},
|
|
2671
|
+
error: { type: "string" }
|
|
2672
|
+
},
|
|
2673
|
+
additionalProperties: false
|
|
2674
|
+
},
|
|
2675
|
+
render: (_args, value) => {
|
|
2676
|
+
const result = value;
|
|
2677
|
+
if (!result.success) return [{
|
|
2678
|
+
type: "text",
|
|
2679
|
+
text: `get_report failed: ${result.error ?? "unknown"}`
|
|
2680
|
+
}];
|
|
2681
|
+
return [{
|
|
2682
|
+
type: "text",
|
|
2683
|
+
text: `report ${String(result.report?.id)}`
|
|
2684
|
+
}];
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2687
|
+
execute: (async (rawArgs, rawExec) => {
|
|
2688
|
+
const reportId = cleanOptional(rawArgs.report_id);
|
|
2689
|
+
if (reportId === void 0) return {
|
|
2690
|
+
success: false,
|
|
2691
|
+
error: "report_id cannot be empty"
|
|
2692
|
+
};
|
|
2693
|
+
const report = state.vulnerabilityReports.find((entry) => entry.id === reportId);
|
|
2694
|
+
if (report === void 0) return {
|
|
2695
|
+
success: false,
|
|
2696
|
+
error: `Report with id '${reportId}' not found`
|
|
2697
|
+
};
|
|
2698
|
+
return {
|
|
2699
|
+
success: true,
|
|
2700
|
+
report: { ...report }
|
|
2701
|
+
};
|
|
2702
|
+
})
|
|
2703
|
+
}));
|
|
2704
|
+
/** Compact summary entry (tool.py `_to_report_summary_entry` field order). */
|
|
2705
|
+
function summarize(report) {
|
|
2706
|
+
const record = report;
|
|
2707
|
+
const summary = {};
|
|
2708
|
+
for (const field of [
|
|
2709
|
+
"id",
|
|
2710
|
+
"title",
|
|
2711
|
+
"severity",
|
|
2712
|
+
"cvss",
|
|
2713
|
+
"confidence",
|
|
2714
|
+
"finding_class",
|
|
2715
|
+
"cve",
|
|
2716
|
+
"cwe",
|
|
2717
|
+
"target",
|
|
2718
|
+
"endpoint",
|
|
2719
|
+
"method",
|
|
2720
|
+
"fix_effort",
|
|
2721
|
+
"agent_name",
|
|
2722
|
+
"timestamp"
|
|
2723
|
+
]) {
|
|
2724
|
+
const value = record[field];
|
|
2725
|
+
if (value !== null && value !== void 0 && value !== "") summary[field] = value;
|
|
2726
|
+
}
|
|
2727
|
+
const description = record["description"];
|
|
2728
|
+
if (typeof description === "string" && description !== "") summary["description_preview"] = description.length > 280 ? `${description.slice(0, 280)}...` : description;
|
|
2729
|
+
return summary;
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Replay-safe toolview meta for finding cards (ui FindingRow consumes
|
|
2733
|
+
* severity/report_id/title from `result.meta`). Pure over (args, value);
|
|
2734
|
+
* undefined keys are omitted so the snapshot stays lossless JSON.
|
|
2735
|
+
*/
|
|
2736
|
+
function findingPresentationMeta(args, value) {
|
|
2737
|
+
const meta = {};
|
|
2738
|
+
const title = args.title;
|
|
2739
|
+
if (title !== void 0) meta["title"] = title;
|
|
2740
|
+
const severity = value.severity;
|
|
2741
|
+
if (severity !== void 0) meta["severity"] = severity;
|
|
2742
|
+
const reportId = value.report_id;
|
|
2743
|
+
if (reportId !== void 0) meta["report_id"] = reportId;
|
|
2744
|
+
return meta;
|
|
2745
|
+
}
|
|
2746
|
+
const composeFinalReport = (sections) => [
|
|
2747
|
+
"# Executive Summary",
|
|
2748
|
+
sections.executiveSummary,
|
|
2749
|
+
"",
|
|
2750
|
+
"# Methodology",
|
|
2751
|
+
sections.methodology,
|
|
2752
|
+
"",
|
|
2753
|
+
"# Technical Analysis",
|
|
2754
|
+
sections.technicalAnalysis,
|
|
2755
|
+
"",
|
|
2756
|
+
"# Recommendations",
|
|
2757
|
+
sections.recommendations
|
|
2758
|
+
].join("\n");
|
|
2759
|
+
const handle = {
|
|
2760
|
+
state,
|
|
2761
|
+
runDir,
|
|
2762
|
+
/** Mark the scan complete and write the final report + artifacts. */
|
|
2763
|
+
async finishScan(sections, status = "completed") {
|
|
2764
|
+
state.finalScanResult = composeFinalReport(sections);
|
|
2765
|
+
state.complete(status);
|
|
2766
|
+
scanResults = {
|
|
2767
|
+
scan_completed: true,
|
|
2768
|
+
executive_summary: sections.executiveSummary,
|
|
2769
|
+
methodology: sections.methodology,
|
|
2770
|
+
technical_analysis: sections.technicalAnalysis,
|
|
2771
|
+
recommendations: sections.recommendations,
|
|
2772
|
+
success: status === "completed"
|
|
2773
|
+
};
|
|
2774
|
+
await writeExecutiveReport(await ensureRunDir(), state.finalScanResult, formatTimestamp(/* @__PURE__ */ new Date()));
|
|
2775
|
+
await saveArtifacts();
|
|
2776
|
+
},
|
|
2777
|
+
/** Dump the current run.json record (golden/test helper). */
|
|
2778
|
+
async writeNow() {
|
|
2779
|
+
await saveArtifacts();
|
|
2780
|
+
},
|
|
2781
|
+
readRaw: async (relative) => readFile(join(runDir, relative), "utf8")
|
|
2782
|
+
};
|
|
2783
|
+
ctx.provide("pentestReporting", handle);
|
|
2784
|
+
return handle;
|
|
2785
|
+
}
|
|
2786
|
+
//#endregion
|
|
2787
|
+
export { Config, ReportState, apply, buildCvssVector, buildDependencyMetadata, buildSarif, calculateCvss, checkDuplicate, classKeyword, cleanTitle, cvssBaseScore, cvssSeverity, dependencyIdentity, dependencySeverity, inject, name, normalizeCodeLocations, normalizeCve, normalizeCwe, renderVulnerabilityMd, ruleIdOf, sarifUri, severityRank, validateCvssBreakdown };
|
|
2788
|
+
|
|
2789
|
+
//# sourceMappingURL=index.js.map
|