@gpzhang2001/sharpkit-reporting 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+