solidity-argus 0.3.7 → 0.5.6
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/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +18 -1
- package/src/agents/scribe-prompt.ts +32 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +797 -148
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +34 -2
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +597 -323
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +120 -25
- package/src/tools/report-generator-tool.ts +394 -328
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- package/src/features/migration/parity-telemetry.ts +0 -133
package/src/solodit-lifecycle.ts
CHANGED
|
@@ -19,10 +19,16 @@ export interface LifecycleStatus {
|
|
|
19
19
|
|
|
20
20
|
let soloditChild: SoloditChildProcess | null = null
|
|
21
21
|
let monitorTimer: ReturnType<typeof setInterval> | null = null
|
|
22
|
-
let
|
|
22
|
+
let restartPromise: Promise<boolean | undefined> | null = null
|
|
23
|
+
let startupPromise: Promise<void> | null = null
|
|
23
24
|
|
|
24
25
|
/** Whether the Solodit MCP server is currently available for tool calls. */
|
|
25
|
-
|
|
26
|
+
let _soloditAvailable = false
|
|
27
|
+
|
|
28
|
+
/** Returns whether the Solodit MCP server is currently available. */
|
|
29
|
+
export function isSoloditAvailable(): boolean {
|
|
30
|
+
return _soloditAvailable
|
|
31
|
+
}
|
|
26
32
|
|
|
27
33
|
let lifecycleState: LifecycleState = "stopped"
|
|
28
34
|
let lifecycleError: string | undefined
|
|
@@ -34,13 +40,31 @@ const HEALTH_CHECK_INTERVAL_MS = 60_000
|
|
|
34
40
|
let restartSettleMs = DEFAULT_RESTART_SETTLE_MS
|
|
35
41
|
let retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS
|
|
36
42
|
|
|
43
|
+
function withSuppressedParentOutput<T>(fn: () => T): T {
|
|
44
|
+
const savedStdoutWrite = process.stdout.write.bind(process.stdout)
|
|
45
|
+
const savedStderrWrite = process.stderr.write.bind(process.stderr)
|
|
46
|
+
const noop = (() => true) as typeof process.stdout.write
|
|
47
|
+
|
|
48
|
+
process.stdout.write = noop
|
|
49
|
+
process.stderr.write = noop
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return fn()
|
|
53
|
+
} finally {
|
|
54
|
+
process.stdout.write = savedStdoutWrite
|
|
55
|
+
process.stderr.write = savedStderrWrite
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
const defaultSpawnFn = (port: number): SoloditChildProcess =>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
withSuppressedParentOutput(() =>
|
|
61
|
+
Bun.spawn(["npx", "-y", "@lyuboslavlyubenov/solodit-mcp"], {
|
|
62
|
+
stdin: "ignore",
|
|
63
|
+
stdout: "ignore",
|
|
64
|
+
stderr: "ignore",
|
|
65
|
+
env: { ...process.env, PORT: String(port) },
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
44
68
|
|
|
45
69
|
let spawnFn: (port: number) => SoloditChildProcess = defaultSpawnFn
|
|
46
70
|
|
|
@@ -78,7 +102,9 @@ function classifySpawnError(err: unknown, port: number): string {
|
|
|
78
102
|
function spawnSoloditChild(port: number): SoloditChildProcess {
|
|
79
103
|
try {
|
|
80
104
|
const child = spawnFn(port)
|
|
81
|
-
|
|
105
|
+
// Do NOT unref() — child must die with the parent process.
|
|
106
|
+
// unref() lets the parent exit without waiting for the child,
|
|
107
|
+
// creating orphaned solodit-mcp processes that hoard ports.
|
|
82
108
|
return child
|
|
83
109
|
} catch (err) {
|
|
84
110
|
const message = classifySpawnError(err, port)
|
|
@@ -90,14 +116,56 @@ function spawnSoloditChild(port: number): SoloditChildProcess {
|
|
|
90
116
|
|
|
91
117
|
function trackChildExit(child: SoloditChildProcess): void {
|
|
92
118
|
const logger = createLogger()
|
|
93
|
-
child.exited
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
soloditChild
|
|
119
|
+
child.exited
|
|
120
|
+
.then((code) => {
|
|
121
|
+
if (code !== 0 && code !== null) {
|
|
122
|
+
logger.warn(`Solodit MCP exited with code ${code}`)
|
|
123
|
+
}
|
|
124
|
+
if (soloditChild === child) {
|
|
125
|
+
soloditChild = null
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
logger.warn(
|
|
130
|
+
`Solodit MCP exit tracking failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
131
|
+
)
|
|
132
|
+
if (soloditChild === child) {
|
|
133
|
+
soloditChild = null
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Kill the solodit-mcp child process. Called on parent exit to prevent orphans. */
|
|
139
|
+
function killSoloditChild(): void {
|
|
140
|
+
if (soloditChild) {
|
|
141
|
+
try {
|
|
142
|
+
soloditChild.kill()
|
|
143
|
+
} catch {
|
|
144
|
+
// Process already dead — ignore.
|
|
99
145
|
}
|
|
100
|
-
|
|
146
|
+
soloditChild = null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Register once: kill child on parent exit to prevent orphaned processes.
|
|
151
|
+
let exitHandlerRegistered = false
|
|
152
|
+
let sigintHandler: (() => void) | null = null
|
|
153
|
+
let sigtermHandler: (() => void) | null = null
|
|
154
|
+
|
|
155
|
+
function ensureExitHandler(): void {
|
|
156
|
+
if (exitHandlerRegistered) return
|
|
157
|
+
exitHandlerRegistered = true
|
|
158
|
+
process.on("exit", killSoloditChild)
|
|
159
|
+
sigintHandler = () => {
|
|
160
|
+
killSoloditChild()
|
|
161
|
+
process.exit(130)
|
|
162
|
+
}
|
|
163
|
+
sigtermHandler = () => {
|
|
164
|
+
killSoloditChild()
|
|
165
|
+
process.exit(143)
|
|
166
|
+
}
|
|
167
|
+
process.on("SIGINT", sigintHandler)
|
|
168
|
+
process.on("SIGTERM", sigtermHandler)
|
|
101
169
|
}
|
|
102
170
|
|
|
103
171
|
async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
@@ -106,7 +174,7 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
|
106
174
|
// Pre-check: if existing instance recovered, skip restart entirely
|
|
107
175
|
const preCheck = await checkSoloditHealth(port, true)
|
|
108
176
|
if (preCheck.reachable) {
|
|
109
|
-
|
|
177
|
+
_soloditAvailable = true
|
|
110
178
|
lifecycleState = "running"
|
|
111
179
|
lifecycleError = undefined
|
|
112
180
|
logger.info("Solodit MCP already healthy — skipping restart")
|
|
@@ -132,7 +200,7 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
|
132
200
|
logger.warn(`Solodit MCP spawn failed: ${message}`)
|
|
133
201
|
lifecycleState = "failed"
|
|
134
202
|
lifecycleError = message
|
|
135
|
-
|
|
203
|
+
_soloditAvailable = false
|
|
136
204
|
return false
|
|
137
205
|
}
|
|
138
206
|
|
|
@@ -153,7 +221,7 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
|
153
221
|
)
|
|
154
222
|
|
|
155
223
|
if (result.success) {
|
|
156
|
-
|
|
224
|
+
_soloditAvailable = true
|
|
157
225
|
lifecycleState = "running"
|
|
158
226
|
lifecycleError = undefined
|
|
159
227
|
logger.info("Solodit MCP restarted successfully")
|
|
@@ -167,26 +235,29 @@ async function restartSoloditMcp(port: number): Promise<boolean> {
|
|
|
167
235
|
}
|
|
168
236
|
|
|
169
237
|
export async function _runMonitoringCycle(port: number): Promise<void> {
|
|
170
|
-
|
|
238
|
+
// Use a promise-based mutex to prevent concurrent restart attempts.
|
|
239
|
+
// If a restart is already in flight, wait for it rather than starting another.
|
|
240
|
+
if (restartPromise) {
|
|
241
|
+
await restartPromise.catch(() => {})
|
|
242
|
+
return
|
|
243
|
+
}
|
|
171
244
|
const logger = createLogger()
|
|
172
245
|
try {
|
|
173
246
|
const health = await checkSoloditHealth(port, true)
|
|
174
247
|
if (health.reachable) {
|
|
175
|
-
if (!
|
|
176
|
-
|
|
248
|
+
if (!_soloditAvailable) {
|
|
249
|
+
_soloditAvailable = true
|
|
177
250
|
lifecycleState = "running"
|
|
178
251
|
lifecycleError = undefined
|
|
179
252
|
logger.info("Solodit MCP recovered — now available")
|
|
180
253
|
}
|
|
181
|
-
} else if (
|
|
182
|
-
|
|
254
|
+
} else if (_soloditAvailable) {
|
|
255
|
+
_soloditAvailable = false
|
|
183
256
|
logger.warn("Solodit MCP health check failed, attempting restart...")
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
isRestarting = false
|
|
189
|
-
}
|
|
257
|
+
restartPromise = restartSoloditMcp(port).finally(() => {
|
|
258
|
+
restartPromise = null
|
|
259
|
+
})
|
|
260
|
+
await restartPromise
|
|
190
261
|
}
|
|
191
262
|
} catch {
|
|
192
263
|
logger.debug("Monitoring cycle encountered an error")
|
|
@@ -214,8 +285,9 @@ export function stopSoloditMonitoring(): void {
|
|
|
214
285
|
/** Reset all Solodit state — for testing only. */
|
|
215
286
|
export function _resetSoloditState(): void {
|
|
216
287
|
stopSoloditMonitoring()
|
|
217
|
-
|
|
218
|
-
|
|
288
|
+
_soloditAvailable = false
|
|
289
|
+
restartPromise = null
|
|
290
|
+
startupPromise = null
|
|
219
291
|
lifecycleState = "stopped"
|
|
220
292
|
lifecycleError = undefined
|
|
221
293
|
restartSettleMs = DEFAULT_RESTART_SETTLE_MS
|
|
@@ -229,17 +301,35 @@ export function _resetSoloditState(): void {
|
|
|
229
301
|
}
|
|
230
302
|
soloditChild = null
|
|
231
303
|
}
|
|
304
|
+
// Remove registered signal/exit handlers to prevent accumulation
|
|
305
|
+
process.removeListener("exit", killSoloditChild)
|
|
306
|
+
if (sigintHandler) {
|
|
307
|
+
process.removeListener("SIGINT", sigintHandler)
|
|
308
|
+
sigintHandler = null
|
|
309
|
+
}
|
|
310
|
+
if (sigtermHandler) {
|
|
311
|
+
process.removeListener("SIGTERM", sigtermHandler)
|
|
312
|
+
sigtermHandler = null
|
|
313
|
+
}
|
|
314
|
+
// Reset exit handler so tests can re-register cleanly
|
|
315
|
+
exitHandlerRegistered = false
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Set _soloditAvailable flag — for testing only. */
|
|
319
|
+
export function _setSoloditAvailable(value: boolean): void {
|
|
320
|
+
_soloditAvailable = value
|
|
232
321
|
}
|
|
233
322
|
|
|
234
|
-
|
|
323
|
+
async function startSoloditMcpInternal(port: number): Promise<void> {
|
|
235
324
|
const logger = createLogger()
|
|
236
325
|
lifecycleState = "starting"
|
|
237
326
|
lifecycleError = undefined
|
|
327
|
+
ensureExitHandler()
|
|
238
328
|
|
|
239
329
|
const health = await checkSoloditHealth(port, true)
|
|
240
330
|
if (health.reachable) {
|
|
241
331
|
logger.debug(`Solodit MCP already running on port ${port} — skipping spawn`)
|
|
242
|
-
|
|
332
|
+
_soloditAvailable = true
|
|
243
333
|
lifecycleState = "running"
|
|
244
334
|
startMonitoring(port)
|
|
245
335
|
return
|
|
@@ -253,7 +343,7 @@ export async function startSoloditMcp(port: number): Promise<void> {
|
|
|
253
343
|
logger.warn(`Solodit MCP startup failed: ${message}`)
|
|
254
344
|
lifecycleState = "failed"
|
|
255
345
|
lifecycleError = message
|
|
256
|
-
|
|
346
|
+
_soloditAvailable = false
|
|
257
347
|
startMonitoring(port)
|
|
258
348
|
return
|
|
259
349
|
}
|
|
@@ -266,13 +356,13 @@ export async function startSoloditMcp(port: number): Promise<void> {
|
|
|
266
356
|
if (deadline.aborted) break
|
|
267
357
|
const healthResult = await checkSoloditHealth(port, true)
|
|
268
358
|
if (healthResult.reachable) {
|
|
269
|
-
|
|
359
|
+
_soloditAvailable = true
|
|
270
360
|
lifecycleState = "running"
|
|
271
361
|
logger.debug(`Solodit MCP healthy on port ${port}`)
|
|
272
362
|
break
|
|
273
363
|
}
|
|
274
364
|
}
|
|
275
|
-
if (!
|
|
365
|
+
if (!_soloditAvailable) {
|
|
276
366
|
lifecycleState = "failed"
|
|
277
367
|
lifecycleError = "Solodit MCP not reachable after startup — monitoring will retry"
|
|
278
368
|
logger.warn(lifecycleError)
|
|
@@ -280,3 +370,29 @@ export async function startSoloditMcp(port: number): Promise<void> {
|
|
|
280
370
|
|
|
281
371
|
startMonitoring(port)
|
|
282
372
|
}
|
|
373
|
+
|
|
374
|
+
export async function startSoloditMcp(
|
|
375
|
+
port: number,
|
|
376
|
+
options: { waitForHealth?: boolean } = {},
|
|
377
|
+
): Promise<void> {
|
|
378
|
+
const waitForHealth = options.waitForHealth ?? true
|
|
379
|
+
|
|
380
|
+
if (startupPromise) {
|
|
381
|
+
if (waitForHealth) {
|
|
382
|
+
await startupPromise
|
|
383
|
+
}
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let promise!: Promise<void>
|
|
388
|
+
promise = startSoloditMcpInternal(port).finally(() => {
|
|
389
|
+
if (startupPromise === promise) {
|
|
390
|
+
startupPromise = null
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
startupPromise = promise
|
|
394
|
+
|
|
395
|
+
if (waitForHealth) {
|
|
396
|
+
await promise
|
|
397
|
+
}
|
|
398
|
+
}
|
package/src/state/adapters.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { normalizeFilePath } from "../shared/path-utils"
|
|
2
|
+
import { isRecord } from "../shared/type-guards"
|
|
3
|
+
import {
|
|
4
|
+
VALID_AGENTS,
|
|
5
|
+
VALID_CONFIDENCES,
|
|
6
|
+
VALID_SEVERITIES,
|
|
7
|
+
VALID_SOURCES,
|
|
8
|
+
} from "../shared/validation-constants"
|
|
1
9
|
import { computeIssueFingerprint, computeObservationFingerprint } from "./finding-fingerprint"
|
|
2
10
|
import {
|
|
3
11
|
type CanonicalFinding,
|
|
@@ -5,7 +13,7 @@ import {
|
|
|
5
13
|
type ValidationError,
|
|
6
14
|
validateCanonicalFinding,
|
|
7
15
|
} from "./schemas"
|
|
8
|
-
import type { ArgusAgentName, AuditPhase, Finding
|
|
16
|
+
import type { ArgusAgentName, AuditPhase, Finding } from "./types"
|
|
9
17
|
|
|
10
18
|
export interface Diagnostic {
|
|
11
19
|
level: "warn" | "error"
|
|
@@ -16,38 +24,12 @@ export interface Diagnostic {
|
|
|
16
24
|
|
|
17
25
|
export type AdapterResult<T> = { data: T; diagnostics: Diagnostic[] }
|
|
18
26
|
|
|
19
|
-
const VALID_SEVERITIES: ReadonlySet<FindingSeverity> = new Set([
|
|
20
|
-
"Critical",
|
|
21
|
-
"High",
|
|
22
|
-
"Medium",
|
|
23
|
-
"Low",
|
|
24
|
-
"Informational",
|
|
25
|
-
])
|
|
26
|
-
const VALID_CONFIDENCES: ReadonlySet<CanonicalFinding["confidence"]> = new Set([
|
|
27
|
-
"High",
|
|
28
|
-
"Medium",
|
|
29
|
-
"Low",
|
|
30
|
-
])
|
|
31
|
-
const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
|
|
32
|
-
"slither",
|
|
33
|
-
"manual",
|
|
34
|
-
"pattern",
|
|
35
|
-
"scvd",
|
|
36
|
-
"solodit",
|
|
37
|
-
"fuzz",
|
|
38
|
-
])
|
|
39
|
-
const VALID_REPORTED_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
|
|
40
|
-
"argus",
|
|
41
|
-
"sentinel",
|
|
42
|
-
"pythia",
|
|
43
|
-
"scribe",
|
|
44
|
-
"unknown",
|
|
45
|
-
])
|
|
46
|
-
|
|
47
27
|
const KNOWN_INPUT_FIELDS = new Set([
|
|
48
28
|
"id",
|
|
49
29
|
"check",
|
|
50
30
|
"detector",
|
|
31
|
+
"title",
|
|
32
|
+
"name",
|
|
51
33
|
"severity",
|
|
52
34
|
"confidence",
|
|
53
35
|
"description",
|
|
@@ -59,6 +41,9 @@ const KNOWN_INPUT_FIELDS = new Set([
|
|
|
59
41
|
"line_start",
|
|
60
42
|
"line_end",
|
|
61
43
|
"source",
|
|
44
|
+
"recommendation",
|
|
45
|
+
"proofOfConcept",
|
|
46
|
+
"proof_of_concept",
|
|
62
47
|
"remediation",
|
|
63
48
|
"exploitReference",
|
|
64
49
|
"provenance",
|
|
@@ -78,6 +63,7 @@ const KNOWN_INPUT_FIELDS = new Set([
|
|
|
78
63
|
"observationFingerprint",
|
|
79
64
|
"issueFingerprint",
|
|
80
65
|
"elements",
|
|
66
|
+
"location",
|
|
81
67
|
])
|
|
82
68
|
|
|
83
69
|
export interface NormalizeFindingOptions {
|
|
@@ -87,10 +73,6 @@ export interface NormalizeFindingOptions {
|
|
|
87
73
|
observationId?: string
|
|
88
74
|
}
|
|
89
75
|
|
|
90
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
91
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
76
|
function normalizeSeverity(value: unknown): CanonicalFinding["severity"] {
|
|
95
77
|
if (typeof value !== "string") return "Informational"
|
|
96
78
|
const lower = value.toLowerCase()
|
|
@@ -140,6 +122,17 @@ function normalizeLines(
|
|
|
140
122
|
return undefined
|
|
141
123
|
}
|
|
142
124
|
|
|
125
|
+
function extractFileFromLocation(location: string): string {
|
|
126
|
+
const colonIndex = location.lastIndexOf(":")
|
|
127
|
+
if (colonIndex > 0) {
|
|
128
|
+
const afterColon = location.substring(colonIndex + 1)
|
|
129
|
+
if (/^\d+(-\d+)?$/.test(afterColon)) {
|
|
130
|
+
return location.substring(0, colonIndex)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return location
|
|
134
|
+
}
|
|
135
|
+
|
|
143
136
|
function slitherElementFileAlias(input: Record<string, unknown>): string | undefined {
|
|
144
137
|
if (!Array.isArray(input.elements) || input.elements.length === 0) {
|
|
145
138
|
return undefined
|
|
@@ -169,6 +162,7 @@ export function normalizeToCanonicalFinding(
|
|
|
169
162
|
runId: string,
|
|
170
163
|
seq: number,
|
|
171
164
|
options: NormalizeFindingOptions = {},
|
|
165
|
+
projectDir?: string,
|
|
172
166
|
): AdapterResult<CanonicalFinding> {
|
|
173
167
|
const diagnostics: Diagnostic[] = []
|
|
174
168
|
const input = isRecord(raw) ? raw : {}
|
|
@@ -189,7 +183,11 @@ export function normalizeToCanonicalFinding(
|
|
|
189
183
|
? input.check
|
|
190
184
|
: typeof input.detector === "string" && input.detector.length > 0
|
|
191
185
|
? input.detector
|
|
192
|
-
: ""
|
|
186
|
+
: typeof input.title === "string" && input.title.length > 0
|
|
187
|
+
? input.title
|
|
188
|
+
: typeof input.name === "string" && input.name.length > 0
|
|
189
|
+
? input.name
|
|
190
|
+
: ""
|
|
193
191
|
|
|
194
192
|
const description =
|
|
195
193
|
typeof input.description === "string" && input.description.length > 0
|
|
@@ -201,12 +199,23 @@ export function normalizeToCanonicalFinding(
|
|
|
201
199
|
? input.first_markdown_element
|
|
202
200
|
: check
|
|
203
201
|
|
|
204
|
-
const
|
|
202
|
+
const rawFile =
|
|
205
203
|
typeof input.file === "string" && input.file.length > 0
|
|
206
204
|
? input.file
|
|
207
|
-
:
|
|
208
|
-
|
|
209
|
-
|
|
205
|
+
: typeof input.location === "string" && input.location.length > 0
|
|
206
|
+
? extractFileFromLocation(input.location)
|
|
207
|
+
: (slitherElementFileAlias(input) ?? "")
|
|
208
|
+
const file = projectDir ? normalizeFilePath(rawFile, projectDir) : rawFile
|
|
209
|
+
|
|
210
|
+
let lines = normalizeLines(input.lines, input)
|
|
211
|
+
if (!lines && typeof input.location === "string") {
|
|
212
|
+
const match = input.location.match(/:(\d+)(?:-(\d+))?$/)
|
|
213
|
+
if (match) {
|
|
214
|
+
const start = parseInt(match[1] ?? "0", 10)
|
|
215
|
+
const end = match[2] ? parseInt(match[2], 10) : start
|
|
216
|
+
lines = [start, end] as [number, number]
|
|
217
|
+
}
|
|
218
|
+
}
|
|
210
219
|
const severity = normalizeSeverity(input.severity)
|
|
211
220
|
const confidence = normalizeConfidence(input.confidence)
|
|
212
221
|
const source =
|
|
@@ -220,9 +229,7 @@ export function normalizeToCanonicalFinding(
|
|
|
220
229
|
(typeof input.reportedByAgent === "string" ? input.reportedByAgent : undefined) ??
|
|
221
230
|
options.reportedByAgent ??
|
|
222
231
|
"unknown"
|
|
223
|
-
const reportedByAgent: ArgusAgentName =
|
|
224
|
-
reportedByAgentRaw as ArgusAgentName,
|
|
225
|
-
)
|
|
232
|
+
const reportedByAgent: ArgusAgentName = VALID_AGENTS.has(reportedByAgentRaw as ArgusAgentName)
|
|
226
233
|
? (reportedByAgentRaw as ArgusAgentName)
|
|
227
234
|
: "unknown"
|
|
228
235
|
|
|
@@ -295,6 +302,18 @@ export function normalizeToCanonicalFinding(
|
|
|
295
302
|
issue_fingerprint: issueFingerprint,
|
|
296
303
|
observation_fingerprint: observationFingerprint,
|
|
297
304
|
observation_id: observationId,
|
|
305
|
+
impact: typeof input.impact === "string" && input.impact.length > 0 ? input.impact : undefined,
|
|
306
|
+
recommendation:
|
|
307
|
+
typeof input.recommendation === "string" && input.recommendation.length > 0
|
|
308
|
+
? input.recommendation
|
|
309
|
+
: undefined,
|
|
310
|
+
proofOfConcept:
|
|
311
|
+
(typeof input.proofOfConcept === "string" && input.proofOfConcept.length > 0
|
|
312
|
+
? input.proofOfConcept
|
|
313
|
+
: undefined) ??
|
|
314
|
+
(typeof input.proof_of_concept === "string" && input.proof_of_concept.length > 0
|
|
315
|
+
? input.proof_of_concept
|
|
316
|
+
: undefined),
|
|
298
317
|
remediation: typeof input.remediation === "string" ? input.remediation : undefined,
|
|
299
318
|
exploitReference:
|
|
300
319
|
typeof input.exploitReference === "string" ? input.exploitReference : undefined,
|
|
@@ -329,28 +348,3 @@ export function normalizeToCanonicalFinding(
|
|
|
329
348
|
|
|
330
349
|
return { data: canonical, diagnostics }
|
|
331
350
|
}
|
|
332
|
-
|
|
333
|
-
export function normalizeLegacyFindingsArray(
|
|
334
|
-
raw: unknown[],
|
|
335
|
-
runId: string,
|
|
336
|
-
): { findings: CanonicalFinding[]; diagnostics: Diagnostic[] } {
|
|
337
|
-
const findings: CanonicalFinding[] = []
|
|
338
|
-
const diagnostics: Diagnostic[] = []
|
|
339
|
-
|
|
340
|
-
for (const [index, item] of raw.entries()) {
|
|
341
|
-
const normalized = normalizeToCanonicalFinding(isRecord(item) ? item : {}, runId, index + 1)
|
|
342
|
-
diagnostics.push(
|
|
343
|
-
...normalized.diagnostics.map((d) => ({
|
|
344
|
-
...d,
|
|
345
|
-
message: `[index:${index}] ${d.message}`,
|
|
346
|
-
})),
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
const hasErrors = normalized.diagnostics.some((d) => d.level === "error")
|
|
350
|
-
if (!hasErrors) {
|
|
351
|
-
findings.push(normalized.data)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return { findings, diagnostics }
|
|
356
|
-
}
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
+
import { SEVERITY_RANK } from "../shared/validation-constants"
|
|
1
2
|
import type { CanonicalFinding } from "./schemas"
|
|
2
3
|
|
|
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
4
|
function uniqueSorted(values: string[]): string[] {
|
|
12
5
|
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right))
|
|
13
6
|
}
|
|
@@ -48,6 +41,10 @@ export function dedupeFindingsForFinalOutput(findings: CanonicalFinding[]): Cano
|
|
|
48
41
|
const base = sortedObservations[0]
|
|
49
42
|
if (!base) continue
|
|
50
43
|
|
|
44
|
+
const highestSeverityObservation = sortedObservations.reduce((best, obs) =>
|
|
45
|
+
SEVERITY_RANK[obs.severity] < SEVERITY_RANK[best.severity] ? obs : best,
|
|
46
|
+
)
|
|
47
|
+
|
|
51
48
|
const reportedByAgents = uniqueSorted(
|
|
52
49
|
sortedObservations.map((finding) => finding.reported_by_agent),
|
|
53
50
|
)
|
|
@@ -58,6 +55,7 @@ export function dedupeFindingsForFinalOutput(findings: CanonicalFinding[]): Cano
|
|
|
58
55
|
|
|
59
56
|
merged.push({
|
|
60
57
|
...base,
|
|
58
|
+
severity: highestSeverityObservation.severity,
|
|
61
59
|
id: issueFingerprint,
|
|
62
60
|
sources,
|
|
63
61
|
reported_by_agents: reportedByAgents,
|
|
@@ -21,7 +21,7 @@ function hash(parts: string[]): string {
|
|
|
21
21
|
return createHash("sha256").update(parts.join("|"), "utf8").digest("hex")
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function normalizeText(value: string): string {
|
|
24
|
+
export function normalizeText(value: string): string {
|
|
25
25
|
return value.trim().toLowerCase()
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import crypto from "node:crypto"
|
|
2
|
+
import { isAbsolute, normalize, relative } from "node:path"
|
|
3
|
+
import { normalizeText } from "./finding-fingerprint"
|
|
2
4
|
import type { AuditState, Finding, FindingSeverity } from "./types"
|
|
3
5
|
|
|
6
|
+
function normalizeStorePath(filePath: string, projectDir: string): string {
|
|
7
|
+
if (!filePath || !projectDir) return filePath
|
|
8
|
+
const n = normalize(filePath)
|
|
9
|
+
if (!isAbsolute(n)) return n.replace(/^\.\//, "")
|
|
10
|
+
const rel = relative(projectDir, n)
|
|
11
|
+
return rel.startsWith("..") ? n : rel
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export interface FindingStore {
|
|
5
15
|
addFinding(finding: Omit<Finding, "id">): Finding
|
|
6
16
|
getFindings(filter?: { severity?: FindingSeverity; source?: Finding["source"] }): Finding[]
|
|
@@ -24,21 +34,31 @@ function isValidHydrationFinding(f: unknown): f is Finding {
|
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export function createFindingStore(state: AuditState): FindingStore {
|
|
27
|
-
|
|
37
|
+
const projectDir = state.projectDir
|
|
28
38
|
|
|
29
39
|
function generateObservationId(check: string, file: string, lines: [number, number]): string {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
return crypto
|
|
41
|
+
.createHash("sha256")
|
|
42
|
+
.update(`${normalizeText(check)}:${normalizeText(file)}:${lines[0]}-${lines[1]}`)
|
|
43
|
+
.digest("hex")
|
|
44
|
+
.substring(0, 16)
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
const hydratedFindings = state.findings.filter(isValidHydrationFinding)
|
|
36
48
|
|
|
37
49
|
function addFinding(finding: Omit<Finding, "id">): Finding {
|
|
38
|
-
const
|
|
50
|
+
const normalizedFile = normalizeStorePath(finding.file, projectDir)
|
|
51
|
+
const normalized =
|
|
52
|
+
normalizedFile !== finding.file ? { ...finding, file: normalizedFile } : finding
|
|
53
|
+
const id = generateObservationId(normalized.check, normalized.file, normalized.lines)
|
|
54
|
+
|
|
55
|
+
const existing = hydratedFindings.find((f) => f.id === id)
|
|
56
|
+
if (existing) {
|
|
57
|
+
return existing
|
|
58
|
+
}
|
|
39
59
|
|
|
40
60
|
const newFinding: Finding = {
|
|
41
|
-
...
|
|
61
|
+
...normalized,
|
|
42
62
|
id,
|
|
43
63
|
}
|
|
44
64
|
|
|
@@ -70,10 +90,12 @@ export function createFindingStore(state: AuditState): FindingStore {
|
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
function hasFinding(check: string, file: string, lines: [number, number]): boolean {
|
|
93
|
+
const normalizedCheck = normalizeText(check)
|
|
94
|
+
const normalizedFile = normalizeText(file)
|
|
73
95
|
return hydratedFindings.some(
|
|
74
96
|
(finding) =>
|
|
75
|
-
finding.check ===
|
|
76
|
-
finding.file ===
|
|
97
|
+
normalizeText(finding.check) === normalizedCheck &&
|
|
98
|
+
normalizeText(finding.file) === normalizedFile &&
|
|
77
99
|
finding.lines[0] === lines[0] &&
|
|
78
100
|
finding.lines[1] === lines[1],
|
|
79
101
|
)
|
package/src/state/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { SEVERITY_RANK } from "../shared/validation-constants"
|
|
1
2
|
export * from "./adapters"
|
|
2
3
|
export { createAuditState } from "./audit-state"
|
|
3
4
|
export { createFindingStore } from "./finding-store"
|
|
@@ -7,7 +8,6 @@ export {
|
|
|
7
8
|
projectFindings,
|
|
8
9
|
projectReportInput,
|
|
9
10
|
projectToolExecutions,
|
|
10
|
-
SEVERITY_RANK,
|
|
11
11
|
stableHash,
|
|
12
12
|
validateEventSequence,
|
|
13
13
|
} from "./projectors"
|