solidity-argus 0.1.7 → 0.2.0

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 (87) hide show
  1. package/README.md +161 -1
  2. package/package.json +5 -2
  3. package/skills/README.md +63 -0
  4. package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
  5. package/skills/manifests/cyfrin.json +16 -0
  6. package/skills/manifests/defifofum.json +25 -0
  7. package/skills/manifests/kadenzipfel.json +48 -0
  8. package/skills/manifests/scvd.json +9 -0
  9. package/skills/manifests/smartbugs.json +11 -0
  10. package/skills/manifests/solodit.json +9 -0
  11. package/skills/manifests/sunweb3sec.json +11 -0
  12. package/skills/manifests/trailofbits.json +9 -0
  13. package/skills/methodology/audit-workflow/SKILL.md +3 -0
  14. package/skills/patterns/access-control.yaml +31 -0
  15. package/skills/patterns/erc4626.yaml +29 -0
  16. package/skills/patterns/flash-loan.yaml +20 -0
  17. package/skills/patterns/oracle.yaml +30 -0
  18. package/skills/patterns/proxy.yaml +30 -0
  19. package/skills/patterns/reentrancy.yaml +30 -0
  20. package/skills/patterns/signature.yaml +31 -0
  21. package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
  22. package/skills/references/exploit-reference/SKILL.md +3 -0
  23. package/skills/vulnerability-patterns/access-control/SKILL.md +13 -0
  24. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +6 -0
  25. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +6 -0
  26. package/skills/vulnerability-patterns/dos-revert/SKILL.md +13 -1
  27. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +12 -0
  28. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +13 -0
  29. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +10 -1
  30. package/skills/vulnerability-patterns/reentrancy/SKILL.md +13 -0
  31. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +9 -0
  32. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +11 -0
  33. package/src/agents/argus-prompt.ts +7 -7
  34. package/src/agents/pythia-prompt.ts +11 -11
  35. package/src/agents/scribe-prompt.ts +6 -6
  36. package/src/agents/sentinel-prompt.ts +7 -7
  37. package/src/cli/cli-output.ts +16 -0
  38. package/src/cli/cli-program.ts +9 -5
  39. package/src/cli/commands/doctor.ts +274 -16
  40. package/src/cli/commands/init.ts +5 -5
  41. package/src/cli/commands/install.ts +5 -5
  42. package/src/cli/commands/lint-skills.ts +114 -0
  43. package/src/cli/tui-prompts.ts +4 -2
  44. package/src/config/schema.ts +2 -0
  45. package/src/create-hooks.ts +141 -32
  46. package/src/create-tools.ts +2 -0
  47. package/src/features/error-recovery/session-recovery.ts +7 -1
  48. package/src/features/error-recovery/tool-error-recovery.ts +74 -19
  49. package/src/features/persistent-state/audit-state-manager.ts +36 -13
  50. package/src/hooks/agent-tracker.ts +53 -0
  51. package/src/hooks/compaction-hook.ts +46 -37
  52. package/src/hooks/config-handler.ts +22 -9
  53. package/src/hooks/context-budget.ts +45 -0
  54. package/src/hooks/event-hook-v2.ts +8 -2
  55. package/src/hooks/event-hook.ts +5 -4
  56. package/src/hooks/knowledge-sync-hook.ts +2 -1
  57. package/src/hooks/recon-context-builder.ts +66 -0
  58. package/src/hooks/safe-create-hook.ts +4 -5
  59. package/src/hooks/system-prompt-hook.ts +92 -221
  60. package/src/hooks/tool-tracking-hook.ts +108 -9
  61. package/src/hooks/types.ts +0 -1
  62. package/src/index.ts +28 -6
  63. package/src/knowledge/retry.ts +53 -0
  64. package/src/knowledge/scvd-client.ts +37 -10
  65. package/src/knowledge/scvd-errors.ts +89 -0
  66. package/src/knowledge/scvd-index.ts +53 -3
  67. package/src/knowledge/scvd-sync.ts +205 -34
  68. package/src/knowledge/source-manifest.ts +102 -0
  69. package/src/plugin-interface.ts +11 -3
  70. package/src/shared/binary-utils.ts +1 -0
  71. package/src/shared/logger.ts +78 -17
  72. package/src/skills/argus-skill-resolver.ts +226 -0
  73. package/src/skills/skill-schema.ts +98 -0
  74. package/src/state/audit-state.ts +2 -0
  75. package/src/state/types.ts +32 -1
  76. package/src/tools/argus-skill-load-tool.ts +73 -0
  77. package/src/tools/pattern-checker-tool.ts +56 -12
  78. package/src/tools/pattern-loader.ts +183 -0
  79. package/src/tools/pattern-schema.ts +51 -0
  80. package/src/tools/report-generator-tool.ts +134 -11
  81. package/src/tools/slither-tool.ts +61 -19
  82. package/src/tools/solodit-search-tool.ts +92 -14
  83. package/src/utils/audit-artifact-detector.ts +119 -0
  84. package/src/utils/dependency-scanner.ts +93 -0
  85. package/src/utils/project-detector.ts +128 -26
  86. package/src/utils/solidity-parser.ts +20 -4
  87. package/src/utils/solodit-health.ts +29 -0
