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.
@@ -0,0 +1,437 @@
1
+ import { createHash } from "node:crypto"
2
+ import {
3
+ type AuditEvent,
4
+ type CanonicalFinding,
5
+ type CanonicalToolExecution,
6
+ type ReportInput,
7
+ SCHEMA_VERSION,
8
+ validateCanonicalFinding,
9
+ } from "./schemas"
10
+ import type {
11
+ AuditPhase,
12
+ AuditState,
13
+ Finding,
14
+ FuzzCounterexample,
15
+ SoloditResult,
16
+ ToolExecution,
17
+ } from "./types"
18
+
19
+ type ProjectorErrorCode = "OUT_OF_ORDER" | "DUPLICATE_SEQ" | "INVALID_EVENT"
20
+
21
+ type CoverageReport = NonNullable<AuditState["coverageReport"]>
22
+ type GasHotspot = NonNullable<AuditState["gasHotspots"]>[number]
23
+ type ProxyContract = NonNullable<AuditState["proxyContracts"]>[number]
24
+
25
+ export class ProjectorError extends Error {
26
+ readonly code: ProjectorErrorCode
27
+
28
+ constructor(code: ProjectorErrorCode, message: string) {
29
+ super(message)
30
+ this.name = "ProjectorError"
31
+ this.code = code
32
+ }
33
+ }
34
+
35
+ export const SEVERITY_RANK: Record<CanonicalFinding["severity"], number> = {
36
+ Critical: 0,
37
+ High: 1,
38
+ Medium: 2,
39
+ Low: 3,
40
+ Informational: 4,
41
+ }
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value)
45
+ }
46
+
47
+ function extractScope(payload: unknown): string[] {
48
+ if (!isRecord(payload) || !Array.isArray(payload.scope)) return []
49
+ return payload.scope.filter((entry): entry is string => typeof entry === "string")
50
+ }
51
+
52
+ function extractPhase(payload: unknown): AuditPhase | undefined {
53
+ if (typeof payload === "string") {
54
+ return payload as AuditPhase
55
+ }
56
+
57
+ if (!isRecord(payload)) return undefined
58
+
59
+ if (typeof payload.phase === "string") {
60
+ return payload.phase as AuditPhase
61
+ }
62
+
63
+ if (typeof payload.currentPhase === "string") {
64
+ return payload.currentPhase as AuditPhase
65
+ }
66
+
67
+ return undefined
68
+ }
69
+
70
+ function sortedUnique(values: string[]): string[] {
71
+ return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
72
+ }
73
+
74
+ function getPayloadRecord(event: AuditEvent): Record<string, unknown> {
75
+ if (!isRecord(event.payload)) {
76
+ throw new ProjectorError("INVALID_EVENT", `event seq ${event.seq} payload must be an object`)
77
+ }
78
+ return event.payload
79
+ }
80
+
81
+ function resolveToolName(event: AuditEvent, payload: Record<string, unknown>): string {
82
+ if (typeof payload.tool === "string" && payload.tool.length > 0) {
83
+ return payload.tool
84
+ }
85
+ return event.source
86
+ }
87
+
88
+ function resolveFindingsCount(payload: Record<string, unknown>): number {
89
+ return typeof payload.findingsCount === "number" ? payload.findingsCount : 0
90
+ }
91
+
92
+ function resolveToolSuccess(payload: Record<string, unknown>): boolean {
93
+ return payload.success !== false
94
+ }
95
+
96
+ function asStringArray(value: unknown): string[] | undefined {
97
+ if (!Array.isArray(value)) return undefined
98
+ return value.filter((item): item is string => typeof item === "string")
99
+ }
100
+
101
+ function asString(value: unknown): string | undefined {
102
+ return typeof value === "string" ? value : undefined
103
+ }
104
+
105
+ function asSoloditResults(value: unknown): SoloditResult[] | undefined {
106
+ if (!Array.isArray(value)) return undefined
107
+
108
+ const results: SoloditResult[] = []
109
+ for (const raw of value) {
110
+ if (!isRecord(raw)) continue
111
+ if (typeof raw.query !== "string" || typeof raw.timestamp !== "number") continue
112
+ if (typeof raw.resultCount !== "number" || !Array.isArray(raw.topResults)) continue
113
+ const topResults = raw.topResults
114
+ .filter((entry): entry is Record<string, unknown> => isRecord(entry))
115
+ .map((entry) => ({
116
+ title: typeof entry.title === "string" ? entry.title : "",
117
+ severity: typeof entry.severity === "string" ? entry.severity : "",
118
+ url: typeof entry.url === "string" ? entry.url : "",
119
+ protocol: typeof entry.protocol === "string" ? entry.protocol : "",
120
+ }))
121
+
122
+ results.push({
123
+ query: raw.query,
124
+ timestamp: raw.timestamp,
125
+ resultCount: raw.resultCount,
126
+ topResults,
127
+ })
128
+ }
129
+
130
+ return results
131
+ }
132
+
133
+ function asFuzzCounterexamples(value: unknown): FuzzCounterexample[] | undefined {
134
+ if (!Array.isArray(value)) return undefined
135
+
136
+ const results: FuzzCounterexample[] = []
137
+ for (const raw of value) {
138
+ if (!isRecord(raw)) continue
139
+ if (typeof raw.testName !== "string" || typeof raw.runs !== "number") continue
140
+ if (typeof raw.timestamp !== "number") continue
141
+
142
+ const inputs = asStringArray(raw.inputs) ?? []
143
+ results.push({
144
+ testName: raw.testName,
145
+ inputs,
146
+ runs: raw.runs,
147
+ seed: typeof raw.seed === "number" ? raw.seed : undefined,
148
+ revertReason: typeof raw.revertReason === "string" ? raw.revertReason : undefined,
149
+ timestamp: raw.timestamp,
150
+ })
151
+ }
152
+
153
+ return results
154
+ }
155
+
156
+ function asCoverageReport(value: unknown): CoverageReport | undefined {
157
+ if (!isRecord(value) || !Array.isArray(value.files)) return undefined
158
+
159
+ const files = value.files
160
+ .filter((entry): entry is Record<string, unknown> => isRecord(entry))
161
+ .map((entry) => ({
162
+ path: typeof entry.path === "string" ? entry.path : "",
163
+ linesPct: typeof entry.linesPct === "number" ? entry.linesPct : 0,
164
+ statementsPct: typeof entry.statementsPct === "number" ? entry.statementsPct : 0,
165
+ branchesPct: typeof entry.branchesPct === "number" ? entry.branchesPct : 0,
166
+ functionsPct: typeof entry.functionsPct === "number" ? entry.functionsPct : 0,
167
+ }))
168
+
169
+ return { files }
170
+ }
171
+
172
+ function asGasHotspots(value: unknown): GasHotspot[] | undefined {
173
+ if (!Array.isArray(value)) return undefined
174
+
175
+ return value
176
+ .filter((entry): entry is Record<string, unknown> => isRecord(entry))
177
+ .map((entry) => ({
178
+ contract: typeof entry.contract === "string" ? entry.contract : "",
179
+ function: typeof entry.function === "string" ? entry.function : "",
180
+ avgGas: typeof entry.avgGas === "number" ? entry.avgGas : 0,
181
+ }))
182
+ }
183
+
184
+ function asProxyContracts(value: unknown): ProxyContract[] | undefined {
185
+ if (!Array.isArray(value)) return undefined
186
+
187
+ return value
188
+ .filter((entry): entry is Record<string, unknown> => isRecord(entry))
189
+ .map((entry) => ({
190
+ file: typeof entry.file === "string" ? entry.file : "",
191
+ proxyType: typeof entry.proxyType === "string" ? entry.proxyType : "",
192
+ indicators: asStringArray(entry.indicators) ?? [],
193
+ }))
194
+ }
195
+
196
+ function extractLatestFromPayload<T>(
197
+ events: AuditEvent[],
198
+ key: string,
199
+ parser: (value: unknown) => T | undefined,
200
+ ): T | undefined {
201
+ let latest: T | undefined
202
+
203
+ for (const event of events) {
204
+ if (!isRecord(event.payload)) continue
205
+ if (!(key in event.payload)) continue
206
+
207
+ const parsed = parser(event.payload[key])
208
+ if (parsed !== undefined) {
209
+ latest = parsed
210
+ }
211
+ }
212
+
213
+ return latest
214
+ }
215
+
216
+ export function validateEventSequence(events: AuditEvent[]): void {
217
+ if (events.length === 0) return
218
+
219
+ const seen = new Set<number>()
220
+
221
+ for (const event of events) {
222
+ if (!Number.isInteger(event.seq) || event.seq <= 0) {
223
+ throw new ProjectorError("INVALID_EVENT", `invalid seq ${event.seq}`)
224
+ }
225
+
226
+ if (seen.has(event.seq)) {
227
+ throw new ProjectorError("DUPLICATE_SEQ", `duplicate seq ${event.seq}`)
228
+ }
229
+
230
+ seen.add(event.seq)
231
+ }
232
+
233
+ for (let i = 0; i < events.length; i++) {
234
+ const expectedSeq = i + 1
235
+ const actualSeq = events[i]?.seq
236
+ if (actualSeq !== expectedSeq) {
237
+ throw new ProjectorError(
238
+ "OUT_OF_ORDER",
239
+ `expected seq ${expectedSeq} at index ${i}, got ${String(actualSeq)}`,
240
+ )
241
+ }
242
+ }
243
+ }
244
+
245
+ export function projectFindings(events: AuditEvent[]): CanonicalFinding[] {
246
+ validateEventSequence(events)
247
+
248
+ const byId = new Map<string, CanonicalFinding>()
249
+
250
+ for (const event of events) {
251
+ if (event.type !== "finding.added") continue
252
+
253
+ const validation = validateCanonicalFinding(event.payload)
254
+ if (!validation.success) {
255
+ throw new ProjectorError(
256
+ "INVALID_EVENT",
257
+ `invalid finding payload at seq ${event.seq}: ${validation.errors.map((e) => e.field).join(",")}`,
258
+ )
259
+ }
260
+
261
+ byId.set(validation.data.id, validation.data)
262
+ }
263
+
264
+ return Array.from(byId.values()).sort((left, right) => {
265
+ const bySeverity = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
266
+ if (bySeverity !== 0) return bySeverity
267
+
268
+ const byFile = left.file.localeCompare(right.file)
269
+ if (byFile !== 0) return byFile
270
+
271
+ const byLine = left.lines[0] - right.lines[0]
272
+ if (byLine !== 0) return byLine
273
+
274
+ return left.id.localeCompare(right.id)
275
+ })
276
+ }
277
+
278
+ export function projectToolExecutions(events: AuditEvent[]): CanonicalToolExecution[] {
279
+ validateEventSequence(events)
280
+
281
+ const executionsByCallId = new Map<string, CanonicalToolExecution>()
282
+
283
+ for (const event of events) {
284
+ if (event.type !== "tool.started" && event.type !== "tool.completed") continue
285
+ if (typeof event.tool_call_id !== "string" || event.tool_call_id.length === 0) {
286
+ throw new ProjectorError(
287
+ "INVALID_EVENT",
288
+ `${event.type} at seq ${event.seq} missing tool_call_id`,
289
+ )
290
+ }
291
+
292
+ const payload = getPayloadRecord(event)
293
+ const existing = executionsByCallId.get(event.tool_call_id)
294
+
295
+ if (event.type === "tool.started") {
296
+ executionsByCallId.set(event.tool_call_id, {
297
+ run_id: event.run_id,
298
+ schema_version: event.schema_version,
299
+ tool: resolveToolName(event, payload),
300
+ startTime: event.timestamp,
301
+ endTime: existing?.endTime,
302
+ success: existing?.success ?? false,
303
+ findingsCount: existing?.findingsCount ?? 0,
304
+ })
305
+ continue
306
+ }
307
+
308
+ const base: CanonicalToolExecution = existing ?? {
309
+ run_id: event.run_id,
310
+ schema_version: event.schema_version,
311
+ tool: resolveToolName(event, payload),
312
+ startTime: event.timestamp,
313
+ success: false,
314
+ findingsCount: 0,
315
+ }
316
+
317
+ executionsByCallId.set(event.tool_call_id, {
318
+ ...base,
319
+ tool: resolveToolName(event, payload),
320
+ endTime: event.timestamp,
321
+ success: resolveToolSuccess(payload),
322
+ findingsCount: resolveFindingsCount(payload),
323
+ run_id: event.run_id,
324
+ schema_version: event.schema_version,
325
+ })
326
+ }
327
+
328
+ return Array.from(executionsByCallId.values()).sort((left, right) => {
329
+ if (left.startTime !== right.startTime) {
330
+ return left.startTime - right.startTime
331
+ }
332
+ return left.tool.localeCompare(right.tool)
333
+ })
334
+ }
335
+
336
+ export function projectAuditState(events: AuditEvent[], projectDir: string): AuditState {
337
+ validateEventSequence(events)
338
+
339
+ const sessionCreated = events.find((event) => event.type === "session.created")
340
+ const scope = sessionCreated ? extractScope(sessionCreated.payload) : []
341
+
342
+ const findings = projectFindings(events)
343
+ const toolsExecuted = projectToolExecutions(events)
344
+
345
+ const contractsReviewed = sortedUnique(
346
+ findings.map((finding) => finding.file).filter((file) => file.length > 0),
347
+ )
348
+
349
+ let currentPhase: AuditPhase = "reconnaissance"
350
+ for (const event of events) {
351
+ if (event.type !== "phase.changed") continue
352
+ const phase = extractPhase(event.payload)
353
+ if (phase) {
354
+ currentPhase = phase
355
+ }
356
+ }
357
+
358
+ return {
359
+ sessionId: sessionCreated?.session_id ?? events[0]?.session_id ?? "",
360
+ projectDir,
361
+ contractsReviewed,
362
+ findings: findings as Finding[],
363
+ toolsExecuted: toolsExecuted as ToolExecution[],
364
+ currentPhase,
365
+ scope,
366
+ startTime: events[0]?.timestamp ?? 0,
367
+ }
368
+ }
369
+
370
+ export function projectReportInput(
371
+ events: AuditEvent[],
372
+ runId: string,
373
+ projectDir: string,
374
+ ): ReportInput {
375
+ validateEventSequence(events)
376
+
377
+ const sessionCreated = events.find((event) => event.type === "session.created")
378
+ const latestFinalized = [...events].reverse().find((event) => event.type === "run.finalized")
379
+
380
+ const findings = projectFindings(events)
381
+ const toolsExecuted = projectToolExecutions(events)
382
+
383
+ const scope = sessionCreated ? extractScope(sessionCreated.payload) : []
384
+ const soloditResults = extractLatestFromPayload(events, "soloditResults", asSoloditResults)
385
+ const fuzzCounterexamples = extractLatestFromPayload(
386
+ events,
387
+ "fuzzCounterexamples",
388
+ asFuzzCounterexamples,
389
+ )
390
+ const coverageReport = extractLatestFromPayload(events, "coverageReport", asCoverageReport)
391
+ const gasHotspots = extractLatestFromPayload(events, "gasHotspots", asGasHotspots)
392
+ const proxyContracts = extractLatestFromPayload(events, "proxyContracts", asProxyContracts)
393
+ const patternVersion = extractLatestFromPayload(events, "patternVersion", asString)
394
+ const skillsLoaded = extractLatestFromPayload(events, "skillsLoaded", asStringArray)
395
+
396
+ return {
397
+ run_id: runId,
398
+ seq: events.at(-1)?.seq ?? 0,
399
+ session_id: sessionCreated?.session_id ?? events[0]?.session_id ?? "",
400
+ tool_call_id: latestFinalized?.tool_call_id ?? "",
401
+ source: latestFinalized?.source ?? events[0]?.source ?? "projector",
402
+ schema_version: latestFinalized?.schema_version ?? events[0]?.schema_version ?? SCHEMA_VERSION,
403
+ projectDir,
404
+ findings,
405
+ toolsExecuted,
406
+ scope,
407
+ soloditResults,
408
+ fuzzCounterexamples,
409
+ coverageReport,
410
+ gasHotspots,
411
+ proxyContracts,
412
+ patternVersion,
413
+ skillsLoaded,
414
+ }
415
+ }
416
+
417
+ function sortForStableStringify(value: unknown): unknown {
418
+ if (Array.isArray(value)) {
419
+ return value.map((item) => sortForStableStringify(item))
420
+ }
421
+
422
+ if (isRecord(value)) {
423
+ const out: Record<string, unknown> = {}
424
+ for (const key of Object.keys(value).sort((a, b) => a.localeCompare(b))) {
425
+ out[key] = sortForStableStringify(value[key])
426
+ }
427
+ return out
428
+ }
429
+
430
+ return value
431
+ }
432
+
433
+ export function stableHash(obj: unknown): string {
434
+ const stable = sortForStableStringify(obj)
435
+ const json = JSON.stringify(stable)
436
+ return createHash("sha256").update(json).digest("hex")
437
+ }