solidity-argus 0.3.3 → 0.3.4
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/package.json +1 -1
- package/src/agents/argus-prompt.ts +21 -8
- package/src/agents/scribe-prompt.ts +9 -5
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +5 -0
- package/src/create-hooks.ts +78 -22
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/event-sink.ts +171 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +175 -0
- package/src/features/persistent-state/run-journal.ts +1 -1
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +74 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/index.ts +14 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +356 -0
- package/src/tools/report-generator-tool.ts +569 -31
- package/src/tools/solodit-search-tool.ts +11 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
1
2
|
import path from "node:path"
|
|
2
3
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
3
4
|
import { loadArgusConfig } from "../config/loader"
|
|
4
5
|
import type { ArgusConfig } from "../config/types"
|
|
6
|
+
import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
|
|
7
|
+
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
5
8
|
import { createLogger } from "../shared/logger"
|
|
6
9
|
import { resolveProjectDir } from "../shared/project-utils"
|
|
10
|
+
import { resolveReportPath } from "../shared/report-path-resolver"
|
|
11
|
+
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
12
|
+
import { stableHash } from "../state/projectors"
|
|
13
|
+
import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
|
|
7
14
|
import type { AuditState, Finding, FindingSeverity } from "../state/types"
|
|
8
15
|
|
|
9
16
|
type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
|
|
@@ -13,7 +20,9 @@ type ReportGeneratorArgs = {
|
|
|
13
20
|
scope: string[]
|
|
14
21
|
include_executive_summary?: boolean
|
|
15
22
|
severity_threshold?: SeverityThreshold
|
|
16
|
-
|
|
23
|
+
quality_gate_policy?: QualityGatePolicy
|
|
24
|
+
report_input?: string
|
|
25
|
+
audit_state?: string
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
type FindingsCount = {
|
|
@@ -28,13 +37,77 @@ export type ReportGenerationResult = {
|
|
|
28
37
|
report: string
|
|
29
38
|
findingsCount: FindingsCount
|
|
30
39
|
filename: string
|
|
40
|
+
contentHash: string
|
|
41
|
+
qualityGates: ReportQualityValidation
|
|
42
|
+
contractDiagnostics: DropDiagnostic[]
|
|
31
43
|
filePath?: string
|
|
44
|
+
error?: { code: string; message: string }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type QualityGatePolicy = "warn" | "strict-fail"
|
|
48
|
+
|
|
49
|
+
type ReportQualityViolation = {
|
|
50
|
+
findingId: string
|
|
51
|
+
code: string
|
|
52
|
+
message: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ReportQualityValidation = {
|
|
56
|
+
passed: boolean
|
|
57
|
+
violations: ReportQualityViolation[]
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
export type ReportGenerationDependencies = {
|
|
35
61
|
loadConfig?: (projectDir: string) => ArgusConfig
|
|
36
62
|
}
|
|
37
63
|
|
|
64
|
+
export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
|
|
65
|
+
|
|
66
|
+
const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract the run_id from report metadata embedded as an HTML comment.
|
|
70
|
+
* Returns null if no metadata is found or run_id is missing.
|
|
71
|
+
*/
|
|
72
|
+
export function extractReportRunId(content: string): string | null {
|
|
73
|
+
const match = content.match(REPORT_METADATA_REGEX)
|
|
74
|
+
if (!match?.[1]) return null
|
|
75
|
+
try {
|
|
76
|
+
const metadata = JSON.parse(match[1])
|
|
77
|
+
return typeof metadata.run_id === "string" ? metadata.run_id : null
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildReportMetadataComment(runId: string): string {
|
|
84
|
+
const metadata = {
|
|
85
|
+
run_id: runId,
|
|
86
|
+
policy_version: SINGLE_WRITER_POLICY_VERSION,
|
|
87
|
+
}
|
|
88
|
+
return `<!-- argus:report_metadata ${JSON.stringify(metadata)} -->`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function checkDuplicateWrite(
|
|
92
|
+
filePath: string,
|
|
93
|
+
runId: string,
|
|
94
|
+
): Promise<{ code: string; message: string } | null> {
|
|
95
|
+
if (!existsSync(filePath)) return null
|
|
96
|
+
try {
|
|
97
|
+
const existingContent = await Bun.file(filePath).text()
|
|
98
|
+
const existingRunId = extractReportRunId(existingContent)
|
|
99
|
+
if (existingRunId === runId) {
|
|
100
|
+
return {
|
|
101
|
+
code: "DUPLICATE_WRITE_ATTEMPT",
|
|
102
|
+
message: `Report for run_id "${runId}" already exists at ${filePath}. Single-writer policy (v${SINGLE_WRITER_POLICY_VERSION}) prevents duplicate writes for the same run.`,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Cannot read existing file; allow write
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
38
111
|
const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
|
|
39
112
|
|
|
40
113
|
const SEVERITY_PREFIX: Record<FindingSeverity, string> = {
|
|
@@ -61,6 +134,24 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
|
|
|
61
134
|
Informational: 1,
|
|
62
135
|
}
|
|
63
136
|
|
|
137
|
+
const SEVERITY_RANK: Record<FindingSeverity, number> = {
|
|
138
|
+
Critical: 0,
|
|
139
|
+
High: 1,
|
|
140
|
+
Medium: 2,
|
|
141
|
+
Low: 3,
|
|
142
|
+
Informational: 4,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
|
|
146
|
+
const MISSING_RECOMMENDATION_TEXT =
|
|
147
|
+
"Recommendation details were not provided in the finding payload."
|
|
148
|
+
|
|
149
|
+
type ReportFindingFields = {
|
|
150
|
+
impact?: string
|
|
151
|
+
recommendation?: string
|
|
152
|
+
proofOfConcept?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
64
155
|
function emptyCounts(): FindingsCount {
|
|
65
156
|
return {
|
|
66
157
|
critical: 0,
|
|
@@ -252,21 +343,237 @@ function normalizeFinding(f: Record<string, unknown>): Finding {
|
|
|
252
343
|
source,
|
|
253
344
|
remediation: typeof f.remediation === "string" ? f.remediation : undefined,
|
|
254
345
|
exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
|
|
346
|
+
...(typeof f.impact === "string" ? { impact: f.impact } : {}),
|
|
347
|
+
...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
|
|
348
|
+
...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
|
|
349
|
+
...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
|
|
350
|
+
} as Finding
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export type ParseAuditStateOptions = {
|
|
354
|
+
dropPolicy?: DropPolicy
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type ParseAuditStateResult = {
|
|
358
|
+
state: AuditState
|
|
359
|
+
diagnostics: DropDiagnostic[]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
type ParseReportInputResult = {
|
|
363
|
+
reportInput: ReportInput
|
|
364
|
+
diagnostics: DropDiagnostic[]
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
|
|
368
|
+
return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function throwContractMismatch(message: string, diagnostics: DropDiagnostic[]): never {
|
|
372
|
+
const details = diagnosticsSummary(diagnostics)
|
|
373
|
+
const fullMessage = details.length > 0 ? `${message}. Diagnostics: ${details}` : message
|
|
374
|
+
throw new Error(fullMessage)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function reportInputToAuditState(reportInput: ReportInput): AuditState {
|
|
378
|
+
return {
|
|
379
|
+
sessionId: reportInput.session_id,
|
|
380
|
+
projectDir: reportInput.projectDir,
|
|
381
|
+
contractsReviewed: Array.from(
|
|
382
|
+
new Set(reportInput.findings.map((finding) => finding.file)),
|
|
383
|
+
).sort((a, b) => a.localeCompare(b)),
|
|
384
|
+
findings: reportInput.findings,
|
|
385
|
+
toolsExecuted: reportInput.toolsExecuted,
|
|
386
|
+
currentPhase: "complete",
|
|
387
|
+
scope: reportInput.scope,
|
|
388
|
+
startTime: 0,
|
|
389
|
+
soloditResults: reportInput.soloditResults,
|
|
390
|
+
fuzzCounterexamples: reportInput.fuzzCounterexamples,
|
|
391
|
+
coverageReport: reportInput.coverageReport,
|
|
392
|
+
gasHotspots: reportInput.gasHotspots,
|
|
393
|
+
proxyContracts: reportInput.proxyContracts,
|
|
394
|
+
patternVersion: reportInput.patternVersion,
|
|
395
|
+
skillsLoaded: reportInput.skillsLoaded,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function buildLegacyCompatibleReportInput(
|
|
400
|
+
state: AuditState,
|
|
401
|
+
context: ToolContext,
|
|
402
|
+
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
403
|
+
): ReportInput {
|
|
404
|
+
diagnostics.warn(
|
|
405
|
+
"REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
|
|
406
|
+
"Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
|
|
407
|
+
"audit_state",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
const runId = state.sessionId || context.sessionID || "legacy-run"
|
|
411
|
+
const sessionId = state.sessionId || context.sessionID || runId
|
|
412
|
+
|
|
413
|
+
if (!state.sessionId) {
|
|
414
|
+
diagnostics.warn(
|
|
415
|
+
"REPORT_INPUT_SYNTHESIZED_SESSION",
|
|
416
|
+
"Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
|
|
417
|
+
"session_id",
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
if (!state.projectDir) {
|
|
421
|
+
diagnostics.warn(
|
|
422
|
+
"REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
|
|
423
|
+
"Legacy payload missing projectDir; synthesized projectDir from tool context.",
|
|
424
|
+
"projectDir",
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const canonicalFindings = state.findings
|
|
429
|
+
.map((finding, index) => {
|
|
430
|
+
const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
|
|
431
|
+
for (const diag of normalized.diagnostics) {
|
|
432
|
+
diagnostics.warn(
|
|
433
|
+
"REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
|
|
434
|
+
`[index:${index}] ${diag.message}`,
|
|
435
|
+
diag.field,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
return normalized.data
|
|
439
|
+
})
|
|
440
|
+
.filter((finding) => finding.check.length > 0 && finding.file.length > 0)
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
run_id: runId,
|
|
444
|
+
seq: state.toolsExecuted.length + canonicalFindings.length,
|
|
445
|
+
session_id: sessionId,
|
|
446
|
+
tool_call_id: "legacy-adapter",
|
|
447
|
+
source: "report-generator-legacy-adapter",
|
|
448
|
+
schema_version: SCHEMA_VERSION,
|
|
449
|
+
projectDir: state.projectDir || resolveProjectDir(context),
|
|
450
|
+
findings: canonicalFindings,
|
|
451
|
+
toolsExecuted: state.toolsExecuted.map((toolExec) => ({
|
|
452
|
+
...toolExec,
|
|
453
|
+
run_id: runId,
|
|
454
|
+
schema_version: SCHEMA_VERSION,
|
|
455
|
+
})),
|
|
456
|
+
scope: state.scope,
|
|
457
|
+
soloditResults: state.soloditResults,
|
|
458
|
+
fuzzCounterexamples: state.fuzzCounterexamples,
|
|
459
|
+
coverageReport: state.coverageReport,
|
|
460
|
+
gasHotspots: state.gasHotspots,
|
|
461
|
+
proxyContracts: state.proxyContracts,
|
|
462
|
+
patternVersion: state.patternVersion,
|
|
463
|
+
skillsLoaded: state.skillsLoaded,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseReportInputPayload(
|
|
468
|
+
args: ReportGeneratorArgs,
|
|
469
|
+
context: ToolContext,
|
|
470
|
+
): ParseReportInputResult {
|
|
471
|
+
const diagnostics = createDropDiagnosticsCollector(
|
|
472
|
+
"warn",
|
|
473
|
+
"report-generator",
|
|
474
|
+
"argus_generate_report",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
|
|
478
|
+
let parsed: unknown
|
|
479
|
+
try {
|
|
480
|
+
parsed = JSON.parse(args.report_input)
|
|
481
|
+
} catch {
|
|
482
|
+
diagnostics.error(
|
|
483
|
+
"REPORT_INPUT_MALFORMED_JSON",
|
|
484
|
+
"report_input is not valid JSON. Expected serialized ReportInput object.",
|
|
485
|
+
"report_input",
|
|
486
|
+
)
|
|
487
|
+
throwContractMismatch(
|
|
488
|
+
"ReportInput contract mismatch: malformed report_input JSON",
|
|
489
|
+
diagnostics.getDiagnostics(),
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const validation = validateReportInput(parsed)
|
|
494
|
+
if (!validation.success) {
|
|
495
|
+
for (const error of validation.errors) {
|
|
496
|
+
diagnostics.error(
|
|
497
|
+
"REPORT_INPUT_CONTRACT_MISMATCH",
|
|
498
|
+
`${error.field}: ${error.message}`,
|
|
499
|
+
error.field,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
throwContractMismatch(
|
|
503
|
+
"ReportInput contract mismatch: report_input failed schema validation",
|
|
504
|
+
diagnostics.getDiagnostics(),
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
|
|
509
|
+
diagnostics.warn(
|
|
510
|
+
"REPORT_INPUT_LEGACY_FIELD_IGNORED",
|
|
511
|
+
"Both report_input and audit_state were provided; audit_state is ignored.",
|
|
512
|
+
"audit_state",
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
|
|
520
|
+
const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
|
|
521
|
+
for (const diagnostic of legacy.diagnostics) {
|
|
522
|
+
diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
|
|
523
|
+
}
|
|
524
|
+
const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
|
|
525
|
+
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
diagnostics.error(
|
|
529
|
+
"REPORT_INPUT_MISSING",
|
|
530
|
+
"Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
|
|
531
|
+
"report_input",
|
|
532
|
+
)
|
|
533
|
+
throwContractMismatch(
|
|
534
|
+
"ReportInput contract mismatch: missing required payload",
|
|
535
|
+
diagnostics.getDiagnostics(),
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function emitDropDiagnosticsForFindings(
|
|
540
|
+
rawItems: unknown[],
|
|
541
|
+
normalized: Record<string, unknown>[],
|
|
542
|
+
validFindings: Finding[],
|
|
543
|
+
diag: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
544
|
+
): void {
|
|
545
|
+
const droppedCount = rawItems.length - validFindings.length
|
|
546
|
+
if (droppedCount <= 0) return
|
|
547
|
+
|
|
548
|
+
for (const item of normalized) {
|
|
549
|
+
if (hasMinimumFindingFields(item)) continue
|
|
550
|
+
const missing: string[] = []
|
|
551
|
+
if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
|
|
552
|
+
if (typeof item.file !== "string") missing.push("file")
|
|
553
|
+
if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
|
|
554
|
+
diag.error(
|
|
555
|
+
"MISSING_REQUIRED_FIELD",
|
|
556
|
+
`Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
|
|
557
|
+
missing[0],
|
|
558
|
+
)
|
|
255
559
|
}
|
|
256
560
|
}
|
|
257
561
|
|
|
258
|
-
export function parseAuditState(auditState: string): AuditState {
|
|
562
|
+
export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
|
|
563
|
+
const policy = options?.dropPolicy ?? "warn"
|
|
564
|
+
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
565
|
+
|
|
259
566
|
let parsed: unknown
|
|
260
567
|
try {
|
|
261
568
|
parsed = JSON.parse(auditState)
|
|
262
569
|
} catch {
|
|
570
|
+
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
571
|
+
diag.throwIfStrict()
|
|
263
572
|
throw new Error(
|
|
264
573
|
"audit_state is not valid JSON — expected an AuditState object or Finding[] array",
|
|
265
574
|
)
|
|
266
575
|
}
|
|
267
576
|
|
|
268
|
-
const logger = createLogger()
|
|
269
|
-
|
|
270
577
|
if (Array.isArray(parsed)) {
|
|
271
578
|
const rawItems = parsed as unknown[]
|
|
272
579
|
const normalized = rawItems
|
|
@@ -275,12 +582,8 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
275
582
|
const validFindings = normalized
|
|
276
583
|
.filter(hasMinimumFindingFields)
|
|
277
584
|
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
logger.warn(
|
|
281
|
-
`parseAuditState: ${dropped}/${rawItems.length} findings dropped (missing required fields after normalization)`,
|
|
282
|
-
)
|
|
283
|
-
}
|
|
585
|
+
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
586
|
+
diag.throwIfStrict()
|
|
284
587
|
return emptyAuditState(validFindings)
|
|
285
588
|
}
|
|
286
589
|
|
|
@@ -297,12 +600,8 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
297
600
|
const validFindings = normalized
|
|
298
601
|
.filter(hasMinimumFindingFields)
|
|
299
602
|
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
logger.warn(
|
|
303
|
-
`parseAuditState: ${dropped}/${rawFindings.length} findings dropped (missing required fields after normalization)`,
|
|
304
|
-
)
|
|
305
|
-
}
|
|
603
|
+
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
604
|
+
diag.throwIfStrict()
|
|
306
605
|
return {
|
|
307
606
|
...emptyAuditState(),
|
|
308
607
|
...state,
|
|
@@ -313,6 +612,59 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
313
612
|
return emptyAuditState()
|
|
314
613
|
}
|
|
315
614
|
|
|
615
|
+
export function parseAuditStateWithDiagnostics(
|
|
616
|
+
auditState: string,
|
|
617
|
+
options?: ParseAuditStateOptions,
|
|
618
|
+
): ParseAuditStateResult {
|
|
619
|
+
const policy = options?.dropPolicy ?? "warn"
|
|
620
|
+
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
621
|
+
|
|
622
|
+
let parsed: unknown
|
|
623
|
+
try {
|
|
624
|
+
parsed = JSON.parse(auditState)
|
|
625
|
+
} catch {
|
|
626
|
+
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
627
|
+
diag.throwIfStrict()
|
|
628
|
+
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (Array.isArray(parsed)) {
|
|
632
|
+
const rawItems = parsed as unknown[]
|
|
633
|
+
const normalized = rawItems
|
|
634
|
+
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
635
|
+
.map((item) => normalizeRawFinding(item))
|
|
636
|
+
const validFindings = normalized
|
|
637
|
+
.filter(hasMinimumFindingFields)
|
|
638
|
+
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
639
|
+
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
640
|
+
diag.throwIfStrict()
|
|
641
|
+
return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (
|
|
645
|
+
typeof parsed === "object" &&
|
|
646
|
+
parsed !== null &&
|
|
647
|
+
Array.isArray((parsed as AuditState).findings)
|
|
648
|
+
) {
|
|
649
|
+
const auditStateObj = parsed as AuditState
|
|
650
|
+
const rawFindings = auditStateObj.findings as unknown[]
|
|
651
|
+
const normalized = rawFindings
|
|
652
|
+
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
653
|
+
.map((item) => normalizeRawFinding(item))
|
|
654
|
+
const validFindings = normalized
|
|
655
|
+
.filter(hasMinimumFindingFields)
|
|
656
|
+
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
657
|
+
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
658
|
+
diag.throwIfStrict()
|
|
659
|
+
return {
|
|
660
|
+
state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
|
|
661
|
+
diagnostics: diag.getDiagnostics(),
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
666
|
+
}
|
|
667
|
+
|
|
316
668
|
function normalizeTitle(check: string): string {
|
|
317
669
|
if (!check || typeof check !== "string") return "Unknown Check"
|
|
318
670
|
return check
|
|
@@ -384,6 +736,146 @@ function genericRecommendation(severity: FindingSeverity): string {
|
|
|
384
736
|
return "Track and resolve during routine code quality and documentation improvements."
|
|
385
737
|
}
|
|
386
738
|
|
|
739
|
+
function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
|
|
740
|
+
return finding as Finding & ReportFindingFields
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function getFindingImpact(finding: Finding): string {
|
|
744
|
+
const extended = getExtendedFinding(finding)
|
|
745
|
+
if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
|
|
746
|
+
return extended.impact.trim()
|
|
747
|
+
}
|
|
748
|
+
return MISSING_IMPACT_TEXT
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function getFindingRecommendation(finding: Finding): string {
|
|
752
|
+
const extended = getExtendedFinding(finding)
|
|
753
|
+
if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
|
|
754
|
+
return extended.recommendation.trim()
|
|
755
|
+
}
|
|
756
|
+
if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
|
|
757
|
+
return finding.remediation.trim()
|
|
758
|
+
}
|
|
759
|
+
return MISSING_RECOMMENDATION_TEXT
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function getPocEvidence(finding: Finding): string | undefined {
|
|
763
|
+
const extended = getExtendedFinding(finding)
|
|
764
|
+
if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
|
|
765
|
+
return extended.proofOfConcept.trim()
|
|
766
|
+
}
|
|
767
|
+
if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
|
|
768
|
+
return finding.exploitReference.trim()
|
|
769
|
+
}
|
|
770
|
+
return undefined
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function compareFindingsDeterministically(a: Finding, b: Finding): number {
|
|
774
|
+
const severityDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
775
|
+
if (severityDelta !== 0) return severityDelta
|
|
776
|
+
|
|
777
|
+
const fileDelta = a.file.localeCompare(b.file)
|
|
778
|
+
if (fileDelta !== 0) return fileDelta
|
|
779
|
+
|
|
780
|
+
const lineDelta = (a.lines[0] ?? 0) - (b.lines[0] ?? 0)
|
|
781
|
+
if (lineDelta !== 0) return lineDelta
|
|
782
|
+
|
|
783
|
+
return a.id.localeCompare(b.id)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function sortFindingsDeterministically(findings: Finding[]): Finding[] {
|
|
787
|
+
return [...findings].sort(compareFindingsDeterministically)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
export function validateReportQuality(
|
|
791
|
+
findings: Finding[],
|
|
792
|
+
policy: QualityGatePolicy,
|
|
793
|
+
): ReportQualityValidation {
|
|
794
|
+
const violations: ReportQualityViolation[] = []
|
|
795
|
+
|
|
796
|
+
for (const finding of findings) {
|
|
797
|
+
const findingId = finding.id
|
|
798
|
+
const impact = getFindingImpact(finding)
|
|
799
|
+
const recommendation = getFindingRecommendation(finding)
|
|
800
|
+
const severity = finding.severity
|
|
801
|
+
|
|
802
|
+
if (!finding.id || !finding.check || !finding.file || !Array.isArray(finding.lines)) {
|
|
803
|
+
violations.push({
|
|
804
|
+
findingId,
|
|
805
|
+
code: "schema.missing-required",
|
|
806
|
+
message: "Finding is missing required fields for deterministic report rendering.",
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (!finding.description || finding.description.trim().length === 0) {
|
|
811
|
+
violations.push({
|
|
812
|
+
findingId,
|
|
813
|
+
code: "completeness.missing-description",
|
|
814
|
+
message: "Finding description must be non-empty.",
|
|
815
|
+
})
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (!finding.source || finding.source.trim().length === 0) {
|
|
819
|
+
violations.push({
|
|
820
|
+
findingId,
|
|
821
|
+
code: "provenance.missing-source",
|
|
822
|
+
message: "Finding source is required for provenance traceability.",
|
|
823
|
+
})
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (severity !== "Critical" && severity !== "High") {
|
|
827
|
+
continue
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (
|
|
831
|
+
impact.length === 0 ||
|
|
832
|
+
impact === MISSING_IMPACT_TEXT ||
|
|
833
|
+
impact === genericImpact(severity)
|
|
834
|
+
) {
|
|
835
|
+
violations.push({
|
|
836
|
+
findingId,
|
|
837
|
+
code: "severity-justification.missing-impact",
|
|
838
|
+
message: `${severity} findings must include specific non-generic impact details.`,
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (
|
|
843
|
+
recommendation.length === 0 ||
|
|
844
|
+
recommendation === MISSING_RECOMMENDATION_TEXT ||
|
|
845
|
+
recommendation === genericRecommendation(severity)
|
|
846
|
+
) {
|
|
847
|
+
violations.push({
|
|
848
|
+
findingId,
|
|
849
|
+
code: "severity-justification.missing-recommendation",
|
|
850
|
+
message: `${severity} findings must include specific non-generic recommendations.`,
|
|
851
|
+
})
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (getPocEvidence(finding) == null) {
|
|
855
|
+
violations.push({
|
|
856
|
+
findingId,
|
|
857
|
+
code: "severity-justification.missing-poc",
|
|
858
|
+
message: `${severity} findings must satisfy PoC policy with exploitReference or proofOfConcept.`,
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (policy === "warn" && violations.length > 0) {
|
|
864
|
+
const logger = createLogger()
|
|
865
|
+
logger.warn(`[report-generator] quality gates failed with ${violations.length} violation(s)`)
|
|
866
|
+
for (const violation of violations) {
|
|
867
|
+
logger.warn(
|
|
868
|
+
`[report-generator] [${violation.code}] finding=${violation.findingId}: ${violation.message}`,
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
passed: violations.length === 0,
|
|
875
|
+
violations,
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
387
879
|
function buildRecommendations(counts: FindingsCount): string[] {
|
|
388
880
|
const items: string[] = []
|
|
389
881
|
|
|
@@ -434,7 +926,8 @@ function buildFindingsSection(findings: Finding[]): string {
|
|
|
434
926
|
const prefix = SEVERITY_PREFIX[severity]
|
|
435
927
|
const findingId = `[${prefix}-${index + 1}]`
|
|
436
928
|
const title = normalizeTitle(finding.check)
|
|
437
|
-
const recommendation = finding
|
|
929
|
+
const recommendation = getFindingRecommendation(finding)
|
|
930
|
+
const impact = getFindingImpact(finding)
|
|
438
931
|
|
|
439
932
|
lines.push(`### ${findingId} ${title}`)
|
|
440
933
|
lines.push(`**Severity**: ${finding.severity}`)
|
|
@@ -443,9 +936,14 @@ function buildFindingsSection(findings: Finding[]): string {
|
|
|
443
936
|
lines.push("")
|
|
444
937
|
lines.push(`**Description**: ${finding.description}`)
|
|
445
938
|
lines.push("")
|
|
446
|
-
lines.push(`**Impact**: ${
|
|
939
|
+
lines.push(`**Impact**: ${impact}`)
|
|
447
940
|
lines.push("")
|
|
448
941
|
lines.push(`**Recommendation**: ${recommendation}`)
|
|
942
|
+
const pocEvidence = getPocEvidence(finding)
|
|
943
|
+
if (pocEvidence) {
|
|
944
|
+
lines.push("")
|
|
945
|
+
lines.push(`**PoC / Evidence**: ${pocEvidence}`)
|
|
946
|
+
}
|
|
449
947
|
lines.push("")
|
|
450
948
|
})
|
|
451
949
|
}
|
|
@@ -465,7 +963,7 @@ export function buildProvenanceAppendix(
|
|
|
465
963
|
): string {
|
|
466
964
|
const lines: string[] = ["## Appendix: Data Provenance"]
|
|
467
965
|
|
|
468
|
-
lines.push("- Data source: `audit_state`
|
|
966
|
+
lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
|
|
469
967
|
lines.push(`- Severity threshold applied: ${threshold}`)
|
|
470
968
|
lines.push(`- Findings included in report: ${includedCount}`)
|
|
471
969
|
|
|
@@ -479,7 +977,11 @@ export function buildProvenanceAppendix(
|
|
|
479
977
|
lines.push("")
|
|
480
978
|
lines.push("| Source | Count |")
|
|
481
979
|
lines.push("| --- | ---: |")
|
|
482
|
-
for (const [source, count] of Object.entries(sourceCounts).sort((a, b) =>
|
|
980
|
+
for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => {
|
|
981
|
+
const countDelta = b[1] - a[1]
|
|
982
|
+
if (countDelta !== 0) return countDelta
|
|
983
|
+
return a[0].localeCompare(b[0])
|
|
984
|
+
})) {
|
|
483
985
|
lines.push(`| ${source} | ${count} |`)
|
|
484
986
|
}
|
|
485
987
|
}
|
|
@@ -562,8 +1064,19 @@ export async function executeReportGeneration(
|
|
|
562
1064
|
): Promise<ReportGenerationResult> {
|
|
563
1065
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
564
1066
|
const threshold = args.severity_threshold ?? "low"
|
|
565
|
-
const
|
|
566
|
-
const
|
|
1067
|
+
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1068
|
+
const { reportInput, diagnostics } = parseReportInputPayload(args, context)
|
|
1069
|
+
const state = reportInputToAuditState(reportInput)
|
|
1070
|
+
const scope = args.scope.length > 0 ? args.scope : reportInput.scope
|
|
1071
|
+
const findings = sortFindingsDeterministically(
|
|
1072
|
+
state.findings.filter((finding) => shouldIncludeFinding(finding, threshold)),
|
|
1073
|
+
)
|
|
1074
|
+
const qualityGates = validateReportQuality(findings, qualityGatePolicy)
|
|
1075
|
+
if (!qualityGates.passed && qualityGatePolicy === "strict-fail") {
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
`Report quality gates failed: ${JSON.stringify({ passed: false, violations: qualityGates.violations })}`,
|
|
1078
|
+
)
|
|
1079
|
+
}
|
|
567
1080
|
const counts = calculateCounts(findings)
|
|
568
1081
|
const auditDate = new Date().toISOString().slice(0, 10)
|
|
569
1082
|
|
|
@@ -590,10 +1103,10 @@ export async function executeReportGeneration(
|
|
|
590
1103
|
|
|
591
1104
|
sections.push("## Scope")
|
|
592
1105
|
sections.push("Contracts in scope:")
|
|
593
|
-
if (
|
|
1106
|
+
if (scope.length === 0) {
|
|
594
1107
|
sections.push("- None provided")
|
|
595
1108
|
} else {
|
|
596
|
-
for (const contract of
|
|
1109
|
+
for (const contract of scope) {
|
|
597
1110
|
sections.push(`- ${contract}`)
|
|
598
1111
|
}
|
|
599
1112
|
}
|
|
@@ -606,7 +1119,7 @@ export async function executeReportGeneration(
|
|
|
606
1119
|
sections.push("- Pattern Analysis")
|
|
607
1120
|
sections.push("- Solodit research cross-referencing")
|
|
608
1121
|
sections.push(
|
|
609
|
-
"Approach: Findings
|
|
1122
|
+
"Approach: Findings are normalized, deterministically ordered by severity/file/line, and validated against report quality gates before emission.",
|
|
610
1123
|
)
|
|
611
1124
|
|
|
612
1125
|
sections.push(buildFindingsSection(findings))
|
|
@@ -618,14 +1131,28 @@ export async function executeReportGeneration(
|
|
|
618
1131
|
|
|
619
1132
|
sections.push(buildProvenanceAppendix(state, threshold, findings.length))
|
|
620
1133
|
|
|
1134
|
+
// Embed report metadata for single-writer policy enforcement
|
|
1135
|
+
const runId = reportInput.run_id || state.sessionId || ""
|
|
1136
|
+
if (runId) {
|
|
1137
|
+
sections.push(buildReportMetadataComment(runId))
|
|
1138
|
+
}
|
|
1139
|
+
|
|
621
1140
|
const reportMarkdown = sections.join("\n\n")
|
|
622
|
-
const
|
|
623
|
-
const
|
|
1141
|
+
const contentHash = stableHash(reportMarkdown)
|
|
1142
|
+
const { filename: canonicalFilename } = resolveReportPath({
|
|
1143
|
+
contractName: args.project_name,
|
|
1144
|
+
date: new Date(auditDate),
|
|
1145
|
+
outputDir: ".opencode/reports/",
|
|
1146
|
+
runId: runId || undefined,
|
|
1147
|
+
})
|
|
624
1148
|
|
|
625
1149
|
const result: ReportGenerationResult = {
|
|
626
1150
|
report: reportMarkdown,
|
|
627
1151
|
findingsCount: counts,
|
|
628
|
-
filename:
|
|
1152
|
+
filename: canonicalFilename,
|
|
1153
|
+
contentHash,
|
|
1154
|
+
qualityGates,
|
|
1155
|
+
contractDiagnostics: diagnostics,
|
|
629
1156
|
}
|
|
630
1157
|
|
|
631
1158
|
try {
|
|
@@ -633,7 +1160,17 @@ export async function executeReportGeneration(
|
|
|
633
1160
|
const projectDir = resolveProjectDir(context)
|
|
634
1161
|
const config = loadConfig(projectDir)
|
|
635
1162
|
const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
|
|
636
|
-
const fullPath = path.join(projectDir, outputDir,
|
|
1163
|
+
const fullPath = path.join(projectDir, outputDir, canonicalFilename)
|
|
1164
|
+
|
|
1165
|
+
// Single-writer policy: check for duplicate writes with same run_id
|
|
1166
|
+
if (runId) {
|
|
1167
|
+
const duplicateError = await checkDuplicateWrite(fullPath, runId)
|
|
1168
|
+
if (duplicateError) {
|
|
1169
|
+
result.error = duplicateError
|
|
1170
|
+
return result
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
637
1174
|
await Bun.write(fullPath, reportMarkdown)
|
|
638
1175
|
result.filePath = fullPath
|
|
639
1176
|
} catch (err: unknown) {
|
|
@@ -647,7 +1184,7 @@ export async function executeReportGeneration(
|
|
|
647
1184
|
|
|
648
1185
|
export const reportGeneratorTool = tool({
|
|
649
1186
|
description:
|
|
650
|
-
"Generate a professional markdown security audit report from
|
|
1187
|
+
"Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
|
|
651
1188
|
args: {
|
|
652
1189
|
project_name: tool.schema.string(),
|
|
653
1190
|
scope: tool.schema.array(tool.schema.string()),
|
|
@@ -655,7 +1192,8 @@ export const reportGeneratorTool = tool({
|
|
|
655
1192
|
severity_threshold: tool.schema
|
|
656
1193
|
.enum(["critical", "high", "medium", "low", "informational"])
|
|
657
1194
|
.default("low"),
|
|
658
|
-
|
|
1195
|
+
report_input: tool.schema.string().optional(),
|
|
1196
|
+
audit_state: tool.schema.string().optional(),
|
|
659
1197
|
},
|
|
660
1198
|
async execute(args, context) {
|
|
661
1199
|
const result = await executeReportGeneration(args, context)
|