@@ -1,5 +1,6 @@
1
- import type { AuditState, FindingSeverity } from "../state/types"
1
+ import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
2
2
  import type { FindingStore } from "../state/finding-store"
3
+ import { createFindingStore } from "../state/finding-store"
3
4
 
4
5
  type ToolHookInput = {
5
6
  tool: string
@@ -165,16 +166,91 @@ function processContractAnalyzerResult(
165
166
  }
166
167
  }
167
168
 
169
+ function processFuzzResult(
170
+ parsed: Record<string, unknown>,
171
+ state: AuditState
172
+ ): void {
173
+ const counterexamples = parsed.counterexamples
174
+ if (!Array.isArray(counterexamples) || counterexamples.length === 0) return
175
+
176
+ const totalRuns =
177
+ typeof parsed.totalRuns === "number" ? parsed.totalRuns : 0
178
+
179
+ state.fuzzCounterexamples ??= []
180
+
181
+ for (const raw of counterexamples) {
182
+ const ce = toRecord(raw)
183
+ if (!ce) continue
184
+
185
+ const testName = ce.testName
186
+ if (typeof testName !== "string") continue
187
+
188
+ const rawInputs = toRecord(ce.inputs)
189
+ const inputs = rawInputs ? Object.values(rawInputs).map(String) : []
190
+
191
+ const entry: FuzzCounterexample = {
192
+ testName,
193
+ inputs,
194
+ runs: totalRuns,
195
+ timestamp: Date.now(),
196
+ }
197
+
198
+ if (typeof ce.revertReason === "string") {
199
+ entry.revertReason = ce.revertReason
200
+ }
201
+
202
+ state.fuzzCounterexamples.push(entry)
203
+ }
204
+ }
205
+
206
+ function processSoloditResult(
207
+ parsed: Record<string, unknown>,
208
+ state: AuditState
209
+ ): void {
210
+ const query = typeof parsed.query === "string" ? parsed.query : ""
211
+ const results = Array.isArray(parsed.results) ? parsed.results : []
212
+ const totalFound =
213
+ typeof parsed.totalFound === "number" ? parsed.totalFound : results.length
214
+
215
+ const topResults: SoloditResult["topResults"] = results
216
+ .slice(0, 5)
217
+ .map((raw) => {
218
+ const r = toRecord(raw)
219
+ return {
220
+ title: typeof r?.title === "string" ? r.title : "",
221
+ severity: typeof r?.severity === "string" ? r.severity : "",
222
+ url: typeof r?.url === "string" ? r.url : "",
223
+ protocol: typeof r?.protocol === "string" ? r.protocol : "",
224
+ }
225
+ })
226
+
227
+ state.soloditResults ??= []
228
+ state.soloditResults.push({
229
+ query,
230
+ timestamp: Date.now(),
231
+ resultCount: totalFound,
232
+ topResults,
233
+ })
234
+ }
235
+
236
+ /**
237
+ * Records a tool execution in the audit state.
238
+ *
239
+ * Multiple entries per tool name are allowed — if the same tool runs multiple times
240
+ * (e.g., argus_slither_analyze on different targets), each execution is recorded
241
+ * with its own findingsCount.
242
+ *
243
+ * Timing limitation: startTime and endTime are both set to Date.now() because this
244
+ * hook fires in the tool.execute.after phase, after execution has already completed.
245
+ * We cannot capture the actual start time. This is a known limitation of the hook
246
+ * architecture. For accurate timing, the hook would need to fire in tool.execute.before
247
+ * and tool.execute.after phases separately.
248
+ */
168
249
  function recordToolExecution(
169
250
  state: AuditState,
170
251
  toolName: string,
171
252
  findingsCount: number
172
253
  ): void {
173
- const alreadyRecorded = state.toolsExecuted.some(
174
- (execution) => execution.tool === toolName
175
- )
176
- if (alreadyRecorded) return
177
-
178
254
  const now = Date.now()
179
255
  state.toolsExecuted.push({
180
256
  tool: toolName,
@@ -193,14 +269,33 @@ function recordToolExecution(
193
269
  * Findings are deduplicated via the FindingStore (by check+file+lines).
194
270
  */
195
271
  export function createToolTrackingHook(
196
- auditState: AuditState,
197
- store: FindingStore
272
+ getAuditState: () => AuditState | null
198
273
  ): (input: ToolHookInput) => Promise<void> {
274
+ const storesByState = new WeakMap<AuditState, FindingStore>()
275
+
276
+ function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
277
+ const state = getAuditState()
278
+ if (!state) return null
279
+
280
+ let store = storesByState.get(state)
281
+ if (!store) {
282
+ store = createFindingStore(state)
283
+ storesByState.set(state, store)
284
+ }
285
+
286
+ return { state, store }
287
+ }
288
+
199
289
  return async (input: ToolHookInput): Promise<void> => {
200
290
  if (!input.tool.startsWith("argus_")) {
201
291
  return
202
292
  }
203
293
 
294
+ const resolved = resolveStateAndStore()
295
+ if (!resolved) return
296
+
297
+ const { state: auditState, store } = resolved
298
+
204
299
  let parsed: unknown
205
300
  try {
206
301
  parsed = JSON.parse(input.result)
@@ -223,9 +318,13 @@ export function createToolTrackingHook(
223
318
  case "argus_analyze_contract":
224
319
  processContractAnalyzerResult(record, auditState)
225
320
  break
321
+ case "argus_solodit_search":
322
+ processSoloditResult(record, auditState)
323
+ break
226
324
  case "argus_forge_test":
325
+ break
227
326
  case "argus_forge_fuzz":
228
- // No findings to extract — counterexamples are informational
327
+ processFuzzResult(record, auditState)
229
328
  break
230
329
  }
231
330
 
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  export type HookName =
7
- | "system-prompt"
8
7
  | "compaction"
9
8
  | "tool-tracking"
10
9
  | "event"
package/src/index.ts CHANGED
@@ -1,20 +1,40 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
- import { spawn } from "node:child_process"
3
2
  import { loadArgusConfig } from "./config/loader"
4
3
  import { createHookGuard } from "./hooks/hook-system"
5
4
  import { createTools } from "./create-tools"
6
5
  import { createHooks } from "./create-hooks"
7
6
  import { createManagers } from "./create-managers"
8
7
  import { createPluginInterface } from "./plugin-interface"
8
+ import { checkSoloditHealth } from "./utils/solodit-health"
9
+ import { createLogger } from "./shared/logger"
9
10
 
10
- function startSoloditMcp(port: number): void {
11
- const child = spawn("npx", ["-y", "@lyuboslavlyubenov/solodit-mcp"], {
12
- stdio: "ignore",
13
- detached: false,
11
+ async function startSoloditMcp(port: number): Promise<void> {
12
+ const logger = createLogger()
13
+
14
+ // Health check before spawn: if already reachable, skip spawn
15
+ const health = await checkSoloditHealth(port, true)
16
+ if (health.reachable) {
17
+ logger.debug(`Solodit MCP already running on port ${port} — skipping spawn`)
18
+ return
19
+ }
20
+
21
+ const child = Bun.spawn(["npx", "-y", "@lyuboslavlyubenov/solodit-mcp"], {
22
+ stdin: "ignore",
23
+ stdout: "ignore",
24
+ stderr: "ignore",
14
25
  env: { ...process.env, PORT: String(port) },
15
26
  })
16
27
  child.unref()
17
- child.on("error", () => {})
28
+
29
+ // Health check after spawn: wait 2s, then ping
30
+ setTimeout(async () => {
31
+ const health = await checkSoloditHealth(port, true)
32
+ if (!health.reachable) {
33
+ logger.debug(`Solodit MCP not yet reachable on port ${port} — will retry on first use`)
34
+ } else {
35
+ logger.debug(`Solodit MCP healthy on port ${port}`)
36
+ }
37
+ }, 2000)
18
38
  }
19
39
 
20
40
  const ArgusPlugin: Plugin = async (ctx) => {
@@ -22,6 +42,8 @@ const ArgusPlugin: Plugin = async (ctx) => {
22
42
  const config = loadArgusConfig(projectDir)
23
43
 
24
44
  if (config.solodit?.enabled !== false) {
45
+ // Fire-and-forget: startSoloditMcp is now async but we don't await
46
+ // to avoid blocking plugin initialization
25
47
  startSoloditMcp(config.solodit?.port ?? 3000)
26
48
  }
27
49
 
@@ -0,0 +1,53 @@
1
+ export interface RetryOptions<T> {
2
+ maxAttempts: number;
3
+ baseDelayMs: number;
4
+ shouldRetry: (error: unknown) => boolean;
5
+ onRetry?: (attempt: number, error: unknown) => void;
6
+ _valueType?: T;
7
+ }
8
+
9
+ export interface RetryResult<T> {
10
+ success: boolean;
11
+ value?: T;
12
+ error?: unknown;
13
+ attempts: number;
14
+ }
15
+
16
+ function sleep(delayMs: number): Promise<void> {
17
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
18
+ }
19
+
20
+ export async function withRetry<T>(
21
+ fn: () => Promise<T>,
22
+ options: RetryOptions<T>
23
+ ): Promise<RetryResult<T>> {
24
+ const maxAttempts = options.maxAttempts > 0 ? options.maxAttempts : 1;
25
+ let lastError: unknown;
26
+
27
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
28
+ try {
29
+ const value = await fn();
30
+ return { success: true, value, attempts: attempt };
31
+ } catch (error) {
32
+ lastError = error;
33
+ const canRetry = attempt < maxAttempts && options.shouldRetry(error);
34
+
35
+ if (!canRetry) {
36
+ return { success: false, error, attempts: attempt };
37
+ }
38
+
39
+ if (options.onRetry) {
40
+ options.onRetry(attempt, error);
41
+ }
42
+
43
+ const delay = options.baseDelayMs * 2 ** (attempt - 1);
44
+ await sleep(delay);
45
+ }
46
+ }
47
+
48
+ return {
49
+ success: false,
50
+ error: lastError,
51
+ attempts: maxAttempts,
52
+ };
53
+ }
@@ -150,6 +150,24 @@ function parseStats(raw: unknown): ScvdStats {
150
150
  };
151
151
  }
152
152
 
153
+ export class ScvdNetworkError extends Error {
154
+ override readonly name = "ScvdNetworkError" as const;
155
+
156
+ constructor(message: string) {
157
+ super(message);
158
+ }
159
+ }
160
+
161
+ export class ScvdApiError extends Error {
162
+ override readonly name = "ScvdApiError" as const;
163
+ readonly httpStatus: number;
164
+
165
+ constructor(httpStatus: number, message?: string) {
166
+ super(message ?? `SCVD API error: HTTP ${httpStatus}`);
167
+ this.httpStatus = httpStatus;
168
+ }
169
+ }
170
+
153
171
  export class ScvdClient {
154
172
  private readonly baseUrl: string;
155
173
  private readonly signal?: AbortSignal;
@@ -167,11 +185,14 @@ export class ScvdClient {
167
185
  response = await fetch(url, { signal: this.signal });
168
186
  } catch (error) {
169
187
  const message = error instanceof Error ? error.message : "unknown network error";
170
- throw new Error(`Failed to fetch SCVD stats from ${url}: ${message}`);
188
+ throw new ScvdNetworkError(`Failed to fetch SCVD stats from ${url}: ${message}`);
171
189
  }
172
190
 
173
191
  if (!response.ok) {
174
- throw new Error(`Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`);
192
+ throw new ScvdApiError(
193
+ response.status,
194
+ `Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`
195
+ );
175
196
  }
176
197
 
177
198
  const body = (await response.json()) as unknown;
@@ -198,17 +219,23 @@ export class ScvdClient {
198
219
  const query = searchParams.toString();
199
220
  const url = `${this.baseUrl}/findings${query.length > 0 ? `?${query}` : ""}`;
200
221
 
222
+ let response: Response;
201
223
  try {
202
- const response = await fetch(url, { signal: this.signal });
203
- if (!response.ok) {
204
- return [];
205
- }
224
+ response = await fetch(url, { signal: this.signal });
225
+ } catch (error) {
226
+ const message = error instanceof Error ? error.message : "unknown network error";
227
+ throw new ScvdNetworkError(`Failed to fetch SCVD findings from ${url}: ${message}`);
228
+ }
206
229
 
207
- const body = (await response.json()) as unknown;
208
- return parseFindings(body);
209
- } catch {
210
- return []; // network error treat as empty page
230
+ if (!response.ok) {
231
+ throw new ScvdApiError(
232
+ response.status,
233
+ `SCVD API error: HTTP ${response.status} for ${url}`
234
+ );
211
235
  }
236
+
237
+ const body = (await response.json()) as unknown;
238
+ return parseFindings(body);
212
239
  }
213
240
 
214
241
  async fetchAllFindings(onProgress?: (count: number) => void): Promise<ScvdFinding[]> {
@@ -0,0 +1,89 @@
1
+ export type SyncError = {
2
+ status: "error";
3
+ success: false;
4
+ reason: "network" | "api" | "parse";
5
+ message: string;
6
+ error: string;
7
+ httpStatus?: number;
8
+ newFindings: 0;
9
+ totalIndexed: 0;
10
+ lastSync: string;
11
+ attempts?: number;
12
+ };
13
+
14
+ export type SyncSuccess = {
15
+ status: "success";
16
+ success: true;
17
+ newFindings: number;
18
+ totalIndexed: number;
19
+ lastSync: string;
20
+ error?: undefined;
21
+ attempts?: number;
22
+ };
23
+
24
+ export type SyncStale = {
25
+ status: "stale";
26
+ success: false;
27
+ newFindings: 0;
28
+ totalIndexed: 0;
29
+ lastSync: string;
30
+ error?: undefined;
31
+ daysSinceSync: number;
32
+ attempts?: number;
33
+ };
34
+
35
+ export type SyncOutcome = SyncSuccess | SyncError | SyncStale;
36
+
37
+ export function createNetworkError(message: string): SyncError {
38
+ return {
39
+ status: "error",
40
+ success: false,
41
+ reason: "network",
42
+ message,
43
+ error: message,
44
+ newFindings: 0,
45
+ totalIndexed: 0,
46
+ lastSync: new Date().toISOString(),
47
+ };
48
+ }
49
+
50
+ export function createApiError(httpStatus: number, message: string): SyncError {
51
+ return {
52
+ status: "error",
53
+ success: false,
54
+ reason: "api",
55
+ message,
56
+ error: message,
57
+ httpStatus,
58
+ newFindings: 0,
59
+ totalIndexed: 0,
60
+ lastSync: new Date().toISOString(),
61
+ };
62
+ }
63
+
64
+ export function createParseError(message: string): SyncError {
65
+ return {
66
+ status: "error",
67
+ success: false,
68
+ reason: "parse",
69
+ message,
70
+ error: message,
71
+ newFindings: 0,
72
+ totalIndexed: 0,
73
+ lastSync: new Date().toISOString(),
74
+ };
75
+ }
76
+
77
+ export function createSyncSuccess(
78
+ data: Omit<SyncSuccess, "status" | "success" | "error"> & { attempts?: number }
79
+ ): SyncSuccess {
80
+ return {
81
+ status: "success",
82
+ success: true,
83
+ ...data,
84
+ };
85
+ }
86
+
87
+ export function isRetryableError(outcome: SyncOutcome): boolean {
88
+ return outcome.status === "error" && outcome.reason === "network";
89
+ }
@@ -10,15 +10,42 @@ export interface ScvdIndexEntry {
10
10
  repoUrl: string;
11
11
  }
12
12
 
13
+ export interface ScvdIndexMetadata {
14
+ lastSuccess: string | null;
15
+ lastAttempt: string | null;
16
+ errorCount: number;
17
+ lastError: string | null;
18
+ lastErrorReason: string | null;
19
+ }
20
+
13
21
  export interface ScvdIndex {
14
22
  version: number;
15
23
  lastSync: string;
16
24
  totalFindings: number;
17
25
  entries: ScvdIndexEntry[];
26
+ metadata?: ScvdIndexMetadata;
18
27
  }
19
28
 
20
29
  const INDEX_VERSION = 1;
21
30
  const DEFAULT_LIMIT = 10;
31
+ let syncInProgress = false;
32
+
33
+ export function acquireSyncLock(): boolean {
34
+ if (syncInProgress) {
35
+ return false;
36
+ }
37
+
38
+ syncInProgress = true;
39
+ return true;
40
+ }
41
+
42
+ export function releaseSyncLock(): void {
43
+ syncInProgress = false;
44
+ }
45
+
46
+ export function isSyncLocked(): boolean {
47
+ return syncInProgress;
48
+ }
22
49
 
23
50
  function normalizeKeywordInput(value: string): string[] {
24
51
  return value
@@ -96,8 +123,10 @@ export function searchIndex(
96
123
  }
97
124
 
98
125
  export async function saveIndex(index: ScvdIndex, filePath: string): Promise<void> {
99
- const json = JSON.stringify(index, null, 2);
100
- await Bun.write(filePath, json);
126
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
127
+ await Bun.write(tmpPath, JSON.stringify(index, null, 2));
128
+ const { renameSync } = await import("node:fs");
129
+ renameSync(tmpPath, filePath);
101
130
  }
102
131
 
103
132
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -142,6 +171,20 @@ function parseEntry(value: unknown): ScvdIndexEntry | null {
142
171
  };
143
172
  }
144
173
 
174
+ function parseNullableString(value: unknown): string | null {
175
+ return typeof value === "string" ? value : null;
176
+ }
177
+
178
+ function parseMetadata(raw: Record<string, unknown>): ScvdIndexMetadata {
179
+ return {
180
+ lastSuccess: parseNullableString(raw.lastSuccess),
181
+ lastAttempt: parseNullableString(raw.lastAttempt),
182
+ errorCount: typeof raw.errorCount === "number" ? raw.errorCount : 0,
183
+ lastError: parseNullableString(raw.lastError),
184
+ lastErrorReason: parseNullableString(raw.lastErrorReason),
185
+ };
186
+ }
187
+
145
188
  export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
146
189
  const file = Bun.file(filePath);
147
190
  const exists = await file.exists();
@@ -174,10 +217,17 @@ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
174
217
  .map(parseEntry)
175
218
  .filter((entry): entry is ScvdIndexEntry => entry !== null);
176
219
 
177
- return {
220
+ const index: ScvdIndex = {
178
221
  version,
179
222
  lastSync,
180
223
  totalFindings,
181
224
  entries,
182
225
  };
226
+
227
+ const rawMetadata = raw.metadata;
228
+ if (isRecord(rawMetadata)) {
229
+ index.metadata = parseMetadata(rawMetadata);
230
+ }
231
+
232
+ return index;
183
233
  }