@gpzhang2001/sharpkit-reporting 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +32 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +498 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +2789 -0
- package/lib/index.js.map +1 -0
- package/package.json +45 -0
- package/src/cvss.ts +141 -0
- package/src/dedupe.ts +136 -0
- package/src/index.ts +1045 -0
- package/src/sarif.ts +531 -0
- package/src/state.ts +250 -0
- package/src/writers.ts +316 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vulnerability / dependency reporting tools — port of strix
|
|
3
|
+
* tools/reporting/tool.py: create_vulnerability_report,
|
|
4
|
+
* create_dependency_report, update_vulnerability_report, list_reports,
|
|
5
|
+
* get_report. Validation parity (runtime-validated value sets, CVSS
|
|
6
|
+
* computation, cross-class update guards), strix's exact response JSON
|
|
7
|
+
* shapes, and the artifacts fan-out on every mutation (md/csv/json/sarif +
|
|
8
|
+
* run.json last, atomic writes; SARIF always emitted even when empty).
|
|
9
|
+
* The dedupe LLM judge is a Config callback (deterministic dependency fast
|
|
10
|
+
* path runs regardless; judge failures default to not-duplicate).
|
|
11
|
+
* @module @gpzhang2001/sharpkit-reporting
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type Schema from '@deepseek-ai/schemastery'
|
|
18
|
+
import z from '@deepseek-ai/schemastery'
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import { calculateCvss, dependencySeverity, validateCvssBreakdown, type CvssMetricName } from './cvss.ts'
|
|
21
|
+
import { checkDuplicate, type DedupeJudge } from './dedupe.ts'
|
|
22
|
+
import { ReportState, formatTimestamp, severityRank, type VulnerabilityReport } from './state.ts'
|
|
23
|
+
import { renderVulnerabilityMd, writeCoverage, writeExecutiveReport, writeRunRecord, writeVulnerabilities } from './writers.ts'
|
|
24
|
+
import { writeSarif, type SarifCoverage } from './sarif.ts'
|
|
25
|
+
|
|
26
|
+
declare module '@deepseek-ai/cordis' {
|
|
27
|
+
interface Context {
|
|
28
|
+
pentestReporting: ReportingHandle
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { renderVulnerabilityMd }
|
|
33
|
+
export { ReportState, cleanTitle, severityRank } from './state.ts'
|
|
34
|
+
export { calculateCvss, cvssBaseScore, cvssSeverity, validateCvssBreakdown, dependencySeverity, buildCvssVector } from './cvss.ts'
|
|
35
|
+
export { checkDuplicate, dependencyIdentity } from './dedupe.ts'
|
|
36
|
+
export { buildSarif, sarifUri, ruleIdOf, classKeyword } from './sarif.ts'
|
|
37
|
+
|
|
38
|
+
/** Deployment-tunable configuration. */
|
|
39
|
+
export interface Config {
|
|
40
|
+
/** Run-name slug basis (strix derives it from the target label). */
|
|
41
|
+
readonly runName?: string
|
|
42
|
+
/** Runs root directory (sharpkit_runs; configurable back to strix_runs for legacy compat). */
|
|
43
|
+
readonly runsRoot?: string
|
|
44
|
+
/** Sandbox image version stamped into SARIF (tool.driver.version). */
|
|
45
|
+
readonly toolVersion?: string
|
|
46
|
+
/** Scan mode recorded into run.json. */
|
|
47
|
+
readonly scanMode?: string
|
|
48
|
+
/** Targets recorded into run.json. */
|
|
49
|
+
readonly targetsInfo?: readonly Record<string, unknown>[]
|
|
50
|
+
/** Pluggable dedupe LLM judge (dynamic findings; failures = not duplicate). */
|
|
51
|
+
readonly dedupeJudge?: DedupeJudge
|
|
52
|
+
/** File the CVSS-broad-CWE ban borrows from strix's guidance. */
|
|
53
|
+
readonly strictCwe?: boolean
|
|
54
|
+
/** Coverage document source (the analysis package wires itself in). */
|
|
55
|
+
readonly coverageSource?: CoverageSource
|
|
56
|
+
/** Auth mode recorded into run.json (strix codex.auth_mode; default "none"). */
|
|
57
|
+
readonly authMode?: string
|
|
58
|
+
/** Scan instruction recorded into run.json (strix set_scan_config). */
|
|
59
|
+
readonly instruction?: string
|
|
60
|
+
/** Diff scope recorded into run.json (diff-scope scans). */
|
|
61
|
+
readonly diffScope?: string
|
|
62
|
+
/** Non-interactive flag recorded into run.json. */
|
|
63
|
+
readonly nonInteractive?: boolean
|
|
64
|
+
/** Local source trees recorded into run.json. */
|
|
65
|
+
readonly localSources?: readonly Record<string, unknown>[]
|
|
66
|
+
/** Scope mode recorded into run.json (strix default "auto"). */
|
|
67
|
+
readonly scopeMode?: string
|
|
68
|
+
/** Diff base ref recorded into run.json (diff-scope scans). */
|
|
69
|
+
readonly diffBase?: string
|
|
70
|
+
/** MCP connection names recorded into run.json (strix record_mcp_connections). */
|
|
71
|
+
readonly mcpConnections?: readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const name = 'pentest-tool-reporting'
|
|
75
|
+
|
|
76
|
+
export const inject = ['tools']
|
|
77
|
+
|
|
78
|
+
export const Config: Schema<Config> = z.object({
|
|
79
|
+
runName: z.string(),
|
|
80
|
+
runsRoot: z.string().default('sharpkit_runs'),
|
|
81
|
+
toolVersion: z.string().default('0.1.0'),
|
|
82
|
+
scanMode: z.string().default('quick'),
|
|
83
|
+
targetsInfo: z.array(z.object({})),
|
|
84
|
+
strictCwe: z.boolean().default(true),
|
|
85
|
+
authMode: z.string().default('none'),
|
|
86
|
+
instruction: z.string(),
|
|
87
|
+
diffScope: z.string(),
|
|
88
|
+
nonInteractive: z.boolean(),
|
|
89
|
+
localSources: z.array(z.object({})),
|
|
90
|
+
scopeMode: z.string().default('auto'),
|
|
91
|
+
diffBase: z.string(),
|
|
92
|
+
mcpConnections: z.array(z.string()),
|
|
93
|
+
}) as unknown as Schema<Config>
|
|
94
|
+
|
|
95
|
+
/** strix-clean an optional string: literal null-words and empties → undefined. */
|
|
96
|
+
function cleanOptional(value: string | undefined): string | undefined {
|
|
97
|
+
if (value === undefined) return undefined
|
|
98
|
+
const trimmed = value.trim()
|
|
99
|
+
if (trimmed === '' || /^(null|none|nil|undefined)$/i.test(trimmed)) return undefined
|
|
100
|
+
return trimmed
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** CVSS metric enum value sets (schema-level, matching strix runtime validation). */
|
|
104
|
+
const CVSS_METRIC_SCHEMAS = {
|
|
105
|
+
attack_vector: { type: 'string' as const, required: true as const, enum: ['N', 'A', 'L', 'P'] } as const,
|
|
106
|
+
attack_complexity: { type: 'string' as const, required: true as const, enum: ['L', 'H'] },
|
|
107
|
+
privileges_required: { type: 'string' as const, required: true as const, enum: ['N', 'L', 'H'] },
|
|
108
|
+
user_interaction: { type: 'string' as const, required: true as const, enum: ['N', 'R'] },
|
|
109
|
+
scope: { type: 'string' as const, required: true as const, enum: ['U', 'C'] },
|
|
110
|
+
confidentiality: { type: 'string' as const, required: true as const, enum: ['N', 'L', 'H'] },
|
|
111
|
+
integrity: { type: 'string' as const, required: true as const, enum: ['N', 'L', 'H'] },
|
|
112
|
+
availability: { type: 'string' as const, required: true as const, enum: ['N', 'L', 'H'] },
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const CODE_LOCATION_SCHEMA = {
|
|
116
|
+
type: 'object' as const,
|
|
117
|
+
properties: {
|
|
118
|
+
file: { type: 'string' as const, required: true as const },
|
|
119
|
+
start_line: { type: 'integer' as const, required: true as const },
|
|
120
|
+
end_line: { type: 'integer' as const, required: true as const },
|
|
121
|
+
snippet: { type: 'string' as const },
|
|
122
|
+
label: { type: 'string' as const },
|
|
123
|
+
fix_before: { type: 'string' as const },
|
|
124
|
+
fix_after: { type: 'string' as const },
|
|
125
|
+
},
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
} as const
|
|
128
|
+
|
|
129
|
+
/** Normalize + validate code_locations (tool.py `_normalize_code_locations`). */
|
|
130
|
+
export function normalizeCodeLocations(raw: unknown): { readonly locations?: Record<string, unknown>[]; readonly errors: string[] } {
|
|
131
|
+
if (!Array.isArray(raw) || raw.length === 0) return { errors: [] }
|
|
132
|
+
const errors: string[] = []
|
|
133
|
+
const locations: Record<string, unknown>[] = []
|
|
134
|
+
for (const [index, item] of raw.entries()) {
|
|
135
|
+
if (typeof item !== 'object' || item === null) continue
|
|
136
|
+
const entry = item as Record<string, unknown>
|
|
137
|
+
const normalized: Record<string, unknown> = {}
|
|
138
|
+
const file = entry['file']
|
|
139
|
+
if (typeof file === 'string' && file !== '') normalized['file'] = file.trim()
|
|
140
|
+
const startLine = entry['start_line']
|
|
141
|
+
if (typeof startLine === 'number' && Number.isInteger(startLine)) normalized['start_line'] = startLine
|
|
142
|
+
else if (typeof startLine === 'string' && startLine !== '' && Number.isInteger(Number(startLine))) normalized['start_line'] = Number(startLine)
|
|
143
|
+
const endLine = entry['end_line']
|
|
144
|
+
if (typeof endLine === 'number' && Number.isInteger(endLine)) normalized['end_line'] = endLine
|
|
145
|
+
else if (typeof endLine === 'string' && endLine !== '' && Number.isInteger(Number(endLine))) normalized['end_line'] = Number(endLine)
|
|
146
|
+
for (const field of ['snippet', 'fix_before', 'fix_after'] as const) {
|
|
147
|
+
const value = entry[field]
|
|
148
|
+
if (typeof value === 'string' && value.trim() !== '') normalized[field] = value.replace(/^\n+|\n+$/g, '')
|
|
149
|
+
}
|
|
150
|
+
for (const field of ['label'] as const) {
|
|
151
|
+
const value = entry[field]
|
|
152
|
+
if (typeof value === 'string' && value.trim() !== '') normalized[field] = value.trim()
|
|
153
|
+
}
|
|
154
|
+
if (normalized['file'] === undefined || normalized['start_line'] === undefined) continue
|
|
155
|
+
if (typeof normalized['file'] === 'string' && (normalized['file'] as string).startsWith('/')) {
|
|
156
|
+
errors.push(`code_locations[${String(index)}]: file must be a repo-relative path (no leading '/')`)
|
|
157
|
+
}
|
|
158
|
+
if (typeof normalized['start_line'] !== 'number' || (normalized['start_line'] as number) < 1) {
|
|
159
|
+
errors.push(`code_locations[${String(index)}]: start_line must be an integer >= 1`)
|
|
160
|
+
}
|
|
161
|
+
if (normalized['end_line'] === undefined) {
|
|
162
|
+
errors.push(`code_locations[${String(index)}]: end_line is required`)
|
|
163
|
+
} else {
|
|
164
|
+
const start = normalized['start_line'] as number
|
|
165
|
+
const end = normalized['end_line'] as number
|
|
166
|
+
if (typeof end !== 'number' || end < 1) errors.push(`code_locations[${String(index)}]: end_line must be an integer >= 1`)
|
|
167
|
+
else if (end < start) errors.push(`code_locations[${String(index)}]: end_line (${String(end)}) must be >= start_line (${String(start)})`)
|
|
168
|
+
}
|
|
169
|
+
locations.push(normalized)
|
|
170
|
+
}
|
|
171
|
+
return { ...(locations.length > 0 ? { locations } : {}), errors }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** CVE normalization (tool.py `_extract_cve` + `_validate_cve`). */
|
|
175
|
+
export function normalizeCve(value: string | undefined): string | undefined {
|
|
176
|
+
const cleaned = cleanOptional(value)
|
|
177
|
+
if (cleaned === undefined) return undefined
|
|
178
|
+
const match = /CVE-\d{4}-\d{4,}/.exec(cleaned)
|
|
179
|
+
if (match === null) return undefined
|
|
180
|
+
return match[0]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** CWE normalization (tool.py `_extract_cwe`). */
|
|
184
|
+
export function normalizeCwe(value: string | undefined): string | undefined {
|
|
185
|
+
const cleaned = cleanOptional(value)
|
|
186
|
+
if (cleaned === undefined) return undefined
|
|
187
|
+
const match = /CWE-\d+/.exec(cleaned)
|
|
188
|
+
if (match === null) return undefined
|
|
189
|
+
return match[0]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Dependency metadata builder (tool.py `_build_dependency_metadata` order). */
|
|
193
|
+
export function buildDependencyMetadata(fields: {
|
|
194
|
+
readonly packageName: string
|
|
195
|
+
readonly installedVersion: string
|
|
196
|
+
readonly advisoryCvss?: number | undefined
|
|
197
|
+
readonly packageEcosystem: string
|
|
198
|
+
readonly manifestPath: string
|
|
199
|
+
readonly fixedVersion?: string | undefined
|
|
200
|
+
readonly introducedBy?: string | undefined
|
|
201
|
+
readonly dependencyPath?: string | undefined
|
|
202
|
+
readonly reachability: string
|
|
203
|
+
readonly reachabilityEvidence?: string | undefined
|
|
204
|
+
readonly contextual?: {
|
|
205
|
+
readonly breakdown: Record<string, string>
|
|
206
|
+
readonly score: number
|
|
207
|
+
readonly vector: string
|
|
208
|
+
readonly reasoning: string
|
|
209
|
+
} | undefined
|
|
210
|
+
}): Record<string, unknown> {
|
|
211
|
+
const metadata: Record<string, unknown> = {
|
|
212
|
+
package_name: fields.packageName,
|
|
213
|
+
installed_version: fields.installedVersion,
|
|
214
|
+
}
|
|
215
|
+
if (fields.advisoryCvss !== undefined) metadata['advisory_cvss'] = fields.advisoryCvss
|
|
216
|
+
metadata['package_ecosystem'] = fields.packageEcosystem
|
|
217
|
+
metadata['manifest_path'] = fields.manifestPath
|
|
218
|
+
if (fields.fixedVersion !== undefined) metadata['fixed_version'] = fields.fixedVersion
|
|
219
|
+
if (fields.introducedBy !== undefined) metadata['introduced_by'] = fields.introducedBy
|
|
220
|
+
if (fields.dependencyPath !== undefined) metadata['dependency_path'] = fields.dependencyPath
|
|
221
|
+
metadata['reachability'] = fields.reachability
|
|
222
|
+
if (fields.reachabilityEvidence !== undefined) metadata['reachability_evidence'] = fields.reachabilityEvidence
|
|
223
|
+
if (fields.contextual !== undefined) {
|
|
224
|
+
metadata['contextual_cvss_breakdown'] = fields.contextual.breakdown
|
|
225
|
+
metadata['contextual_cvss_score'] = fields.contextual.score
|
|
226
|
+
metadata['contextual_cvss_vector'] = fields.contextual.vector
|
|
227
|
+
metadata['contextual_cvss_reasoning'] = fields.contextual.reasoning.slice(0, 2000)
|
|
228
|
+
}
|
|
229
|
+
return metadata
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Valid severities (strix `_VALID_SEVERITIES`). */
|
|
233
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info', 'none'])
|
|
234
|
+
const VALID_FIX_EFFORT = new Set(['trivial', 'low', 'medium', 'high'])
|
|
235
|
+
const VALID_CONFIDENCE = new Set(['high', 'medium', 'low'])
|
|
236
|
+
const VALID_FINDING_CLASSES = new Set(['dynamic', 'dependency_cve'])
|
|
237
|
+
const VALID_REACHABILITY = new Set(['not_imported', 'imported', 'vulnerable_symbol_used', 'reachable_call_path', 'unknown'])
|
|
238
|
+
|
|
239
|
+
/** Broad CWEs strix's guidance forbids (tool.py docstring; enforced lightly). */
|
|
240
|
+
const BROADCWES = new Set(['CWE-74', 'CWE-20', 'CWE-200', 'CWE-284', 'CWE-693'])
|
|
241
|
+
|
|
242
|
+
/** Fields only a dynamic finding may carry on update (strix `_DYNAMIC_ONLY_UPDATE_FIELDS` inverse). */
|
|
243
|
+
const DEPENDENCY_ONLY_UPDATE_FIELDS = new Set(['contextual_cvss_reasoning'])
|
|
244
|
+
const DYNAMIC_ONLY_UPDATE_FIELDS = new Set(['endpoint', 'method', 'poc_description', 'poc_script_code'])
|
|
245
|
+
|
|
246
|
+
export interface ReportingHandle {
|
|
247
|
+
readonly state: ReportState
|
|
248
|
+
readonly runDir: string
|
|
249
|
+
finishScan(sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }, status?: string): Promise<void>
|
|
250
|
+
writeNow(): Promise<void>
|
|
251
|
+
readRaw(relative: string): Promise<string>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Coverage document provider the analysis package supplies at wiring time. */
|
|
255
|
+
export interface CoverageSource {
|
|
256
|
+
entries(): Array<Record<string, unknown>>
|
|
257
|
+
outcomeCounts(): Record<string, number>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Tool bodies are written with concrete arg/result types and bridged onto
|
|
262
|
+
* the DSL at registration (`as never`); the registry re-validates every
|
|
263
|
+
* call against the declared schema at the boundary.
|
|
264
|
+
*/
|
|
265
|
+
type ToolExecute = (args: never, exec: never) => Promise<never>
|
|
266
|
+
|
|
267
|
+
interface ToolRunContextLike {
|
|
268
|
+
readonly signal: AbortSignal
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface CvssBreakdownArgs extends Record<CvssMetricName, string> {}
|
|
272
|
+
|
|
273
|
+
interface CreateVulnArgs {
|
|
274
|
+
title: string
|
|
275
|
+
description: string
|
|
276
|
+
impact: string
|
|
277
|
+
target: string
|
|
278
|
+
technical_analysis: string
|
|
279
|
+
poc_description: string
|
|
280
|
+
poc_script_code: string
|
|
281
|
+
remediation_steps: string
|
|
282
|
+
evidence: string
|
|
283
|
+
assumptions: string
|
|
284
|
+
counterevidence: string
|
|
285
|
+
confidence: string
|
|
286
|
+
confidence_rationale?: string
|
|
287
|
+
severity_change_conditions: string
|
|
288
|
+
fix_effort: string
|
|
289
|
+
cvss_breakdown: CvssBreakdownArgs
|
|
290
|
+
endpoint?: string
|
|
291
|
+
method?: string
|
|
292
|
+
cve?: string
|
|
293
|
+
cwe?: string
|
|
294
|
+
code_locations?: Record<string, unknown>[]
|
|
295
|
+
fix_verification?: string
|
|
296
|
+
fix_pr_body?: string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface UpdateArgs {
|
|
300
|
+
report_id: string
|
|
301
|
+
update_reason: string
|
|
302
|
+
title?: string
|
|
303
|
+
description?: string
|
|
304
|
+
impact?: string
|
|
305
|
+
target?: string
|
|
306
|
+
technical_analysis?: string
|
|
307
|
+
poc_description?: string
|
|
308
|
+
poc_script_code?: string
|
|
309
|
+
remediation_steps?: string
|
|
310
|
+
evidence?: string
|
|
311
|
+
assumptions?: string
|
|
312
|
+
counterevidence?: string
|
|
313
|
+
confidence?: string
|
|
314
|
+
confidence_rationale?: string
|
|
315
|
+
severity_change_conditions?: string
|
|
316
|
+
fix_effort?: string
|
|
317
|
+
cvss_breakdown?: CvssBreakdownArgs
|
|
318
|
+
endpoint?: string
|
|
319
|
+
method?: string
|
|
320
|
+
cve?: string
|
|
321
|
+
cwe?: string
|
|
322
|
+
code_locations?: Record<string, unknown>[]
|
|
323
|
+
fix_verification?: string
|
|
324
|
+
fix_pr_body?: string
|
|
325
|
+
contextual_cvss_reasoning?: string
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
interface CreateDepArgs {
|
|
329
|
+
title: string
|
|
330
|
+
description: string
|
|
331
|
+
target: string
|
|
332
|
+
cve: string
|
|
333
|
+
package_name: string
|
|
334
|
+
installed_version: string
|
|
335
|
+
advisory_cvss: number
|
|
336
|
+
impact: string
|
|
337
|
+
remediation_steps: string
|
|
338
|
+
assumptions: string
|
|
339
|
+
package_ecosystem: string
|
|
340
|
+
manifest_path?: string
|
|
341
|
+
fixed_version?: string
|
|
342
|
+
cwe?: string
|
|
343
|
+
technical_analysis?: string
|
|
344
|
+
fix_effort?: string
|
|
345
|
+
introduced_by?: string
|
|
346
|
+
dependency_path?: string
|
|
347
|
+
reachability?: string
|
|
348
|
+
reachability_evidence?: string
|
|
349
|
+
contextual_cvss_breakdown?: CvssBreakdownArgs
|
|
350
|
+
contextual_cvss_reasoning?: string
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
interface ListArgs {
|
|
354
|
+
severity?: string
|
|
355
|
+
finding_class?: string
|
|
356
|
+
target?: string
|
|
357
|
+
search?: string
|
|
358
|
+
include_details?: boolean
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
interface GetArgs {
|
|
362
|
+
report_id: string
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function apply(ctx: Context, config: Config = {}): ReportingHandle {
|
|
366
|
+
const runsRoot = config.runsRoot ?? 'sharpkit_runs'
|
|
367
|
+
const runName = config.runName ?? `pentest-${Math.random().toString(16).slice(2, 6)}`
|
|
368
|
+
const runDir = resolve(join(resolve(runsRoot), runName))
|
|
369
|
+
const state = new ReportState({ runName })
|
|
370
|
+
const toolVersion = config.toolVersion ?? '0.1.0'
|
|
371
|
+
/** scan_results block, set by finishScan and appended to run.json. */
|
|
372
|
+
let scanResults: Record<string, unknown> | undefined
|
|
373
|
+
|
|
374
|
+
// llm_usage ledger: session assistant/message usage events are the only
|
|
375
|
+
// host-side token source (dsh emits no cost events). Counts every session
|
|
376
|
+
// in this host process — single-scan deployments are exact; concurrent
|
|
377
|
+
// scans in one host share the ledger (session log stays authoritative).
|
|
378
|
+
const usageLedger = { requests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 }
|
|
379
|
+
void ctx.on('session/event', (_session: unknown, event: unknown) => {
|
|
380
|
+
const record = event as { type?: string; data?: { usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } } }
|
|
381
|
+
if (record.type !== 'assistant/message') return
|
|
382
|
+
const usage = record.data?.usage
|
|
383
|
+
if (usage === undefined || usage === null) return
|
|
384
|
+
usageLedger.requests += 1
|
|
385
|
+
usageLedger.inputTokens += usage.inputTokens ?? 0
|
|
386
|
+
usageLedger.outputTokens += usage.outputTokens ?? 0
|
|
387
|
+
usageLedger.totalTokens += usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
|
388
|
+
})
|
|
389
|
+
const llmUsageRecord = (): Record<string, unknown> => ({
|
|
390
|
+
requests: usageLedger.requests,
|
|
391
|
+
input_tokens: usageLedger.inputTokens,
|
|
392
|
+
output_tokens: usageLedger.outputTokens,
|
|
393
|
+
total_tokens: usageLedger.totalTokens,
|
|
394
|
+
// dsh session events carry tokens only; cost stays null (parity deviation
|
|
395
|
+
// recorded in the manual — strix estimates cost via LiteLLM callbacks).
|
|
396
|
+
cost: null,
|
|
397
|
+
agents: [],
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const ensureRunDir = async (): Promise<string> => {
|
|
401
|
+
await mkdir(runDir, { recursive: true })
|
|
402
|
+
return runDir
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Resolve the coverage source: Config hook first, else the analysis package's service. */
|
|
406
|
+
const coverageSource = (): CoverageSource | undefined => {
|
|
407
|
+
if (config.coverageSource !== undefined) return config.coverageSource
|
|
408
|
+
const analysis = ctx.get('pentestAnalysis') as
|
|
409
|
+
| { coverageEntries(): Array<Record<string, unknown>>; outcomeCounts(): Record<string, number> }
|
|
410
|
+
| undefined
|
|
411
|
+
return analysis === undefined ? undefined : { entries: () => analysis.coverageEntries(), outcomeCounts: () => analysis.outcomeCounts() }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Assemble the coverage document (state.py `_coverage_document`, minus agent-graph gaps). */
|
|
415
|
+
const coverageDocument = (): Record<string, unknown> => {
|
|
416
|
+
const source = coverageSource()
|
|
417
|
+
const entries = source?.entries() ?? []
|
|
418
|
+
const outcomes = source?.outcomeCounts() ?? {}
|
|
419
|
+
return {
|
|
420
|
+
schema_version: 1,
|
|
421
|
+
generated_at: formatTimestamp(new Date()),
|
|
422
|
+
run_id: state.runId,
|
|
423
|
+
run_name: state.runName,
|
|
424
|
+
scope: { targets: config.targetsInfo ?? [], scan_mode: config.scanMode ?? null, scope_mode: null, diff_scope: null, instruction: '' },
|
|
425
|
+
summary: {
|
|
426
|
+
surfaces_reviewed: entries.length,
|
|
427
|
+
outcomes,
|
|
428
|
+
findings_filed: state.vulnerabilityReports.length,
|
|
429
|
+
gaps: entries.filter(entry => entry['outcome'] === 'needs_follow_up').length,
|
|
430
|
+
},
|
|
431
|
+
completeness: { complete: state.status === 'completed', scan_status: state.status, exit_reason: null, caveats: state.status === 'running' ? ['scan still running'] : [] },
|
|
432
|
+
entries: entries.map(entry => ({
|
|
433
|
+
surface: entry['surface'],
|
|
434
|
+
risk_area: entry['risk_area'],
|
|
435
|
+
outcome: entry['outcome'],
|
|
436
|
+
outcome_label: { reported: 'Finding reported', no_issue_found: 'No issue identified', ruled_out: 'Ruled out', not_applicable: 'Not applicable', needs_follow_up: 'Requires further review' }[String(entry['outcome'])] ?? String(entry['outcome']),
|
|
437
|
+
...(entry['evidence'] !== undefined ? { evidence: entry['evidence'] } : {}),
|
|
438
|
+
recorded_by: entry['agent_name'] ?? null,
|
|
439
|
+
recorded_at: entry['created_at'],
|
|
440
|
+
...(entry['updated_at'] !== undefined ? { updated_at: entry['updated_at'] } : {}),
|
|
441
|
+
previous_outcomes: (entry['history'] as Array<Record<string, unknown>> | undefined)?.map(item => item['outcome']) ?? [],
|
|
442
|
+
source: 'agent_reported',
|
|
443
|
+
})),
|
|
444
|
+
gaps: entries.filter(entry => entry['outcome'] === 'needs_follow_up').map(entry => ({
|
|
445
|
+
kind: 'needs_follow_up',
|
|
446
|
+
surface: entry['surface'],
|
|
447
|
+
risk_area: entry['risk_area'],
|
|
448
|
+
detail: `'${String(entry['surface'])}' (${String(entry['risk_area'])}) still needs follow-up.`,
|
|
449
|
+
})),
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Full artifacts fan-out (state.py `_save_artifacts` order; run.json LAST). */
|
|
454
|
+
const saveArtifacts = async (): Promise<void> => {
|
|
455
|
+
try {
|
|
456
|
+
const dir = await ensureRunDir()
|
|
457
|
+
const coverage = coverageDocument()
|
|
458
|
+
await writeCoverage(dir, coverage)
|
|
459
|
+
await writeVulnerabilities(dir, state.vulnerabilityReports, state.savedVulnIds)
|
|
460
|
+
const source = coverageSource()
|
|
461
|
+
const sarifCoverage: SarifCoverage | undefined = source === undefined ? undefined : { entries: coverage['entries'] as unknown as SarifCoverage['entries'], completeness: coverage['completeness'] as unknown as SarifCoverage['completeness'] }
|
|
462
|
+
await writeSarif(dir, state.vulnerabilityReports, { toolVersion, coverage: sarifCoverage })
|
|
463
|
+
// Key order follows strix's run_record construction (state.py :207-216
|
|
464
|
+
// initial fields, :612-631 set_scan_config appends, :560-582 scan_results).
|
|
465
|
+
const runRecord: Record<string, unknown> = {
|
|
466
|
+
run_id: state.runId,
|
|
467
|
+
run_name: state.runName,
|
|
468
|
+
start_time: state.startTime,
|
|
469
|
+
end_time: state.endTime,
|
|
470
|
+
status: state.status,
|
|
471
|
+
auth_mode: config.authMode ?? 'none',
|
|
472
|
+
targets_info: config.targetsInfo ?? [],
|
|
473
|
+
llm_usage: llmUsageRecord(),
|
|
474
|
+
instruction: config.instruction ?? null,
|
|
475
|
+
scan_mode: config.scanMode ?? 'quick',
|
|
476
|
+
diff_scope: config.diffScope ?? null,
|
|
477
|
+
non_interactive: config.nonInteractive ?? false,
|
|
478
|
+
local_sources: config.localSources ?? [],
|
|
479
|
+
scope_mode: config.scopeMode ?? 'auto',
|
|
480
|
+
diff_base: config.diffBase ?? null,
|
|
481
|
+
}
|
|
482
|
+
if (config.mcpConnections !== undefined && config.mcpConnections.length > 0) {
|
|
483
|
+
runRecord['mcp_connections'] = config.mcpConnections
|
|
484
|
+
}
|
|
485
|
+
if (scanResults !== undefined) runRecord['scan_results'] = scanResults
|
|
486
|
+
await writeRunRecord(dir, runRecord)
|
|
487
|
+
} catch (error) {
|
|
488
|
+
// Best-effort persistence surface (strix swallows OSError/RuntimeError)
|
|
489
|
+
// but said out loud: a silent artifact failure hides lost findings.
|
|
490
|
+
ctx.logger.warn(`pentest-reporting: artifact save failed: ${String(error instanceof Error ? error.message : error)}`)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The create-side validation + dedupe + persistence shared by both create tools. */
|
|
495
|
+
const persistCreate = async (
|
|
496
|
+
input: { readonly title: string; readonly severity: string; readonly findingClass: string; readonly dependencyMetadata?: Record<string, unknown>; readonly fields: Record<string, unknown> },
|
|
497
|
+
dedupeCandidate: Record<string, unknown>,
|
|
498
|
+
): Promise<Record<string, unknown>> => {
|
|
499
|
+
const verdict = await checkDuplicate(dedupeCandidate, input.dependencyMetadata, state.vulnerabilityReports, config.dedupeJudge)
|
|
500
|
+
if (verdict.isDuplicate) {
|
|
501
|
+
const existing = state.vulnerabilityReports.find(report => report.id === verdict.duplicateId)
|
|
502
|
+
const title = existing?.title ?? ''
|
|
503
|
+
return {
|
|
504
|
+
success: false,
|
|
505
|
+
error: `Potential duplicate of '${title}' (id=${verdict.duplicateId.slice(0, 8)}...) — do not re-report the same vulnerability`,
|
|
506
|
+
duplicate_of: verdict.duplicateId,
|
|
507
|
+
confidence: verdict.confidence,
|
|
508
|
+
reason: verdict.reason,
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
const report = state.addVulnerabilityReport(input)
|
|
513
|
+
await saveArtifacts()
|
|
514
|
+
return {
|
|
515
|
+
success: true,
|
|
516
|
+
message: `${input.findingClass === 'dependency_cve' ? 'Dependency finding' : 'Vulnerability report'} '${input.title}' created successfully`,
|
|
517
|
+
report_id: report.id,
|
|
518
|
+
severity: report.severity,
|
|
519
|
+
...(report.cvss !== undefined ? { cvss_score: report.cvss } : {}),
|
|
520
|
+
...(input.findingClass === 'dependency_cve' && report.cve !== undefined ? { cve: report.cve } : {}),
|
|
521
|
+
}
|
|
522
|
+
} catch (error) {
|
|
523
|
+
return { success: false, error: `Failed to create ${input.findingClass === 'dependency_cve' ? 'dependency' : 'vulnerability'} report: ${String(error instanceof Error ? error.message : error)}` }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
ctx.tools.register(defineTool({
|
|
528
|
+
name: 'create_vulnerability_report',
|
|
529
|
+
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.",
|
|
530
|
+
parameters: {
|
|
531
|
+
title: { type: 'string', required: true, description: 'Specific finding title (e.g. "SQL Injection in /api/users login parameter").' },
|
|
532
|
+
description: { type: 'string', required: true, description: 'Concise, non-technical TL;DR (1-3 sentences).' },
|
|
533
|
+
impact: { type: 'string', required: true, description: 'The unauthorized result demonstrated by the PoC and its scope.' },
|
|
534
|
+
target: { type: 'string', required: true, description: 'Affected URL / domain / repository.' },
|
|
535
|
+
technical_analysis: { type: 'string', required: true, description: 'The mechanism and root cause.' },
|
|
536
|
+
poc_description: { type: 'string', required: true, description: 'Step-by-step reproduction (steps only, no code).' },
|
|
537
|
+
poc_script_code: { type: 'string', required: true, description: 'Working PoC (Python preferred).' },
|
|
538
|
+
remediation_steps: { type: 'string', required: true, description: 'Specific, actionable fix (prose, no code).' },
|
|
539
|
+
evidence: { type: 'string', required: true, description: 'Concrete proof: request/response excerpts, observed behavior, tool output.' },
|
|
540
|
+
assumptions: { type: 'string', required: true, description: 'Assumptions/prerequisites that make this finding impactful.' },
|
|
541
|
+
counterevidence: { type: 'string', required: true, description: 'REQUIRED: the strongest case against this finding, after actively looking for it.' },
|
|
542
|
+
confidence: { type: 'string', required: true, enum: ['high', 'medium', 'low'], description: 'Calibrated confidence.' },
|
|
543
|
+
confidence_rationale: { type: 'string', description: 'Required when confidence is not high: name the specific gap.' },
|
|
544
|
+
severity_change_conditions: { type: 'string', required: true, description: 'One concrete sentence on what evidence would raise or lower severity.' },
|
|
545
|
+
fix_effort: { type: 'string', required: true, enum: ['trivial', 'low', 'medium', 'high'], description: 'Estimated fix effort.' },
|
|
546
|
+
cvss_breakdown: { type: 'object', required: true, properties: CVSS_METRIC_SCHEMAS, additionalProperties: false, description: 'All 8 CVSS v3.1 metrics; score and severity are computed from it.' },
|
|
547
|
+
endpoint: { type: 'string', description: 'API path / Git path (e.g. /api/login).' },
|
|
548
|
+
method: { type: 'string', description: 'HTTP method when relevant.' },
|
|
549
|
+
cve: { type: 'string', description: 'CVE-YYYY-NNNNN if certain, else omit.' },
|
|
550
|
+
cwe: { type: 'string', description: 'CWE-NNN (most specific child) if certain, else omit.' },
|
|
551
|
+
code_locations: { type: 'array', items: CODE_LOCATION_SCHEMA, description: 'White-box findings: file/start_line/end_line/snippet/label/fix_before/fix_after.' },
|
|
552
|
+
fix_verification: { type: 'string', description: 'Required whenever any code_locations entry carries fix_after: the 4 ordered verification gates.' },
|
|
553
|
+
fix_pr_body: { type: 'string', description: 'Optional markdown PR-description body proposing the fix.' },
|
|
554
|
+
},
|
|
555
|
+
output: {
|
|
556
|
+
schema: {
|
|
557
|
+
type: 'object',
|
|
558
|
+
properties: {
|
|
559
|
+
success: { type: 'boolean', required: true },
|
|
560
|
+
message: { type: 'string' },
|
|
561
|
+
report_id: { type: 'string' },
|
|
562
|
+
severity: { type: 'string' },
|
|
563
|
+
cvss_score: { type: 'number' },
|
|
564
|
+
error: { type: 'string' },
|
|
565
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
566
|
+
duplicate_of: { type: 'string' },
|
|
567
|
+
confidence: { type: 'number' },
|
|
568
|
+
reason: { type: 'string' },
|
|
569
|
+
warning: { type: 'string' },
|
|
570
|
+
},
|
|
571
|
+
additionalProperties: false,
|
|
572
|
+
},
|
|
573
|
+
render: (_args, value) => {
|
|
574
|
+
const result = value as { success: boolean; report_id?: string; severity?: string; cvss_score?: number; error?: string }
|
|
575
|
+
if (!result.success) return [{ type: 'text', text: `create_vulnerability_report failed: ${result.error ?? 'unknown'}` }]
|
|
576
|
+
return [{ type: 'text', text: `filed ${result.report_id} (${String(result.severity)}, CVSS ${String(result.cvss_score)})` }]
|
|
577
|
+
},
|
|
578
|
+
presentationMeta: (args: unknown, value: unknown) => findingPresentationMeta(args, value),
|
|
579
|
+
},
|
|
580
|
+
execute: (async (rawArgs: never, rawExec: never) => {
|
|
581
|
+
const args = rawArgs as never as CreateVulnArgs
|
|
582
|
+
const exec = rawExec as never as ToolRunContextLike
|
|
583
|
+
void exec
|
|
584
|
+
const errors: string[] = []
|
|
585
|
+
const breakdown = args.cvss_breakdown as unknown as Record<CvssMetricName, string>
|
|
586
|
+
errors.push(...validateCvssBreakdown(breakdown))
|
|
587
|
+
const confidence = args.confidence.toLowerCase()
|
|
588
|
+
if (!VALID_CONFIDENCE.has(confidence)) errors.push(`Invalid confidence: ${args.confidence}. Must be one of: [high, low, medium]`)
|
|
589
|
+
const fixEffort = args.fix_effort.toLowerCase()
|
|
590
|
+
if (!VALID_FIX_EFFORT.has(fixEffort)) errors.push(`Invalid fix_effort: ${args.fix_effort}. Must be one of: [high, low, medium, trivial]`)
|
|
591
|
+
const cve = normalizeCve(args.cve)
|
|
592
|
+
if (args.cve !== undefined && cve === undefined) errors.push(`Invalid cve: ${args.cve}. Must match CVE-YYYY-NNNNN`)
|
|
593
|
+
const cwe = normalizeCwe(args.cwe)
|
|
594
|
+
if (args.cwe !== undefined && cwe === undefined) errors.push(`Invalid cwe: ${args.cwe}. Must match CWE-NNN`)
|
|
595
|
+
if (cwe !== undefined && config.strictCwe === true && BROADCWES.has(cwe)) errors.push(`${cwe} is too broad — file the most specific child CWE`)
|
|
596
|
+
const locations = normalizeCodeLocations(args.code_locations)
|
|
597
|
+
errors.push(...locations.errors)
|
|
598
|
+
const hasFixAfter = locations.locations?.some(location => typeof location['fix_after'] === 'string' && location['fix_after'] !== '') ?? false
|
|
599
|
+
if (hasFixAfter && cleanOptional(args.fix_verification) === undefined) errors.push('fix_verification is required when any code_locations entry carries fix_after')
|
|
600
|
+
if (args.confidence !== 'high' && cleanOptional(args.confidence_rationale) === undefined) errors.push('confidence_rationale is required when confidence is not high')
|
|
601
|
+
if (errors.length > 0) return { success: false, error: 'Validation failed', errors }
|
|
602
|
+
let cvssScore: number | undefined
|
|
603
|
+
let severity: string | undefined
|
|
604
|
+
try {
|
|
605
|
+
const computed = calculateCvss(breakdown)
|
|
606
|
+
cvssScore = computed.score
|
|
607
|
+
severity = computed.severity
|
|
608
|
+
} catch (error) {
|
|
609
|
+
return { success: false, error: 'Validation failed', errors: [String(error instanceof Error ? error.message : error)] }
|
|
610
|
+
}
|
|
611
|
+
const fields: Record<string, unknown> = {
|
|
612
|
+
description: args.description, impact: args.impact, target: args.target,
|
|
613
|
+
technical_analysis: args.technical_analysis, poc_description: args.poc_description,
|
|
614
|
+
poc_script_code: args.poc_script_code, remediation_steps: args.remediation_steps,
|
|
615
|
+
evidence: args.evidence, assumptions: args.assumptions, counterevidence: args.counterevidence,
|
|
616
|
+
confidence, confidence_rationale: args.confidence_rationale,
|
|
617
|
+
severity_change_conditions: args.severity_change_conditions, fix_effort: fixEffort,
|
|
618
|
+
cvss: cvssScore, cvss_breakdown: breakdown,
|
|
619
|
+
endpoint: args.endpoint, method: args.method, cve, cwe,
|
|
620
|
+
code_locations: locations.locations, fix_verification: args.fix_verification, fix_pr_body: args.fix_pr_body,
|
|
621
|
+
}
|
|
622
|
+
return persistCreate({ title: args.title, severity, findingClass: 'dynamic', fields }, {
|
|
623
|
+
title: args.title, description: args.description, impact: args.impact, target: args.target,
|
|
624
|
+
technical_analysis: args.technical_analysis, poc_description: args.poc_description,
|
|
625
|
+
poc_script_code: args.poc_script_code, endpoint: args.endpoint, method: args.method,
|
|
626
|
+
})
|
|
627
|
+
}) as unknown as ToolExecute,
|
|
628
|
+
}))
|
|
629
|
+
|
|
630
|
+
ctx.tools.register(defineTool({
|
|
631
|
+
name: 'update_vulnerability_report',
|
|
632
|
+
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.",
|
|
633
|
+
parameters: {
|
|
634
|
+
report_id: { type: 'string', required: true, description: 'Id of the report to revise (format vuln-NNNN).' },
|
|
635
|
+
update_reason: { type: 'string', required: true, description: 'What you learned that the report does not yet carry (1-2 sentences).' },
|
|
636
|
+
title: { type: 'string' }, description: { type: 'string' }, impact: { type: 'string' },
|
|
637
|
+
target: { type: 'string' }, technical_analysis: { type: 'string' }, poc_description: { type: 'string' },
|
|
638
|
+
poc_script_code: { type: 'string' }, remediation_steps: { type: 'string' }, evidence: { type: 'string' },
|
|
639
|
+
assumptions: { type: 'string' }, counterevidence: { type: 'string' },
|
|
640
|
+
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
641
|
+
confidence_rationale: { type: 'string' }, severity_change_conditions: { type: 'string' },
|
|
642
|
+
fix_effort: { type: 'string', enum: ['trivial', 'low', 'medium', 'high'] },
|
|
643
|
+
cvss_breakdown: { type: 'object', properties: CVSS_METRIC_SCHEMAS, additionalProperties: false },
|
|
644
|
+
endpoint: { type: 'string' }, method: { type: 'string' }, cve: { type: 'string' }, cwe: { type: 'string' },
|
|
645
|
+
code_locations: { type: 'array', items: CODE_LOCATION_SCHEMA },
|
|
646
|
+
fix_verification: { type: 'string' }, fix_pr_body: { type: 'string' },
|
|
647
|
+
contextual_cvss_reasoning: { type: 'string', description: 'Dependency findings only: what you observed in this codebase justifying the contextual cvss_breakdown.' },
|
|
648
|
+
},
|
|
649
|
+
output: {
|
|
650
|
+
schema: {
|
|
651
|
+
type: 'object',
|
|
652
|
+
properties: {
|
|
653
|
+
success: { type: 'boolean', required: true },
|
|
654
|
+
action: { type: 'string' },
|
|
655
|
+
message: { type: 'string' },
|
|
656
|
+
report_id: { type: 'string' },
|
|
657
|
+
updated_fields: { type: 'array', items: { type: 'string' } },
|
|
658
|
+
severity: { type: 'string' },
|
|
659
|
+
cvss_score: { type: 'number' },
|
|
660
|
+
error: { type: 'string' },
|
|
661
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
662
|
+
finding_class: { type: 'string' },
|
|
663
|
+
rejected_fields: { type: 'array', items: { type: 'string' } },
|
|
664
|
+
},
|
|
665
|
+
additionalProperties: false,
|
|
666
|
+
},
|
|
667
|
+
render: (_args, value) => {
|
|
668
|
+
const result = value as { success: boolean; report_id?: string; error?: string }
|
|
669
|
+
if (!result.success) return [{ type: 'text', text: `update_vulnerability_report failed: ${result.error ?? 'unknown'}` }]
|
|
670
|
+
return [{ type: 'text', text: `revised ${result.report_id}` }]
|
|
671
|
+
},
|
|
672
|
+
presentationMeta: (args: unknown, value: unknown) => findingPresentationMeta(args, value),
|
|
673
|
+
},
|
|
674
|
+
execute: (async (rawArgs: never, rawExec: never) => {
|
|
675
|
+
const args = rawArgs as never as UpdateArgs
|
|
676
|
+
const exec = rawExec as never as ToolRunContextLike
|
|
677
|
+
void exec
|
|
678
|
+
const reportId = cleanOptional(args.report_id)
|
|
679
|
+
const reason = cleanOptional(args.update_reason)
|
|
680
|
+
if (reportId === undefined || reason === undefined) {
|
|
681
|
+
return { success: false, error: `${reportId === undefined ? 'report_id' : 'update_reason'} cannot be empty - name the report you are revising and state what you learned that it does not yet carry` }
|
|
682
|
+
}
|
|
683
|
+
const report = state.vulnerabilityReports.find(entry => entry.id === reportId)
|
|
684
|
+
if (report === undefined) return { success: false, error: `Report with id '${reportId}' not found`, report_id: reportId }
|
|
685
|
+
const changes: Record<string, unknown> = {}
|
|
686
|
+
for (const [key, value] of Object.entries(args)) {
|
|
687
|
+
if (key === 'report_id' || key === 'update_reason') continue
|
|
688
|
+
const cleaned = typeof value === 'string' ? cleanOptional(value) : value
|
|
689
|
+
if (cleaned === undefined) continue
|
|
690
|
+
changes[key] = cleaned
|
|
691
|
+
}
|
|
692
|
+
if (Object.keys(changes).length === 0) {
|
|
693
|
+
return { success: false, error: 'No fields to update - pass at least one field you want to replace', report_id: reportId }
|
|
694
|
+
}
|
|
695
|
+
// Cross-class guard (tool.py `_fit_revision_to_class`).
|
|
696
|
+
const findingClass = (typeof report.finding_class === 'string' ? report.finding_class : (report.dependency_metadata !== undefined ? 'dependency_cve' : 'dynamic')) as string
|
|
697
|
+
const rejected: string[] = []
|
|
698
|
+
if (findingClass === 'dependency_cve') {
|
|
699
|
+
for (const field of DYNAMIC_ONLY_UPDATE_FIELDS) {
|
|
700
|
+
if (changes[field] !== undefined) rejected.push(field)
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
for (const field of DEPENDENCY_ONLY_UPDATE_FIELDS) {
|
|
704
|
+
if (changes[field] !== undefined) rejected.push(field)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (rejected.length > 0) {
|
|
708
|
+
return {
|
|
709
|
+
success: false,
|
|
710
|
+
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.`,
|
|
711
|
+
report_id: reportId,
|
|
712
|
+
finding_class: findingClass,
|
|
713
|
+
rejected_fields: rejected,
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Dependency re-rating: breakdown must carry contextual reasoning.
|
|
717
|
+
if (changes['cvss_breakdown'] !== undefined && findingClass === 'dependency_cve') {
|
|
718
|
+
if (changes['contextual_cvss_reasoning'] === undefined && report.dependency_metadata === undefined) {
|
|
719
|
+
return { success: false, error: 'Validation failed', errors: ['contextual_cvss_reasoning is required when re-rating a dependency finding'], report_id: reportId }
|
|
720
|
+
}
|
|
721
|
+
const errors = validateCvssBreakdown(changes['cvss_breakdown'] as Record<string, unknown>)
|
|
722
|
+
if (errors.length > 0) return { success: false, error: 'Validation failed', errors, report_id: reportId }
|
|
723
|
+
const computed = calculateCvss(changes['cvss_breakdown'] as Record<CvssMetricName, string>)
|
|
724
|
+
changes['cvss'] = computed.score
|
|
725
|
+
changes['severity'] = computed.severity
|
|
726
|
+
} else if (changes['cvss_breakdown'] !== undefined) {
|
|
727
|
+
const errors = validateCvssBreakdown(changes['cvss_breakdown'] as Record<string, unknown>)
|
|
728
|
+
if (errors.length > 0) return { success: false, error: 'Validation failed', errors, report_id: reportId }
|
|
729
|
+
const computed = calculateCvss(changes['cvss_breakdown'] as Record<CvssMetricName, string>)
|
|
730
|
+
changes['cvss'] = computed.score
|
|
731
|
+
changes['severity'] = computed.severity
|
|
732
|
+
}
|
|
733
|
+
const outcome = state.updateVulnerabilityReport(reportId, changes, reason)
|
|
734
|
+
if ('noop' in outcome) {
|
|
735
|
+
return { success: false, error: `Report '${reportId}' already says this - nothing in your update changes it`, report_id: reportId }
|
|
736
|
+
}
|
|
737
|
+
await saveArtifacts()
|
|
738
|
+
const updated = outcome.report
|
|
739
|
+
return {
|
|
740
|
+
success: true,
|
|
741
|
+
action: 'updated',
|
|
742
|
+
message: `Report '${reportId}' now carries your revision. Do not file it again.`,
|
|
743
|
+
report_id: reportId,
|
|
744
|
+
updated_fields: (updated['update_history'] as { fields: string[] }[] | undefined)?.at(-1)?.fields ?? [],
|
|
745
|
+
severity: updated.severity,
|
|
746
|
+
...(updated.cvss !== undefined ? { cvss_score: updated.cvss as number } : {}),
|
|
747
|
+
}
|
|
748
|
+
}) as unknown as ToolExecute,
|
|
749
|
+
}))
|
|
750
|
+
|
|
751
|
+
ctx.tools.register(defineTool({
|
|
752
|
+
name: 'create_dependency_report',
|
|
753
|
+
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.",
|
|
754
|
+
parameters: {
|
|
755
|
+
title: { type: 'string', required: true, description: 'e.g. "CVE-2024-1234 in lodash 4.17.20 (prototype pollution)".' },
|
|
756
|
+
description: { type: 'string', required: true, description: 'What the CVE is and why the pinned version is affected.' },
|
|
757
|
+
target: { type: 'string', required: true, description: 'Affected repository / project / manifest.' },
|
|
758
|
+
cve: { type: 'string', required: true, description: 'CVE-YYYY-NNNNN — required and verified.' },
|
|
759
|
+
package_name: { type: 'string', required: true, description: 'Affected package name (e.g. lodash).' },
|
|
760
|
+
installed_version: { type: 'string', required: true, description: 'The version currently pinned/installed.' },
|
|
761
|
+
advisory_cvss: { type: 'number', required: true, description: 'Published advisory base score (0.0-10.0).' },
|
|
762
|
+
impact: { type: 'string', required: true, description: 'What the CVE enables; business risk in this context.' },
|
|
763
|
+
remediation_steps: { type: 'string', required: true, description: 'How to fix (usually upgrade to a fixed version).' },
|
|
764
|
+
assumptions: { type: 'string', required: true, description: 'Exploitability/reachability assumptions & confidence.' },
|
|
765
|
+
package_ecosystem: { type: 'string', required: true, description: 'e.g. npm / pypi / maven / go.' },
|
|
766
|
+
manifest_path: { type: 'string', description: 'Repo-relative lockfile/manifest path (required).' },
|
|
767
|
+
fixed_version: { type: 'string', description: 'First non-vulnerable version, if known.' },
|
|
768
|
+
cwe: { type: 'string', description: 'CWE-NNN (most specific) if certain.' },
|
|
769
|
+
technical_analysis: { type: 'string', description: 'Optional deeper mechanism/root-cause detail.' },
|
|
770
|
+
fix_effort: { type: 'string', enum: ['trivial', 'low', 'medium', 'high'], description: 'Default low.' },
|
|
771
|
+
introduced_by: { type: 'string', description: 'For a transitive dep, the direct dependency pulling it in (name@version).' },
|
|
772
|
+
dependency_path: { type: 'string', description: 'Resolution chain joined with " > ".' },
|
|
773
|
+
reachability: { type: 'string', enum: ['not_imported', 'imported', 'vulnerable_symbol_used', 'reachable_call_path', 'unknown'], description: 'Usage-evidence level (default unknown).' },
|
|
774
|
+
reachability_evidence: { type: 'string', description: 'Concrete proof for the level (required).' },
|
|
775
|
+
contextual_cvss_breakdown: { type: 'object', properties: CVSS_METRIC_SCHEMAS, additionalProperties: false, description: 'Full CVSS v3.1 rating of this CVE in this codebase (required).' },
|
|
776
|
+
contextual_cvss_reasoning: { type: 'string', description: '2-4 verifiable sentences with file:line hops (required).' },
|
|
777
|
+
},
|
|
778
|
+
output: {
|
|
779
|
+
schema: {
|
|
780
|
+
type: 'object',
|
|
781
|
+
properties: {
|
|
782
|
+
success: { type: 'boolean', required: true },
|
|
783
|
+
message: { type: 'string' },
|
|
784
|
+
report_id: { type: 'string' },
|
|
785
|
+
severity: { type: 'string' },
|
|
786
|
+
cve: { type: 'string' },
|
|
787
|
+
error: { type: 'string' },
|
|
788
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
789
|
+
duplicate_of: { type: 'string' },
|
|
790
|
+
confidence: { type: 'number' },
|
|
791
|
+
reason: { type: 'string' },
|
|
792
|
+
warning: { type: 'string' },
|
|
793
|
+
},
|
|
794
|
+
additionalProperties: false,
|
|
795
|
+
},
|
|
796
|
+
render: (_args, value) => {
|
|
797
|
+
const result = value as { success: boolean; report_id?: string; severity?: string; cve?: string; error?: string }
|
|
798
|
+
if (!result.success) return [{ type: 'text', text: `create_dependency_report failed: ${result.error ?? 'unknown'}` }]
|
|
799
|
+
return [{ type: 'text', text: `filed ${result.report_id} (${String(result.severity)}) for ${result.cve ?? 'CVE'}` }]
|
|
800
|
+
},
|
|
801
|
+
presentationMeta: (args: unknown, value: unknown) => findingPresentationMeta(args, value),
|
|
802
|
+
},
|
|
803
|
+
execute: (async (rawArgs: never, _rawExec: never) => {
|
|
804
|
+
const args = rawArgs as never as CreateDepArgs
|
|
805
|
+
const errors: string[] = []
|
|
806
|
+
const requireText = (value: string | undefined, name: string): string | undefined => {
|
|
807
|
+
const cleaned = cleanOptional(value)
|
|
808
|
+
if (cleaned === undefined) errors.push(`${name} cannot be empty`)
|
|
809
|
+
return cleaned
|
|
810
|
+
}
|
|
811
|
+
const packageName = requireText(args.package_name, 'package_name')
|
|
812
|
+
const installedVersion = requireText(args.installed_version, 'installed_version')
|
|
813
|
+
const packageEcosystem = requireText(args.package_ecosystem, 'package_ecosystem')
|
|
814
|
+
const manifestPath = requireText(args.manifest_path, 'manifest_path')
|
|
815
|
+
const reachabilityEvidence = requireText(args.reachability_evidence, 'reachability_evidence')
|
|
816
|
+
const contextualReasoning = requireText(args.contextual_cvss_reasoning, 'contextual_cvss_reasoning')
|
|
817
|
+
const cve = normalizeCve(args.cve)
|
|
818
|
+
if (cve === undefined) errors.push(`Invalid cve: ${String(args.cve)}. Must match CVE-YYYY-NNNNN`)
|
|
819
|
+
const cwe = normalizeCwe(args.cwe)
|
|
820
|
+
if (args.cwe !== undefined && cwe === undefined) errors.push(`Invalid cwe: ${args.cwe}. Must match CWE-NNN`)
|
|
821
|
+
const advisory = args.advisory_cvss
|
|
822
|
+
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`)
|
|
823
|
+
const reachability = (args.reachability ?? 'unknown').toLowerCase()
|
|
824
|
+
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]`)
|
|
825
|
+
const fixEffort = (args.fix_effort ?? 'low').toLowerCase()
|
|
826
|
+
if (!VALID_FIX_EFFORT.has(fixEffort)) errors.push(`Invalid fix_effort: ${String(args.fix_effort)}. Must be one of: [high, low, medium, trivial]`)
|
|
827
|
+
if (manifestPath !== undefined && (manifestPath.startsWith('/') || manifestPath.includes('\\') || manifestPath.split('/').some(part => part === '' || part === '.' || part === '..'))) {
|
|
828
|
+
errors.push(`Invalid manifest_path: ${manifestPath}. Must be a repo-relative path without absolute or traversal segments`)
|
|
829
|
+
}
|
|
830
|
+
let contextual: Parameters<typeof buildDependencyMetadata>[0]['contextual'] | undefined
|
|
831
|
+
const hasContextualBreakdown = args.contextual_cvss_breakdown !== undefined
|
|
832
|
+
if (hasContextualBreakdown || contextualReasoning !== undefined) {
|
|
833
|
+
if (!hasContextualBreakdown) errors.push('contextual_cvss_breakdown is required when contextual_cvss_reasoning is given')
|
|
834
|
+
if (contextualReasoning === undefined) errors.push('contextual_cvss_reasoning is required when contextual_cvss_breakdown is given')
|
|
835
|
+
if (hasContextualBreakdown && contextualReasoning !== undefined) {
|
|
836
|
+
const breakdown = args.contextual_cvss_breakdown as unknown as Record<CvssMetricName, string>
|
|
837
|
+
errors.push(...validateCvssBreakdown(breakdown))
|
|
838
|
+
const computed = calculateCvss(breakdown)
|
|
839
|
+
contextual = { breakdown, score: computed.score, vector: computed.vector, reasoning: contextualReasoning }
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (errors.length > 0) return { success: false, error: 'Validation failed', errors }
|
|
843
|
+
const severity = contextual !== undefined
|
|
844
|
+
? calculateCvss(contextual.breakdown as CvssBreakdownArgs).severity
|
|
845
|
+
: dependencySeverity(advisory)
|
|
846
|
+
const metadata = buildDependencyMetadata({
|
|
847
|
+
packageName: packageName as string,
|
|
848
|
+
installedVersion: installedVersion as string,
|
|
849
|
+
advisoryCvss: advisory,
|
|
850
|
+
packageEcosystem: packageEcosystem as string,
|
|
851
|
+
manifestPath: manifestPath as string,
|
|
852
|
+
fixedVersion: cleanOptional(args.fixed_version),
|
|
853
|
+
introducedBy: cleanOptional(args.introduced_by),
|
|
854
|
+
dependencyPath: cleanOptional(args.dependency_path),
|
|
855
|
+
reachability,
|
|
856
|
+
reachabilityEvidence,
|
|
857
|
+
contextual,
|
|
858
|
+
})
|
|
859
|
+
const fields: Record<string, unknown> = {
|
|
860
|
+
description: args.description, impact: args.impact, target: args.target,
|
|
861
|
+
technical_analysis: args.technical_analysis, remediation_steps: args.remediation_steps,
|
|
862
|
+
assumptions: args.assumptions, fix_effort: fixEffort,
|
|
863
|
+
cve, cwe,
|
|
864
|
+
cvss: contextual !== undefined ? contextual.score : advisory,
|
|
865
|
+
}
|
|
866
|
+
return persistCreate({ title: args.title, severity, findingClass: 'dependency_cve', dependencyMetadata: metadata, fields }, {
|
|
867
|
+
title: args.title, description: args.description, target: args.target, cve,
|
|
868
|
+
dependency_metadata: metadata, technical_analysis: args.technical_analysis,
|
|
869
|
+
})
|
|
870
|
+
}) as unknown as ToolExecute,
|
|
871
|
+
}))
|
|
872
|
+
|
|
873
|
+
ctx.tools.register(defineTool({
|
|
874
|
+
name: 'list_reports',
|
|
875
|
+
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.",
|
|
876
|
+
parameters: {
|
|
877
|
+
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info', 'none'], description: 'Filter to one severity.' },
|
|
878
|
+
finding_class: { type: 'string', enum: ['dynamic', 'dependency_cve'], description: 'dynamic or dependency_cve.' },
|
|
879
|
+
target: { type: 'string', description: 'Substring match against target/endpoint.' },
|
|
880
|
+
search: { type: 'string', description: 'Substring match against title and description.' },
|
|
881
|
+
include_details: { type: 'boolean', description: 'Full report bodies instead of compact entries (default false).' },
|
|
882
|
+
},
|
|
883
|
+
output: {
|
|
884
|
+
schema: {
|
|
885
|
+
type: 'object',
|
|
886
|
+
properties: {
|
|
887
|
+
success: { type: 'boolean', required: true },
|
|
888
|
+
reports: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
|
|
889
|
+
filtered_count: { type: 'integer', required: true },
|
|
890
|
+
total_count: { type: 'integer', required: true },
|
|
891
|
+
severity_counts: { type: 'object', properties: {}, additionalProperties: true, required: true },
|
|
892
|
+
warning: { type: 'string' },
|
|
893
|
+
error: { type: 'string' },
|
|
894
|
+
},
|
|
895
|
+
additionalProperties: false,
|
|
896
|
+
},
|
|
897
|
+
render: (_args, value) => {
|
|
898
|
+
const result = value as { reports: unknown[]; total_count: number }
|
|
899
|
+
return [{ type: 'text', text: `${String(result.reports.length)} of ${String(result.total_count)} report(s)` }]
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
execute: (async (rawArgs: never, rawExec: never) => {
|
|
903
|
+
const args = rawArgs as never as ListArgs
|
|
904
|
+
const exec = rawExec as never as ToolRunContextLike
|
|
905
|
+
void exec
|
|
906
|
+
const severityFilter = cleanOptional(args.severity)?.toLowerCase()
|
|
907
|
+
const classFilter = cleanOptional(args.finding_class)?.toLowerCase()
|
|
908
|
+
const targetFilter = cleanOptional(args.target)?.toLowerCase()
|
|
909
|
+
const searchFilter = cleanOptional(args.search)?.toLowerCase()
|
|
910
|
+
if (severityFilter !== undefined && !VALID_SEVERITIES.has(severityFilter)) {
|
|
911
|
+
return { success: false, reports: [], filtered_count: 0, total_count: 0, severity_counts: {}, error: `Invalid severity: ${severityFilter}. Must be one of: [critical, high, info, low, medium, none]` }
|
|
912
|
+
}
|
|
913
|
+
if (classFilter !== undefined && !VALID_FINDING_CLASSES.has(classFilter)) {
|
|
914
|
+
return { success: false, reports: [], filtered_count: 0, total_count: 0, severity_counts: {}, error: `Invalid finding_class: ${classFilter}. Must be one of: [dependency_cve, dynamic]` }
|
|
915
|
+
}
|
|
916
|
+
const severityCounts: Record<string, number> = {}
|
|
917
|
+
for (const report of state.vulnerabilityReports) {
|
|
918
|
+
const key = String(report.severity)
|
|
919
|
+
severityCounts[key] = (severityCounts[key] ?? 0) + 1
|
|
920
|
+
}
|
|
921
|
+
const filtered = state.vulnerabilityReports.filter(report => {
|
|
922
|
+
if (severityFilter !== undefined && report.severity !== severityFilter) return false
|
|
923
|
+
if (classFilter !== undefined && String(report.finding_class) !== classFilter) return false
|
|
924
|
+
if (targetFilter !== undefined) {
|
|
925
|
+
const target = String(report.target ?? '').toLowerCase()
|
|
926
|
+
const endpoint = String(report.endpoint ?? '').toLowerCase()
|
|
927
|
+
if (!target.includes(targetFilter) && !endpoint.includes(targetFilter)) return false
|
|
928
|
+
}
|
|
929
|
+
if (searchFilter !== undefined) {
|
|
930
|
+
const title = String(report.title ?? '').toLowerCase()
|
|
931
|
+
const description = String(report.description ?? '').toLowerCase()
|
|
932
|
+
if (!title.includes(searchFilter) && !description.includes(searchFilter)) return false
|
|
933
|
+
}
|
|
934
|
+
return true
|
|
935
|
+
})
|
|
936
|
+
filtered.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.id.localeCompare(b.id))
|
|
937
|
+
const entries = filtered.map(report => args.include_details === true ? { ...report as unknown as Record<string, unknown> } : summarize(report))
|
|
938
|
+
return {
|
|
939
|
+
success: true,
|
|
940
|
+
reports: entries,
|
|
941
|
+
filtered_count: filtered.length,
|
|
942
|
+
total_count: state.vulnerabilityReports.length,
|
|
943
|
+
severity_counts: severityCounts,
|
|
944
|
+
}
|
|
945
|
+
}) as unknown as ToolExecute,
|
|
946
|
+
}))
|
|
947
|
+
|
|
948
|
+
ctx.tools.register(defineTool({
|
|
949
|
+
name: 'get_report',
|
|
950
|
+
description: 'Fetch one vulnerability report by its id (e.g. vuln-0001). Read-only; use list_reports to find ids.',
|
|
951
|
+
parameters: {
|
|
952
|
+
report_id: { type: 'string', required: true, description: "Report id from list_reports or a create response (format 'vuln-NNNN')." },
|
|
953
|
+
},
|
|
954
|
+
output: {
|
|
955
|
+
schema: {
|
|
956
|
+
type: 'object',
|
|
957
|
+
properties: {
|
|
958
|
+
success: { type: 'boolean', required: true },
|
|
959
|
+
report: { type: 'object', properties: {}, additionalProperties: true },
|
|
960
|
+
error: { type: 'string' },
|
|
961
|
+
},
|
|
962
|
+
additionalProperties: false,
|
|
963
|
+
},
|
|
964
|
+
render: (_args, value) => {
|
|
965
|
+
const result = value as { success: boolean; report?: { id?: string } | null; error?: string }
|
|
966
|
+
if (!result.success) return [{ type: 'text', text: `get_report failed: ${result.error ?? 'unknown'}` }]
|
|
967
|
+
return [{ type: 'text', text: `report ${String(result.report?.id)}` }]
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
execute: (async (rawArgs: never, rawExec: never) => {
|
|
971
|
+
const args = rawArgs as never as GetArgs
|
|
972
|
+
const exec = rawExec as never as ToolRunContextLike
|
|
973
|
+
void exec
|
|
974
|
+
const reportId = cleanOptional(args.report_id)
|
|
975
|
+
if (reportId === undefined) return { success: false, error: 'report_id cannot be empty' }
|
|
976
|
+
const report = state.vulnerabilityReports.find(entry => entry.id === reportId)
|
|
977
|
+
if (report === undefined) return { success: false, error: `Report with id '${reportId}' not found` }
|
|
978
|
+
return { success: true, report: { ...report as unknown as Record<string, unknown> } }
|
|
979
|
+
}) as unknown as ToolExecute,
|
|
980
|
+
}))
|
|
981
|
+
|
|
982
|
+
/** Compact summary entry (tool.py `_to_report_summary_entry` field order). */
|
|
983
|
+
function summarize(report: VulnerabilityReport): Record<string, unknown> {
|
|
984
|
+
const record = report as unknown as Record<string, unknown>
|
|
985
|
+
const summary: Record<string, unknown> = {}
|
|
986
|
+
for (const field of ['id', 'title', 'severity', 'cvss', 'confidence', 'finding_class', 'cve', 'cwe', 'target', 'endpoint', 'method', 'fix_effort', 'agent_name', 'timestamp']) {
|
|
987
|
+
const value = record[field]
|
|
988
|
+
if (value !== null && value !== undefined && value !== '') summary[field] = value
|
|
989
|
+
}
|
|
990
|
+
const description = record['description']
|
|
991
|
+
if (typeof description === 'string' && description !== '') {
|
|
992
|
+
summary['description_preview'] = description.length > 280 ? `${description.slice(0, 280)}...` : description
|
|
993
|
+
}
|
|
994
|
+
return summary
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Replay-safe toolview meta for finding cards (ui FindingRow consumes
|
|
999
|
+
* severity/report_id/title from `result.meta`). Pure over (args, value);
|
|
1000
|
+
* undefined keys are omitted so the snapshot stays lossless JSON.
|
|
1001
|
+
*/
|
|
1002
|
+
function findingPresentationMeta(args: unknown, value: unknown): Record<string, string> {
|
|
1003
|
+
const meta: Record<string, string> = {}
|
|
1004
|
+
const title = (args as { title?: string }).title
|
|
1005
|
+
if (title !== undefined) meta['title'] = title
|
|
1006
|
+
const severity = (value as { severity?: string }).severity
|
|
1007
|
+
if (severity !== undefined) meta['severity'] = severity
|
|
1008
|
+
const reportId = (value as { report_id?: string }).report_id
|
|
1009
|
+
if (reportId !== undefined) meta['report_id'] = reportId
|
|
1010
|
+
return meta
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const composeFinalReport = (sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }): string =>
|
|
1014
|
+
['# Executive Summary', sections.executiveSummary, '', '# Methodology', sections.methodology, '', '# Technical Analysis', sections.technicalAnalysis, '', '# Recommendations', sections.recommendations].join('\n')
|
|
1015
|
+
|
|
1016
|
+
const handle: ReportingHandle = {
|
|
1017
|
+
state,
|
|
1018
|
+
runDir,
|
|
1019
|
+
/** Mark the scan complete and write the final report + artifacts. */
|
|
1020
|
+
async finishScan(sections: { readonly executiveSummary: string; readonly methodology: string; readonly technicalAnalysis: string; readonly recommendations: string }, status = 'completed'): Promise<void> {
|
|
1021
|
+
state.finalScanResult = composeFinalReport(sections)
|
|
1022
|
+
state.complete(status)
|
|
1023
|
+
// strix `update_scan_final_fields` (finish tool → state.py :560-582).
|
|
1024
|
+
scanResults = {
|
|
1025
|
+
scan_completed: true,
|
|
1026
|
+
executive_summary: sections.executiveSummary,
|
|
1027
|
+
methodology: sections.methodology,
|
|
1028
|
+
technical_analysis: sections.technicalAnalysis,
|
|
1029
|
+
recommendations: sections.recommendations,
|
|
1030
|
+
success: status === 'completed',
|
|
1031
|
+
}
|
|
1032
|
+
const dir = await ensureRunDir()
|
|
1033
|
+
await writeExecutiveReport(dir, state.finalScanResult, formatTimestamp(new Date()))
|
|
1034
|
+
await saveArtifacts()
|
|
1035
|
+
},
|
|
1036
|
+
/** Dump the current run.json record (golden/test helper). */
|
|
1037
|
+
async writeNow(): Promise<void> {
|
|
1038
|
+
await saveArtifacts()
|
|
1039
|
+
},
|
|
1040
|
+
readRaw: async (relative: string): Promise<string> => readFile(join(runDir, relative), 'utf8'),
|
|
1041
|
+
}
|
|
1042
|
+
ctx.provide('pentestReporting', handle)
|
|
1043
|
+
return handle
|
|
1044
|
+
}
|
|
1045
|
+
|