solidity-argus 0.3.5 → 0.3.7
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 +4 -4
- package/src/agents/argus-prompt.ts +10 -2
- package/src/agents/pythia-prompt.ts +11 -0
- package/src/agents/scribe-prompt.ts +6 -5
- package/src/agents/sentinel-prompt.ts +10 -0
- package/src/create-hooks.ts +111 -1
- package/src/create-tools.ts +2 -0
- package/src/features/audit-enforcer/audit-enforcer.ts +0 -1
- package/src/features/persistent-state/audit-state-manager.ts +157 -9
- package/src/features/persistent-state/event-sink.ts +11 -6
- package/src/features/persistent-state/findings-materializer.ts +25 -2
- package/src/features/persistent-state/run-finalizer.ts +2 -0
- package/src/features/persistent-state/run-journal.ts +1 -4
- package/src/hooks/event-hook.ts +4 -1
- package/src/hooks/system-prompt-hook.ts +2 -7
- package/src/hooks/tool-tracking-hook.ts +176 -12
- package/src/shared/plugin-metadata.ts +23 -0
- package/src/state/adapters.ts +99 -5
- package/src/state/finding-aggregation.ts +100 -0
- package/src/state/finding-fingerprint.ts +47 -0
- package/src/state/finding-store.ts +19 -29
- package/src/state/projectors.ts +18 -4
- package/src/state/schemas.ts +49 -2
- package/src/state/types.ts +11 -1
- package/src/tools/record-finding-tool.ts +125 -0
- package/src/tools/report-generator-tool.ts +53 -15
package/src/hooks/event-hook.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { EventSink } from "../features/persistent-state/event-sink"
|
|
|
2
2
|
import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
|
|
3
3
|
import { finalizeRun } from "../features/persistent-state/run-finalizer"
|
|
4
4
|
import { createLogger } from "../shared/logger"
|
|
5
|
+
import { ARGUS_PLUGIN_VERSION } from "../shared/plugin-metadata"
|
|
5
6
|
import { createAuditState } from "../state/audit-state"
|
|
6
7
|
import type { AuditEvent } from "../state/schemas"
|
|
7
8
|
import { SCHEMA_VERSION } from "../state/schemas"
|
|
@@ -112,7 +113,6 @@ export function createEventHook(
|
|
|
112
113
|
|
|
113
114
|
case "session.deleted": {
|
|
114
115
|
preDeleteState = currentAuditState
|
|
115
|
-
currentAuditState = null
|
|
116
116
|
break
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -140,6 +140,7 @@ export function createEventHook(
|
|
|
140
140
|
await emitToSink("session.created", currentAuditState.sessionId, sessionId, {
|
|
141
141
|
projectDir: currentAuditState.projectDir,
|
|
142
142
|
sessionId: currentAuditState.sessionId,
|
|
143
|
+
plugin_version: ARGUS_PLUGIN_VERSION,
|
|
143
144
|
})
|
|
144
145
|
}
|
|
145
146
|
break
|
|
@@ -160,6 +161,7 @@ export function createEventHook(
|
|
|
160
161
|
if (preDeleteState) {
|
|
161
162
|
await emitToSink("session.deleted", preDeleteState.sessionId, sessionId, {
|
|
162
163
|
archived: true,
|
|
164
|
+
plugin_version: ARGUS_PLUGIN_VERSION,
|
|
163
165
|
})
|
|
164
166
|
|
|
165
167
|
if (eventSink) {
|
|
@@ -176,6 +178,7 @@ export function createEventHook(
|
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
}
|
|
181
|
+
currentAuditState = null
|
|
179
182
|
eventSink = null
|
|
180
183
|
break
|
|
181
184
|
}
|
|
@@ -12,7 +12,6 @@ const TOOL_SHORT_NAMES: Record<string, string> = {
|
|
|
12
12
|
}
|
|
13
13
|
const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
|
|
14
14
|
|
|
15
|
-
|
|
16
15
|
/** Maps unavailable-tool short names to their KEY_TOOLS counterpart */
|
|
17
16
|
const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
|
|
18
17
|
slither: "slither",
|
|
@@ -77,12 +76,8 @@ export function buildDynamicContext(
|
|
|
77
76
|
(t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
|
|
78
77
|
).join(" ")
|
|
79
78
|
const unavailable = auditState.unavailableTools ?? []
|
|
80
|
-
const excusedTools = new Set(
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
const pendingKeyTools = KEY_TOOLS.filter(
|
|
84
|
-
(t) => !executedToolNames.has(t) && !excusedTools.has(t),
|
|
85
|
-
)
|
|
79
|
+
const excusedTools = new Set(unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean))
|
|
80
|
+
const pendingKeyTools = KEY_TOOLS.filter((t) => !executedToolNames.has(t) && !excusedTools.has(t))
|
|
86
81
|
const gateStatus =
|
|
87
82
|
pendingKeyTools.length > 0
|
|
88
83
|
? `REPORTING GATE: BLOCKED \u2014 key tools pending: ${pendingKeyTools.join(", ")}`
|
|
@@ -12,7 +12,14 @@ import type { FindingStore } from "../state/finding-store"
|
|
|
12
12
|
import { createFindingStore } from "../state/finding-store"
|
|
13
13
|
import type { AuditEvent } from "../state/schemas"
|
|
14
14
|
import { SCHEMA_VERSION } from "../state/schemas"
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
ArgusAgentName,
|
|
17
|
+
AuditState,
|
|
18
|
+
Finding,
|
|
19
|
+
FindingSeverity,
|
|
20
|
+
FuzzCounterexample,
|
|
21
|
+
SoloditResult,
|
|
22
|
+
} from "../state/types"
|
|
16
23
|
|
|
17
24
|
const logger = createLogger()
|
|
18
25
|
|
|
@@ -20,6 +27,8 @@ type ToolHookInput = {
|
|
|
20
27
|
tool: string
|
|
21
28
|
args: unknown
|
|
22
29
|
result: string
|
|
30
|
+
sessionID?: string
|
|
31
|
+
callID?: string
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
type ToolExecutionMetadata = {
|
|
@@ -30,6 +39,8 @@ type ToolExecutionMetadata = {
|
|
|
30
39
|
export type ToolTrackingOptions = {
|
|
31
40
|
getEventSink?: () => EventSink | null
|
|
32
41
|
getSessionId?: () => string
|
|
42
|
+
getAgentName?: () => ArgusAgentName | undefined
|
|
43
|
+
getAgentNameForSession?: (sessionId: string) => ArgusAgentName | undefined
|
|
33
44
|
dropPolicy?: DropPolicy
|
|
34
45
|
onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
|
|
35
46
|
}
|
|
@@ -77,13 +88,35 @@ function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
77
88
|
return undefined
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
function toFindingSource(value: unknown): Finding["source"] {
|
|
92
|
+
if (
|
|
93
|
+
value === "slither" ||
|
|
94
|
+
value === "manual" ||
|
|
95
|
+
value === "pattern" ||
|
|
96
|
+
value === "scvd" ||
|
|
97
|
+
value === "solodit" ||
|
|
98
|
+
value === "fuzz"
|
|
99
|
+
) {
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return "manual"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function emitToSink(
|
|
107
|
+
sink: EventSink,
|
|
108
|
+
event: AuditEvent,
|
|
109
|
+
options?: { failFast?: boolean },
|
|
110
|
+
): Promise<void> {
|
|
81
111
|
try {
|
|
82
112
|
await sink.append(event)
|
|
83
113
|
} catch (error) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
114
|
+
const message = `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`
|
|
115
|
+
logger.error(message)
|
|
116
|
+
|
|
117
|
+
if (options?.failFast) {
|
|
118
|
+
throw new Error(message)
|
|
119
|
+
}
|
|
87
120
|
}
|
|
88
121
|
}
|
|
89
122
|
|
|
@@ -155,11 +188,13 @@ function identifyMissingFields(
|
|
|
155
188
|
|
|
156
189
|
const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
|
|
157
190
|
const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
|
|
191
|
+
const MANUAL_REQUIRED = ["check", "description", "file", "lines"] as const
|
|
158
192
|
|
|
159
193
|
function processSlitherResult(
|
|
160
194
|
parsed: Record<string, unknown>,
|
|
161
195
|
store: FindingStore,
|
|
162
196
|
diag: DropDiagnosticsCollector,
|
|
197
|
+
metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
|
|
163
198
|
): number {
|
|
164
199
|
const findings = parsed.findings
|
|
165
200
|
if (!Array.isArray(findings)) return 0
|
|
@@ -197,6 +232,8 @@ function processSlitherResult(
|
|
|
197
232
|
file,
|
|
198
233
|
lines,
|
|
199
234
|
source: "slither",
|
|
235
|
+
reported_by_agent: metadata.reportedByAgent,
|
|
236
|
+
reported_by_session_id: metadata.reportedBySessionId,
|
|
200
237
|
})
|
|
201
238
|
count++
|
|
202
239
|
}
|
|
@@ -208,6 +245,7 @@ function processPatternResult(
|
|
|
208
245
|
parsed: Record<string, unknown>,
|
|
209
246
|
store: FindingStore,
|
|
210
247
|
diag: DropDiagnosticsCollector,
|
|
248
|
+
metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
|
|
211
249
|
): number {
|
|
212
250
|
const sources = parsed.sources
|
|
213
251
|
if (!Array.isArray(sources)) return 0
|
|
@@ -252,6 +290,8 @@ function processPatternResult(
|
|
|
252
290
|
file,
|
|
253
291
|
lines,
|
|
254
292
|
source: "pattern",
|
|
293
|
+
reported_by_agent: metadata.reportedByAgent,
|
|
294
|
+
reported_by_session_id: metadata.reportedBySessionId,
|
|
255
295
|
})
|
|
256
296
|
count++
|
|
257
297
|
}
|
|
@@ -260,6 +300,89 @@ function processPatternResult(
|
|
|
260
300
|
return count
|
|
261
301
|
}
|
|
262
302
|
|
|
303
|
+
function processRecordedFindingResult(
|
|
304
|
+
parsed: Record<string, unknown>,
|
|
305
|
+
store: FindingStore,
|
|
306
|
+
diag: DropDiagnosticsCollector,
|
|
307
|
+
metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
|
|
308
|
+
): number {
|
|
309
|
+
const findings = parsed.findings
|
|
310
|
+
if (!Array.isArray(findings)) {
|
|
311
|
+
diag.error(
|
|
312
|
+
"MISSING_REQUIRED_FIELD",
|
|
313
|
+
"argus_record_finding result missing findings array",
|
|
314
|
+
"findings",
|
|
315
|
+
)
|
|
316
|
+
return 0
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let count = 0
|
|
320
|
+
for (const raw of findings) {
|
|
321
|
+
const finding = toRecord(raw)
|
|
322
|
+
if (!finding) continue
|
|
323
|
+
|
|
324
|
+
const check = finding.check
|
|
325
|
+
const description = finding.description
|
|
326
|
+
const file = finding.file
|
|
327
|
+
const lines = toLines(finding.lines)
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
typeof check !== "string" ||
|
|
331
|
+
typeof description !== "string" ||
|
|
332
|
+
typeof file !== "string" ||
|
|
333
|
+
!lines
|
|
334
|
+
) {
|
|
335
|
+
const missing = identifyMissingFields(finding, MANUAL_REQUIRED)
|
|
336
|
+
diag.error(
|
|
337
|
+
"MISSING_REQUIRED_FIELD",
|
|
338
|
+
`Recorded finding skipped: missing ${missing.join(", ")}`,
|
|
339
|
+
missing[0],
|
|
340
|
+
)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const reportedByAgentRaw = finding.reported_by_agent
|
|
345
|
+
const reportedByAgent =
|
|
346
|
+
reportedByAgentRaw === "argus" ||
|
|
347
|
+
reportedByAgentRaw === "sentinel" ||
|
|
348
|
+
reportedByAgentRaw === "pythia" ||
|
|
349
|
+
reportedByAgentRaw === "scribe" ||
|
|
350
|
+
reportedByAgentRaw === "unknown"
|
|
351
|
+
? (reportedByAgentRaw as ArgusAgentName)
|
|
352
|
+
: metadata.reportedByAgent
|
|
353
|
+
|
|
354
|
+
store.addFinding({
|
|
355
|
+
check,
|
|
356
|
+
severity: toSeverity(finding.severity),
|
|
357
|
+
confidence: toConfidence(finding.confidence),
|
|
358
|
+
description,
|
|
359
|
+
file,
|
|
360
|
+
lines,
|
|
361
|
+
source: toFindingSource(finding.source),
|
|
362
|
+
remediation: typeof finding.remediation === "string" ? finding.remediation : undefined,
|
|
363
|
+
exploitReference:
|
|
364
|
+
typeof finding.exploitReference === "string" ? finding.exploitReference : undefined,
|
|
365
|
+
reported_by_agent: reportedByAgent,
|
|
366
|
+
reported_by_session_id:
|
|
367
|
+
typeof finding.reported_by_session_id === "string" &&
|
|
368
|
+
finding.reported_by_session_id.length > 0
|
|
369
|
+
? finding.reported_by_session_id
|
|
370
|
+
: metadata.reportedBySessionId,
|
|
371
|
+
issue_fingerprint:
|
|
372
|
+
typeof finding.issue_fingerprint === "string" ? finding.issue_fingerprint : undefined,
|
|
373
|
+
observation_fingerprint:
|
|
374
|
+
typeof finding.observation_fingerprint === "string"
|
|
375
|
+
? finding.observation_fingerprint
|
|
376
|
+
: undefined,
|
|
377
|
+
observation_id:
|
|
378
|
+
typeof finding.observation_id === "string" ? finding.observation_id : undefined,
|
|
379
|
+
})
|
|
380
|
+
count++
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return count
|
|
384
|
+
}
|
|
385
|
+
|
|
263
386
|
function processContractAnalyzerResult(parsed: Record<string, unknown>, state: AuditState): void {
|
|
264
387
|
if (typeof parsed.filePath === "string") {
|
|
265
388
|
if (!state.contractsReviewed.includes(parsed.filePath)) {
|
|
@@ -382,7 +505,7 @@ export function createToolTrackingHook(
|
|
|
382
505
|
const correlationId = randomUUID()
|
|
383
506
|
const resolved = resolveStateAndStore()
|
|
384
507
|
const sink = options?.getEventSink?.()
|
|
385
|
-
const sessionId = options?.getSessionId?.() ?? ""
|
|
508
|
+
const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
|
|
386
509
|
const toolCallId = randomUUID()
|
|
387
510
|
|
|
388
511
|
if (childSessionId) {
|
|
@@ -427,6 +550,10 @@ export function createToolTrackingHook(
|
|
|
427
550
|
|
|
428
551
|
const resolved = resolveStateAndStore()
|
|
429
552
|
if (!resolved) {
|
|
553
|
+
if (input.tool === "argus_record_finding") {
|
|
554
|
+
throw new Error("argus_record_finding requires active audit state")
|
|
555
|
+
}
|
|
556
|
+
|
|
430
557
|
const sinkForNoState = options?.getEventSink?.()
|
|
431
558
|
if (sinkForNoState) {
|
|
432
559
|
const toolCallId = randomUUID()
|
|
@@ -452,7 +579,15 @@ export function createToolTrackingHook(
|
|
|
452
579
|
const { state: auditState, store } = resolved
|
|
453
580
|
const sink = options?.getEventSink?.()
|
|
454
581
|
const runId = auditState.sessionId
|
|
455
|
-
const sessionId = options?.getSessionId?.() ?? ""
|
|
582
|
+
const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
|
|
583
|
+
const reportedByAgent =
|
|
584
|
+
(input.sessionID ? options?.getAgentNameForSession?.(input.sessionID) : undefined) ??
|
|
585
|
+
options?.getAgentName?.() ??
|
|
586
|
+
"unknown"
|
|
587
|
+
const findingMetadata = {
|
|
588
|
+
reportedByAgent,
|
|
589
|
+
reportedBySessionId: sessionId,
|
|
590
|
+
}
|
|
456
591
|
const toolCallId = randomUUID()
|
|
457
592
|
const policy = options?.dropPolicy ?? "warn"
|
|
458
593
|
const diag = createDropDiagnosticsCollector(policy, "tool-tracking-hook", input.tool)
|
|
@@ -464,6 +599,7 @@ export function createToolTrackingHook(
|
|
|
464
599
|
tool: input.tool,
|
|
465
600
|
args: input.args,
|
|
466
601
|
}),
|
|
602
|
+
{ failFast: input.tool === "argus_record_finding" },
|
|
467
603
|
)
|
|
468
604
|
}
|
|
469
605
|
|
|
@@ -502,6 +638,9 @@ export function createToolTrackingHook(
|
|
|
502
638
|
} catch {
|
|
503
639
|
diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
|
|
504
640
|
lastDiagnostics = diag.getDiagnostics()
|
|
641
|
+
if (input.tool === "argus_record_finding") {
|
|
642
|
+
throw new Error("argus_record_finding returned malformed JSON")
|
|
643
|
+
}
|
|
505
644
|
diag.throwIfStrict()
|
|
506
645
|
return
|
|
507
646
|
}
|
|
@@ -509,6 +648,9 @@ export function createToolTrackingHook(
|
|
|
509
648
|
const record = toRecord(parsed)
|
|
510
649
|
if (!record) {
|
|
511
650
|
lastDiagnostics = diag.getDiagnostics()
|
|
651
|
+
if (input.tool === "argus_record_finding") {
|
|
652
|
+
throw new Error("argus_record_finding response must be a JSON object")
|
|
653
|
+
}
|
|
512
654
|
return
|
|
513
655
|
}
|
|
514
656
|
|
|
@@ -516,10 +658,13 @@ export function createToolTrackingHook(
|
|
|
516
658
|
|
|
517
659
|
switch (input.tool) {
|
|
518
660
|
case "argus_slither_analyze":
|
|
519
|
-
findingsCount = processSlitherResult(record, store, diag)
|
|
661
|
+
findingsCount = processSlitherResult(record, store, diag, findingMetadata)
|
|
520
662
|
break
|
|
521
663
|
case "argus_check_patterns":
|
|
522
|
-
findingsCount = processPatternResult(record, store, diag)
|
|
664
|
+
findingsCount = processPatternResult(record, store, diag, findingMetadata)
|
|
665
|
+
break
|
|
666
|
+
case "argus_record_finding":
|
|
667
|
+
findingsCount = processRecordedFindingResult(record, store, diag, findingMetadata)
|
|
523
668
|
break
|
|
524
669
|
case "argus_analyze_contract":
|
|
525
670
|
processContractAnalyzerResult(record, auditState)
|
|
@@ -595,14 +740,32 @@ export function createToolTrackingHook(
|
|
|
595
740
|
lastDiagnostics = diag.getDiagnostics()
|
|
596
741
|
diag.throwIfStrict()
|
|
597
742
|
|
|
743
|
+
if (input.tool === "argus_record_finding" && findingsCount === 0) {
|
|
744
|
+
throw new Error("argus_record_finding did not persist any findings")
|
|
745
|
+
}
|
|
746
|
+
|
|
598
747
|
recordToolExecution(auditState, input.tool, findingsCount)
|
|
599
748
|
onStateChanged?.({ tool: input.tool, findingsCount })
|
|
600
749
|
|
|
750
|
+
if (input.tool === "argus_record_finding" && !sink) {
|
|
751
|
+
throw new Error("argus_record_finding requires an active event sink for durable persistence")
|
|
752
|
+
}
|
|
753
|
+
|
|
601
754
|
if (sink) {
|
|
755
|
+
const failFast = input.tool === "argus_record_finding"
|
|
602
756
|
const newFindings = auditState.findings.slice(findingsCountBefore)
|
|
603
|
-
for (const finding of newFindings) {
|
|
604
|
-
const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0
|
|
605
|
-
|
|
757
|
+
for (const [index, finding] of newFindings.entries()) {
|
|
758
|
+
const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0, {
|
|
759
|
+
reportedByAgent,
|
|
760
|
+
reportedBySessionId: sessionId,
|
|
761
|
+
toolCallId,
|
|
762
|
+
observationId: `${toolCallId}:${index + 1}`,
|
|
763
|
+
})
|
|
764
|
+
await emitToSink(
|
|
765
|
+
sink,
|
|
766
|
+
buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
|
|
767
|
+
{ failFast },
|
|
768
|
+
)
|
|
606
769
|
}
|
|
607
770
|
|
|
608
771
|
await emitToSink(
|
|
@@ -612,6 +775,7 @@ export function createToolTrackingHook(
|
|
|
612
775
|
findingsCount,
|
|
613
776
|
success: true,
|
|
614
777
|
}),
|
|
778
|
+
{ failFast },
|
|
615
779
|
)
|
|
616
780
|
}
|
|
617
781
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
function resolvePluginVersion(): string {
|
|
6
|
+
try {
|
|
7
|
+
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const packageJsonPath = resolve(currentDir, "../../package.json")
|
|
9
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
10
|
+
version?: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
|
|
14
|
+
return packageJson.version
|
|
15
|
+
}
|
|
16
|
+
} catch (_error) {
|
|
17
|
+
return "unknown"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return "unknown"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ARGUS_PLUGIN_VERSION = resolvePluginVersion()
|
package/src/state/adapters.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { computeIssueFingerprint, computeObservationFingerprint } from "./finding-fingerprint"
|
|
1
2
|
import {
|
|
2
3
|
type CanonicalFinding,
|
|
3
4
|
SCHEMA_VERSION,
|
|
4
5
|
type ValidationError,
|
|
5
6
|
validateCanonicalFinding,
|
|
6
7
|
} from "./schemas"
|
|
7
|
-
import type { AuditPhase, Finding, FindingSeverity } from "./types"
|
|
8
|
+
import type { ArgusAgentName, AuditPhase, Finding, FindingSeverity } from "./types"
|
|
8
9
|
|
|
9
10
|
export interface Diagnostic {
|
|
10
11
|
level: "warn" | "error"
|
|
@@ -35,6 +36,13 @@ const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
|
|
|
35
36
|
"solodit",
|
|
36
37
|
"fuzz",
|
|
37
38
|
])
|
|
39
|
+
const VALID_REPORTED_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
|
|
40
|
+
"argus",
|
|
41
|
+
"sentinel",
|
|
42
|
+
"pythia",
|
|
43
|
+
"scribe",
|
|
44
|
+
"unknown",
|
|
45
|
+
])
|
|
38
46
|
|
|
39
47
|
const KNOWN_INPUT_FIELDS = new Set([
|
|
40
48
|
"id",
|
|
@@ -59,9 +67,26 @@ const KNOWN_INPUT_FIELDS = new Set([
|
|
|
59
67
|
"session_id",
|
|
60
68
|
"tool_call_id",
|
|
61
69
|
"schema_version",
|
|
70
|
+
"observation_id",
|
|
71
|
+
"observation_fingerprint",
|
|
72
|
+
"issue_fingerprint",
|
|
73
|
+
"reported_by_agent",
|
|
74
|
+
"reported_by_session_id",
|
|
75
|
+
"reportedByAgent",
|
|
76
|
+
"reportedBySessionId",
|
|
77
|
+
"observationId",
|
|
78
|
+
"observationFingerprint",
|
|
79
|
+
"issueFingerprint",
|
|
62
80
|
"elements",
|
|
63
81
|
])
|
|
64
82
|
|
|
83
|
+
export interface NormalizeFindingOptions {
|
|
84
|
+
reportedByAgent?: ArgusAgentName
|
|
85
|
+
reportedBySessionId?: string
|
|
86
|
+
toolCallId?: string
|
|
87
|
+
observationId?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
91
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
67
92
|
}
|
|
@@ -143,6 +168,7 @@ export function normalizeToCanonicalFinding(
|
|
|
143
168
|
raw: Finding | Record<string, unknown>,
|
|
144
169
|
runId: string,
|
|
145
170
|
seq: number,
|
|
171
|
+
options: NormalizeFindingOptions = {},
|
|
146
172
|
): AdapterResult<CanonicalFinding> {
|
|
147
173
|
const diagnostics: Diagnostic[] = []
|
|
148
174
|
const input = isRecord(raw) ? raw : {}
|
|
@@ -189,11 +215,74 @@ export function normalizeToCanonicalFinding(
|
|
|
189
215
|
? (input.source as CanonicalFinding["source"])
|
|
190
216
|
: "manual"
|
|
191
217
|
|
|
218
|
+
const reportedByAgentRaw =
|
|
219
|
+
(typeof input.reported_by_agent === "string" ? input.reported_by_agent : undefined) ??
|
|
220
|
+
(typeof input.reportedByAgent === "string" ? input.reportedByAgent : undefined) ??
|
|
221
|
+
options.reportedByAgent ??
|
|
222
|
+
"unknown"
|
|
223
|
+
const reportedByAgent: ArgusAgentName = VALID_REPORTED_AGENTS.has(
|
|
224
|
+
reportedByAgentRaw as ArgusAgentName,
|
|
225
|
+
)
|
|
226
|
+
? (reportedByAgentRaw as ArgusAgentName)
|
|
227
|
+
: "unknown"
|
|
228
|
+
|
|
229
|
+
const reportedBySessionId =
|
|
230
|
+
(typeof input.reported_by_session_id === "string" && input.reported_by_session_id.length > 0
|
|
231
|
+
? input.reported_by_session_id
|
|
232
|
+
: undefined) ??
|
|
233
|
+
(typeof input.reportedBySessionId === "string" && input.reportedBySessionId.length > 0
|
|
234
|
+
? input.reportedBySessionId
|
|
235
|
+
: undefined) ??
|
|
236
|
+
options.reportedBySessionId
|
|
237
|
+
|
|
238
|
+
const issueFingerprint =
|
|
239
|
+
(typeof input.issue_fingerprint === "string" && input.issue_fingerprint.length > 0
|
|
240
|
+
? input.issue_fingerprint
|
|
241
|
+
: undefined) ??
|
|
242
|
+
(typeof input.issueFingerprint === "string" && input.issueFingerprint.length > 0
|
|
243
|
+
? input.issueFingerprint
|
|
244
|
+
: undefined) ??
|
|
245
|
+
computeIssueFingerprint({
|
|
246
|
+
check,
|
|
247
|
+
file,
|
|
248
|
+
lines: lines ?? [0, 0],
|
|
249
|
+
severity: VALID_SEVERITIES.has(severity) ? severity : "Informational",
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const observationId =
|
|
253
|
+
(typeof input.observation_id === "string" && input.observation_id.length > 0
|
|
254
|
+
? input.observation_id
|
|
255
|
+
: undefined) ??
|
|
256
|
+
(typeof input.observationId === "string" && input.observationId.length > 0
|
|
257
|
+
? input.observationId
|
|
258
|
+
: undefined) ??
|
|
259
|
+
options.observationId ??
|
|
260
|
+
`${runId}:${seq}:${computeObservationFingerprint({
|
|
261
|
+
issueFingerprint,
|
|
262
|
+
source,
|
|
263
|
+
reportedByAgent,
|
|
264
|
+
toolCallId: options.toolCallId,
|
|
265
|
+
sessionId: reportedBySessionId,
|
|
266
|
+
})}`
|
|
267
|
+
|
|
268
|
+
const observationFingerprint =
|
|
269
|
+
(typeof input.observation_fingerprint === "string" && input.observation_fingerprint.length > 0
|
|
270
|
+
? input.observation_fingerprint
|
|
271
|
+
: undefined) ??
|
|
272
|
+
(typeof input.observationFingerprint === "string" && input.observationFingerprint.length > 0
|
|
273
|
+
? input.observationFingerprint
|
|
274
|
+
: undefined) ??
|
|
275
|
+
computeObservationFingerprint({
|
|
276
|
+
issueFingerprint,
|
|
277
|
+
source,
|
|
278
|
+
reportedByAgent,
|
|
279
|
+
toolCallId: options.toolCallId,
|
|
280
|
+
sessionId: reportedBySessionId,
|
|
281
|
+
observationId,
|
|
282
|
+
})
|
|
283
|
+
|
|
192
284
|
const canonical: CanonicalFinding = {
|
|
193
|
-
id:
|
|
194
|
-
typeof input.id === "string" && input.id.length > 0
|
|
195
|
-
? input.id
|
|
196
|
-
: `${check}:${file}:${lines?.[0] ?? 0}`,
|
|
285
|
+
id: observationId,
|
|
197
286
|
check,
|
|
198
287
|
severity: VALID_SEVERITIES.has(severity) ? severity : "Informational",
|
|
199
288
|
confidence: VALID_CONFIDENCES.has(confidence) ? confidence : "Low",
|
|
@@ -201,6 +290,11 @@ export function normalizeToCanonicalFinding(
|
|
|
201
290
|
file,
|
|
202
291
|
lines: lines ?? [0, 0],
|
|
203
292
|
source,
|
|
293
|
+
reported_by_agent: reportedByAgent,
|
|
294
|
+
reported_by_session_id: reportedBySessionId,
|
|
295
|
+
issue_fingerprint: issueFingerprint,
|
|
296
|
+
observation_fingerprint: observationFingerprint,
|
|
297
|
+
observation_id: observationId,
|
|
204
298
|
remediation: typeof input.remediation === "string" ? input.remediation : undefined,
|
|
205
299
|
exploitReference:
|
|
206
300
|
typeof input.exploitReference === "string" ? input.exploitReference : undefined,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { CanonicalFinding } from "./schemas"
|
|
2
|
+
|
|
3
|
+
const SEVERITY_RANK: Record<CanonicalFinding["severity"], number> = {
|
|
4
|
+
Critical: 0,
|
|
5
|
+
High: 1,
|
|
6
|
+
Medium: 2,
|
|
7
|
+
Low: 3,
|
|
8
|
+
Informational: 4,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function uniqueSorted(values: string[]): string[] {
|
|
12
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function compareObservations(left: CanonicalFinding, right: CanonicalFinding): number {
|
|
16
|
+
if (left.seq !== right.seq) return left.seq - right.seq
|
|
17
|
+
return left.observation_id.localeCompare(right.observation_id)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function compareFinalFindings(left: CanonicalFinding, right: CanonicalFinding): number {
|
|
21
|
+
const bySeverity = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
|
|
22
|
+
if (bySeverity !== 0) return bySeverity
|
|
23
|
+
|
|
24
|
+
const byFile = left.file.localeCompare(right.file)
|
|
25
|
+
if (byFile !== 0) return byFile
|
|
26
|
+
|
|
27
|
+
const byLine = left.lines[0] - right.lines[0]
|
|
28
|
+
if (byLine !== 0) return byLine
|
|
29
|
+
|
|
30
|
+
return left.issue_fingerprint.localeCompare(right.issue_fingerprint)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function dedupeFindingsForFinalOutput(findings: CanonicalFinding[]): CanonicalFinding[] {
|
|
34
|
+
const byIssue = new Map<string, CanonicalFinding[]>()
|
|
35
|
+
for (const finding of findings) {
|
|
36
|
+
const group = byIssue.get(finding.issue_fingerprint)
|
|
37
|
+
if (group) {
|
|
38
|
+
group.push(finding)
|
|
39
|
+
} else {
|
|
40
|
+
byIssue.set(finding.issue_fingerprint, [finding])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const merged: CanonicalFinding[] = []
|
|
45
|
+
|
|
46
|
+
for (const [issueFingerprint, observations] of byIssue.entries()) {
|
|
47
|
+
const sortedObservations = observations.slice().sort(compareObservations)
|
|
48
|
+
const base = sortedObservations[0]
|
|
49
|
+
if (!base) continue
|
|
50
|
+
|
|
51
|
+
const reportedByAgents = uniqueSorted(
|
|
52
|
+
sortedObservations.map((finding) => finding.reported_by_agent),
|
|
53
|
+
)
|
|
54
|
+
const sources = uniqueSorted(sortedObservations.map((finding) => finding.source))
|
|
55
|
+
const observationIds = sortedObservations
|
|
56
|
+
.map((finding) => finding.observation_id)
|
|
57
|
+
.sort((left, right) => left.localeCompare(right))
|
|
58
|
+
|
|
59
|
+
merged.push({
|
|
60
|
+
...base,
|
|
61
|
+
id: issueFingerprint,
|
|
62
|
+
sources,
|
|
63
|
+
reported_by_agents: reportedByAgents,
|
|
64
|
+
observation_ids: observationIds,
|
|
65
|
+
observation_count: sortedObservations.length,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return merged.sort(compareFinalFindings)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function issueFingerprintSet(findings: CanonicalFinding[]): Set<string> {
|
|
73
|
+
const set = new Set<string>()
|
|
74
|
+
for (const finding of findings) {
|
|
75
|
+
set.add(finding.issue_fingerprint)
|
|
76
|
+
}
|
|
77
|
+
return set
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function compareIssueFingerprintSets(
|
|
81
|
+
expected: CanonicalFinding[],
|
|
82
|
+
actual: CanonicalFinding[],
|
|
83
|
+
): { missing: string[]; extra: string[]; matches: boolean } {
|
|
84
|
+
const expectedSet = issueFingerprintSet(expected)
|
|
85
|
+
const actualSet = issueFingerprintSet(actual)
|
|
86
|
+
|
|
87
|
+
const missing = Array.from(expectedSet)
|
|
88
|
+
.filter((fingerprint) => !actualSet.has(fingerprint))
|
|
89
|
+
.sort((left, right) => left.localeCompare(right))
|
|
90
|
+
|
|
91
|
+
const extra = Array.from(actualSet)
|
|
92
|
+
.filter((fingerprint) => !expectedSet.has(fingerprint))
|
|
93
|
+
.sort((left, right) => left.localeCompare(right))
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
missing,
|
|
97
|
+
extra,
|
|
98
|
+
matches: missing.length === 0 && extra.length === 0,
|
|
99
|
+
}
|
|
100
|
+
}
|