solidity-argus 0.3.7 → 0.5.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.
Files changed (108) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +24 -2
  22. package/src/agents/scribe-prompt.ts +34 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/cli/commands/install.ts +74 -33
  27. package/src/config/loader.ts +29 -5
  28. package/src/config/schema.ts +45 -45
  29. package/src/constants/defaults.ts +1 -0
  30. package/src/create-hooks.ts +806 -173
  31. package/src/create-managers.ts +4 -2
  32. package/src/create-tools.ts +5 -1
  33. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  34. package/src/features/background-agent/background-manager.ts +32 -5
  35. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  36. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  37. package/src/features/persistent-state/event-sink.ts +96 -25
  38. package/src/features/persistent-state/findings-materializer.ts +68 -2
  39. package/src/features/persistent-state/global-run-index.ts +86 -8
  40. package/src/features/persistent-state/index.ts +7 -1
  41. package/src/features/persistent-state/run-finalizer.ts +116 -7
  42. package/src/features/persistent-state/run-pruner.ts +93 -0
  43. package/src/hooks/agent-tracker.ts +14 -2
  44. package/src/hooks/compaction-hook.ts +7 -16
  45. package/src/hooks/config-handler.ts +83 -29
  46. package/src/hooks/context-budget.ts +4 -5
  47. package/src/hooks/event-hook.ts +213 -57
  48. package/src/hooks/knowledge-sync-hook.ts +2 -3
  49. package/src/hooks/safe-create-hook.ts +13 -1
  50. package/src/hooks/system-prompt-hook.ts +20 -39
  51. package/src/hooks/tool-tracking-hook.ts +602 -323
  52. package/src/index.ts +15 -1
  53. package/src/knowledge/scvd-client.ts +2 -4
  54. package/src/knowledge/scvd-errors.ts +25 -2
  55. package/src/knowledge/scvd-index.ts +7 -5
  56. package/src/knowledge/scvd-sync.ts +6 -6
  57. package/src/managers/types.ts +20 -2
  58. package/src/shared/agent-names.ts +23 -0
  59. package/src/shared/audit-artifact-resolver.ts +8 -3
  60. package/src/shared/audit-phases.ts +12 -0
  61. package/src/shared/cache-paths.ts +41 -0
  62. package/src/shared/drop-diagnostics.ts +2 -2
  63. package/src/shared/forge-errors.ts +31 -0
  64. package/src/shared/forge-runner.ts +30 -0
  65. package/src/shared/format-error.ts +3 -0
  66. package/src/shared/index.ts +9 -0
  67. package/src/shared/key-tools.ts +39 -0
  68. package/src/shared/logger.ts +7 -7
  69. package/src/shared/path-containment.ts +25 -0
  70. package/src/shared/path-utils.ts +11 -0
  71. package/src/shared/report-path-resolver.ts +4 -2
  72. package/src/shared/safe-emit.ts +24 -0
  73. package/src/shared/token-utils.ts +5 -0
  74. package/src/shared/type-guards.ts +8 -0
  75. package/src/shared/validation-constants.ts +52 -0
  76. package/src/skills/analysis/cluster.ts +1 -114
  77. package/src/skills/analysis/normalize.ts +2 -114
  78. package/src/skills/analysis/stopwords.ts +109 -0
  79. package/src/skills/argus-skill-resolver.ts +6 -3
  80. package/src/solodit-lifecycle.ts +153 -37
  81. package/src/state/adapters.ts +60 -66
  82. package/src/state/finding-aggregation.ts +6 -8
  83. package/src/state/finding-fingerprint.ts +1 -1
  84. package/src/state/finding-store.ts +31 -9
  85. package/src/state/index.ts +1 -1
  86. package/src/state/projectors.ts +27 -19
  87. package/src/state/schemas.ts +8 -32
  88. package/src/state/types.ts +3 -0
  89. package/src/tools/contract-analyzer-tool.ts +4 -6
  90. package/src/tools/forge-coverage-tool.ts +10 -35
  91. package/src/tools/forge-fuzz-tool.ts +21 -51
  92. package/src/tools/forge-test-tool.ts +25 -47
  93. package/src/tools/gas-analysis-tool.ts +12 -41
  94. package/src/tools/pattern-checker-tool.ts +37 -15
  95. package/src/tools/pattern-loader.ts +18 -4
  96. package/src/tools/persist-deduped-tool.ts +94 -0
  97. package/src/tools/proxy-detection-tool.ts +35 -34
  98. package/src/tools/read-findings-tool.ts +390 -0
  99. package/src/tools/record-finding-tool.ts +130 -25
  100. package/src/tools/report-generator-tool.ts +475 -327
  101. package/src/tools/report-preflight.ts +5 -1
  102. package/src/tools/slither-tool.ts +55 -16
  103. package/src/tools/solodit-search-tool.ts +260 -112
  104. package/src/tools/sync-knowledge-tool.ts +2 -3
  105. package/src/utils/solidity-parser.ts +39 -24
  106. package/src/features/migration/index.ts +0 -14
  107. package/src/features/migration/migration-adapter.ts +0 -151
  108. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -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 isRestarting = false
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
- export let soloditAvailable = false
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
- Bun.spawn(["npx", "-y", "@lyuboslavlyubenov/solodit-mcp"], {
39
- stdin: "ignore",
40
- stdout: "ignore",
41
- stderr: "ignore",
42
- env: { ...process.env, PORT: String(port) },
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
- child.unref()
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.then((code) => {
94
- if (code !== 0 && code !== null) {
95
- logger.warn(`Solodit MCP exited with code ${code}`)
96
- }
97
- if (soloditChild === child) {
98
- soloditChild = null
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
- soloditAvailable = true
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
- soloditAvailable = false
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
- soloditAvailable = true
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
- if (isRestarting) return
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 (!soloditAvailable) {
176
- soloditAvailable = true
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 (soloditAvailable) {
182
- soloditAvailable = false
254
+ } else if (_soloditAvailable) {
255
+ _soloditAvailable = false
183
256
  logger.warn("Solodit MCP health check failed, attempting restart...")
184
- isRestarting = true
185
- try {
186
- await restartSoloditMcp(port)
187
- } finally {
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
- soloditAvailable = false
218
- isRestarting = false
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
- export async function startSoloditMcp(port: number): Promise<void> {
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
- soloditAvailable = true
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
- soloditAvailable = false
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
- soloditAvailable = true
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 (!soloditAvailable) {
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
+ }
@@ -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, FindingSeverity } from "./types"
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 file =
202
+ const rawFile =
205
203
  typeof input.file === "string" && input.file.length > 0
206
204
  ? input.file
207
- : (slitherElementFileAlias(input) ?? "")
208
-
209
- const lines = normalizeLines(input.lines, input)
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 = VALID_REPORTED_AGENTS.has(
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 { createHash } from "node:crypto"
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
- let observationCounter = state.findings.length
37
+ const projectDir = state.projectDir
28
38
 
29
39
  function generateObservationId(check: string, file: string, lines: [number, number]): string {
30
- const key = `${check}:${file}:${lines[0]}-${lines[1]}:${observationCounter}`
31
- observationCounter += 1
32
- return createHash("sha256").update(key).digest("hex").substring(0, 16)
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 id = generateObservationId(finding.check, finding.file, finding.lines)
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
- ...finding,
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 === check &&
76
- finding.file === 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
  )
@@ -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"