solidity-argus 0.1.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 (131) hide show
  1. package/AGENTS.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/package.json +43 -0
  5. package/skills/INVENTORY.md +79 -0
  6. package/skills/README.md +56 -0
  7. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +424 -0
  8. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +157 -0
  9. package/skills/checklists/cyfrin-defi-core/SKILL.md +373 -0
  10. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +412 -0
  11. package/skills/checklists/cyfrin-gas/SKILL.md +55 -0
  12. package/skills/checklists/general-audit/SKILL.md +433 -0
  13. package/skills/methodology/audit-workflow/SKILL.md +129 -0
  14. package/skills/methodology/report-template/SKILL.md +190 -0
  15. package/skills/methodology/severity-classification/SKILL.md +179 -0
  16. package/skills/protocol-patterns/amm-dex/SKILL.md +229 -0
  17. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +317 -0
  18. package/skills/protocol-patterns/dao-governance/SKILL.md +281 -0
  19. package/skills/protocol-patterns/lending-borrowing/SKILL.md +221 -0
  20. package/skills/protocol-patterns/staking-vesting/SKILL.md +247 -0
  21. package/skills/references/exploit-reference/SKILL.md +259 -0
  22. package/skills/references/smartbugs-examples/SKILL.md +296 -0
  23. package/skills/vulnerability-patterns/access-control/SKILL.md +298 -0
  24. package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +59 -0
  25. package/skills/vulnerability-patterns/assert-violation/SKILL.md +59 -0
  26. package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +61 -0
  27. package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +55 -0
  28. package/skills/vulnerability-patterns/default-visibility/SKILL.md +62 -0
  29. package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +60 -0
  30. package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +59 -0
  31. package/skills/vulnerability-patterns/dos-revert/SKILL.md +72 -0
  32. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +249 -0
  33. package/skills/vulnerability-patterns/floating-pragma/SKILL.md +51 -0
  34. package/skills/vulnerability-patterns/hash-collision/SKILL.md +52 -0
  35. package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +61 -0
  36. package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +60 -0
  37. package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +59 -0
  38. package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +61 -0
  39. package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +61 -0
  40. package/skills/vulnerability-patterns/logic-errors/SKILL.md +333 -0
  41. package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +60 -0
  42. package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +66 -0
  43. package/skills/vulnerability-patterns/off-by-one/SKILL.md +67 -0
  44. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +252 -0
  45. package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +65 -0
  46. package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +61 -0
  47. package/skills/vulnerability-patterns/reentrancy/SKILL.md +266 -0
  48. package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +72 -0
  49. package/skills/vulnerability-patterns/signature-malleability/SKILL.md +59 -0
  50. package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +63 -0
  51. package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +52 -0
  52. package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +65 -0
  53. package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +61 -0
  54. package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +63 -0
  55. package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +56 -0
  56. package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +80 -0
  57. package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +69 -0
  58. package/skills/vulnerability-patterns/unused-variables/SKILL.md +70 -0
  59. package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +81 -0
  60. package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +77 -0
  61. package/skills/vulnerability-patterns/weird-tokens/SKILL.md +294 -0
  62. package/src/agents/argus-prompt.ts +407 -0
  63. package/src/agents/pythia-prompt.ts +134 -0
  64. package/src/agents/scribe-prompt.ts +87 -0
  65. package/src/agents/sentinel-prompt.ts +133 -0
  66. package/src/cli/cli-program.ts +67 -0
  67. package/src/cli/commands/doctor.ts +83 -0
  68. package/src/cli/commands/init.ts +46 -0
  69. package/src/cli/commands/install.ts +55 -0
  70. package/src/cli/index.ts +13 -0
  71. package/src/cli/tui-prompts.ts +75 -0
  72. package/src/cli/types.ts +9 -0
  73. package/src/config/index.ts +3 -0
  74. package/src/config/loader.ts +36 -0
  75. package/src/config/schema.ts +82 -0
  76. package/src/config/types.ts +4 -0
  77. package/src/constants/defaults.ts +6 -0
  78. package/src/create-hooks.ts +84 -0
  79. package/src/create-managers.ts +26 -0
  80. package/src/create-tools.ts +30 -0
  81. package/src/features/audit-enforcer/audit-enforcer.ts +34 -0
  82. package/src/features/audit-enforcer/index.ts +1 -0
  83. package/src/features/background-agent/background-manager.ts +200 -0
  84. package/src/features/background-agent/index.ts +1 -0
  85. package/src/features/context-monitor/context-monitor.ts +48 -0
  86. package/src/features/context-monitor/index.ts +4 -0
  87. package/src/features/context-monitor/tool-output-truncator.ts +17 -0
  88. package/src/features/error-recovery/index.ts +2 -0
  89. package/src/features/error-recovery/session-recovery.ts +27 -0
  90. package/src/features/error-recovery/tool-error-recovery.ts +35 -0
  91. package/src/features/index.ts +5 -0
  92. package/src/features/persistent-state/audit-state-manager.ts +121 -0
  93. package/src/features/persistent-state/index.ts +1 -0
  94. package/src/hooks/compaction-hook.ts +50 -0
  95. package/src/hooks/config-handler.ts +116 -0
  96. package/src/hooks/event-hook-v2.ts +93 -0
  97. package/src/hooks/event-hook.ts +74 -0
  98. package/src/hooks/hook-system.ts +9 -0
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/knowledge-sync-hook.ts +57 -0
  101. package/src/hooks/safe-create-hook.ts +15 -0
  102. package/src/hooks/system-prompt-hook.ts +126 -0
  103. package/src/hooks/tool-tracking-hook.ts +234 -0
  104. package/src/hooks/types.ts +16 -0
  105. package/src/index.ts +36 -0
  106. package/src/knowledge/scvd-client.ts +242 -0
  107. package/src/knowledge/scvd-index.ts +183 -0
  108. package/src/knowledge/scvd-sync.ts +85 -0
  109. package/src/managers/index.ts +1 -0
  110. package/src/managers/types.ts +85 -0
  111. package/src/plugin-interface.ts +38 -0
  112. package/src/shared/binary-utils.ts +63 -0
  113. package/src/shared/deep-merge.ts +71 -0
  114. package/src/shared/file-utils.ts +56 -0
  115. package/src/shared/index.ts +5 -0
  116. package/src/shared/jsonc-parser.ts +39 -0
  117. package/src/shared/logger.ts +36 -0
  118. package/src/state/audit-state.ts +27 -0
  119. package/src/state/finding-store.ts +126 -0
  120. package/src/state/plugin-state.ts +14 -0
  121. package/src/state/types.ts +61 -0
  122. package/src/tools/contract-analyzer-tool.ts +184 -0
  123. package/src/tools/forge-fuzz-tool.ts +311 -0
  124. package/src/tools/forge-test-tool.ts +397 -0
  125. package/src/tools/pattern-checker-tool.ts +337 -0
  126. package/src/tools/report-generator-tool.ts +308 -0
  127. package/src/tools/slither-tool.ts +465 -0
  128. package/src/tools/solodit-search-tool.ts +131 -0
  129. package/src/tools/sync-knowledge-tool.ts +116 -0
  130. package/src/utils/project-detector.ts +133 -0
  131. package/src/utils/solidity-parser.ts +174 -0
@@ -0,0 +1,234 @@
1
+ import type { AuditState, FindingSeverity } from "../state/types"
2
+ import type { FindingStore } from "../state/finding-store"
3
+
4
+ type ToolHookInput = {
5
+ tool: string
6
+ args: unknown
7
+ result: string
8
+ }
9
+
10
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set([
11
+ "Critical",
12
+ "High",
13
+ "Medium",
14
+ "Low",
15
+ "Informational",
16
+ ])
17
+
18
+ const VALID_CONFIDENCES: ReadonlySet<string> = new Set([
19
+ "High",
20
+ "Medium",
21
+ "Low",
22
+ ])
23
+
24
+ function toSeverity(value: unknown): FindingSeverity {
25
+ if (typeof value === "string" && VALID_SEVERITIES.has(value)) {
26
+ return value as FindingSeverity
27
+ }
28
+ return "Informational"
29
+ }
30
+
31
+ function toConfidence(value: unknown): "High" | "Medium" | "Low" {
32
+ if (typeof value === "string" && VALID_CONFIDENCES.has(value)) {
33
+ return value as "High" | "Medium" | "Low"
34
+ }
35
+ return "Low"
36
+ }
37
+
38
+ function toLines(value: unknown): [number, number] | undefined {
39
+ if (
40
+ Array.isArray(value) &&
41
+ value.length >= 2 &&
42
+ typeof value[0] === "number" &&
43
+ typeof value[1] === "number"
44
+ ) {
45
+ return [value[0], value[1]]
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
51
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
52
+ return value as Record<string, unknown>
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ function processSlitherResult(
58
+ parsed: Record<string, unknown>,
59
+ store: FindingStore
60
+ ): number {
61
+ const findings = parsed.findings
62
+ if (!Array.isArray(findings)) return 0
63
+
64
+ let count = 0
65
+ for (const raw of findings) {
66
+ const finding = toRecord(raw)
67
+ if (!finding) continue
68
+
69
+ const check = finding.check
70
+ const description = finding.description
71
+ const file = finding.file
72
+ const lines = toLines(finding.lines)
73
+
74
+ if (
75
+ typeof check !== "string" ||
76
+ typeof description !== "string" ||
77
+ typeof file !== "string" ||
78
+ !lines
79
+ ) {
80
+ continue
81
+ }
82
+
83
+ store.addFinding({
84
+ check,
85
+ severity: toSeverity(finding.severity),
86
+ confidence: toConfidence(finding.confidence),
87
+ description,
88
+ file,
89
+ lines,
90
+ source: "slither",
91
+ })
92
+ count++
93
+ }
94
+
95
+ return count
96
+ }
97
+
98
+ function processPatternResult(
99
+ parsed: Record<string, unknown>,
100
+ store: FindingStore
101
+ ): number {
102
+ const sources = parsed.sources
103
+ if (!Array.isArray(sources)) return 0
104
+
105
+ let count = 0
106
+ for (const rawSource of sources) {
107
+ const source = toRecord(rawSource)
108
+ if (!source) continue
109
+
110
+ const matches = source.matches
111
+ if (!Array.isArray(matches)) continue
112
+
113
+ for (const rawMatch of matches) {
114
+ const match = toRecord(rawMatch)
115
+ if (!match) continue
116
+
117
+ const pattern = match.pattern
118
+ const description = match.description
119
+ const file = match.file
120
+ const lines = toLines(match.lines)
121
+
122
+ if (
123
+ typeof pattern !== "string" ||
124
+ typeof description !== "string" ||
125
+ typeof file !== "string" ||
126
+ !lines
127
+ ) {
128
+ continue
129
+ }
130
+
131
+ store.addFinding({
132
+ check: pattern,
133
+ severity: toSeverity(match.severity),
134
+ confidence: "Medium",
135
+ description,
136
+ file,
137
+ lines,
138
+ source: "pattern",
139
+ })
140
+ count++
141
+ }
142
+ }
143
+
144
+ return count
145
+ }
146
+
147
+ function processContractAnalyzerResult(
148
+ parsed: Record<string, unknown>,
149
+ state: AuditState
150
+ ): void {
151
+ // Handle direct ContractProfile format (actual tool output)
152
+ if (typeof parsed.filePath === "string") {
153
+ if (!state.contractsReviewed.includes(parsed.filePath)) {
154
+ state.contractsReviewed.push(parsed.filePath)
155
+ }
156
+ return
157
+ }
158
+
159
+ // Handle wrapped { contractProfile: { filePath } } format
160
+ const profile = toRecord(parsed.contractProfile)
161
+ if (profile && typeof profile.filePath === "string") {
162
+ if (!state.contractsReviewed.includes(profile.filePath)) {
163
+ state.contractsReviewed.push(profile.filePath)
164
+ }
165
+ }
166
+ }
167
+
168
+ function recordToolExecution(
169
+ state: AuditState,
170
+ toolName: string,
171
+ findingsCount: number
172
+ ): void {
173
+ const alreadyRecorded = state.toolsExecuted.some(
174
+ (execution) => execution.tool === toolName
175
+ )
176
+ if (alreadyRecorded) return
177
+
178
+ const now = Date.now()
179
+ state.toolsExecuted.push({
180
+ tool: toolName,
181
+ startTime: now,
182
+ endTime: now,
183
+ success: true,
184
+ findingsCount,
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Creates a tool tracking hook that intercepts argus_* tool results
190
+ * and updates audit state with extracted findings.
191
+ *
192
+ * Non-argus tools are ignored. Malformed JSON results are silently skipped.
193
+ * Findings are deduplicated via the FindingStore (by check+file+lines).
194
+ */
195
+ export function createToolTrackingHook(
196
+ auditState: AuditState,
197
+ store: FindingStore
198
+ ): (input: ToolHookInput) => Promise<void> {
199
+ return async (input: ToolHookInput): Promise<void> => {
200
+ if (!input.tool.startsWith("argus_")) {
201
+ return
202
+ }
203
+
204
+ let parsed: unknown
205
+ try {
206
+ parsed = JSON.parse(input.result)
207
+ } catch {
208
+ return // non-JSON tool output — nothing to track
209
+ }
210
+
211
+ const record = toRecord(parsed)
212
+ if (!record) return
213
+
214
+ let findingsCount = 0
215
+
216
+ switch (input.tool) {
217
+ case "argus_slither_analyze":
218
+ findingsCount = processSlitherResult(record, store)
219
+ break
220
+ case "argus_check_patterns":
221
+ findingsCount = processPatternResult(record, store)
222
+ break
223
+ case "argus_analyze_contract":
224
+ processContractAnalyzerResult(record, auditState)
225
+ break
226
+ case "argus_forge_test":
227
+ case "argus_forge_fuzz":
228
+ // No findings to extract — counterexamples are informational
229
+ break
230
+ }
231
+
232
+ recordToolExecution(auditState, input.tool, findingsCount)
233
+ }
234
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Hook system types
3
+ * Defines all available hook names in the Argus plugin
4
+ */
5
+
6
+ export type HookName =
7
+ | "system-prompt"
8
+ | "compaction"
9
+ | "tool-tracking"
10
+ | "event"
11
+ | "knowledge-sync"
12
+ | "session-recovery"
13
+ | "tool-error-recovery"
14
+ | "context-window-monitor"
15
+ | "tool-output-truncator"
16
+ | "audit-continuation";
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { spawn } from "node:child_process"
3
+ import { loadArgusConfig } from "./config/loader"
4
+ import { createHookGuard } from "./hooks/hook-system"
5
+ import { createTools } from "./create-tools"
6
+ import { createHooks } from "./create-hooks"
7
+ import { createManagers } from "./create-managers"
8
+ import { createPluginInterface } from "./plugin-interface"
9
+
10
+ function startSoloditMcp(port: number): void {
11
+ const child = spawn("npx", ["-y", "@lyuboslavlyubenov/solodit-mcp"], {
12
+ stdio: "ignore",
13
+ detached: false,
14
+ env: { ...process.env, PORT: String(port) },
15
+ })
16
+ child.unref()
17
+ child.on("error", () => {})
18
+ }
19
+
20
+ const ArgusPlugin: Plugin = async (ctx) => {
21
+ const projectDir = ctx.directory ?? process.cwd()
22
+ const config = loadArgusConfig(projectDir)
23
+
24
+ if (config.solodit?.enabled !== false) {
25
+ startSoloditMcp(config.solodit?.port ?? 3000)
26
+ }
27
+
28
+ const isHookEnabled = createHookGuard(config.disabled_hooks)
29
+ const managers = createManagers({ projectDir, config })
30
+ const tools = createTools(config)
31
+ const hooks = createHooks({ config, managers, projectDir, isHookEnabled })
32
+
33
+ return createPluginInterface({ tools, hooks })
34
+ }
35
+
36
+ export default ArgusPlugin
@@ -0,0 +1,242 @@
1
+ export interface ScvdFinding {
2
+ scvd_id: string;
3
+ doc_id: string;
4
+ title: string;
5
+ description_md: string;
6
+ severity: "Critical" | "High" | "Medium" | "Low" | "Informational";
7
+ taxonomy: { swc: string[]; cwe: string[] };
8
+ repo: { url: string; commit?: string; lines?: [number, number] };
9
+ sections: { recommendation_md?: string; poc_md?: string };
10
+ }
11
+
12
+ export interface ScvdStats {
13
+ total: number;
14
+ by_severity: Record<string, number>;
15
+ last_updated: string;
16
+ }
17
+
18
+ const DEFAULT_PAGE_SIZE = 100;
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null;
22
+ }
23
+
24
+ function toStringArray(value: unknown): string[] {
25
+ if (!Array.isArray(value)) {
26
+ return [];
27
+ }
28
+
29
+ return value.filter((item): item is string => typeof item === "string");
30
+ }
31
+
32
+ function toNumberRecord(value: unknown): Record<string, number> {
33
+ if (!isRecord(value)) {
34
+ return {};
35
+ }
36
+
37
+ const output: Record<string, number> = {};
38
+ for (const [key, rawValue] of Object.entries(value)) {
39
+ if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
40
+ output[key] = rawValue;
41
+ }
42
+ }
43
+
44
+ return output;
45
+ }
46
+
47
+ function parseLines(value: unknown): [number, number] | undefined {
48
+ if (!Array.isArray(value) || value.length !== 2) {
49
+ return undefined;
50
+ }
51
+
52
+ const start = value[0];
53
+ const end = value[1];
54
+
55
+ if (typeof start !== "number" || typeof end !== "number") {
56
+ return undefined;
57
+ }
58
+
59
+ return [start, end];
60
+ }
61
+
62
+ function parseFinding(raw: unknown): ScvdFinding | null {
63
+ if (!isRecord(raw)) {
64
+ return null;
65
+ }
66
+
67
+ const taxonomyRaw = isRecord(raw.taxonomy) ? raw.taxonomy : {};
68
+ const repoRaw = isRecord(raw.repo) ? raw.repo : {};
69
+ const sectionsRaw = isRecord(raw.sections) ? raw.sections : {};
70
+
71
+ const scvdId = raw.scvd_id;
72
+ const docId = raw.doc_id;
73
+ const title = raw.title;
74
+ const description = raw.description_md;
75
+ const severity = raw.severity;
76
+ const repoUrl = repoRaw.url;
77
+
78
+ if (
79
+ typeof scvdId !== "string" ||
80
+ typeof docId !== "string" ||
81
+ typeof title !== "string" ||
82
+ typeof description !== "string" ||
83
+ typeof repoUrl !== "string"
84
+ ) {
85
+ return null;
86
+ }
87
+
88
+ if (
89
+ severity !== "Critical" &&
90
+ severity !== "High" &&
91
+ severity !== "Medium" &&
92
+ severity !== "Low" &&
93
+ severity !== "Informational"
94
+ ) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ scvd_id: scvdId,
100
+ doc_id: docId,
101
+ title,
102
+ description_md: description,
103
+ severity,
104
+ taxonomy: {
105
+ swc: toStringArray(taxonomyRaw.swc),
106
+ cwe: toStringArray(taxonomyRaw.cwe),
107
+ },
108
+ repo: {
109
+ url: repoUrl,
110
+ commit: typeof repoRaw.commit === "string" ? repoRaw.commit : undefined,
111
+ lines: parseLines(repoRaw.lines),
112
+ },
113
+ sections: {
114
+ recommendation_md:
115
+ typeof sectionsRaw.recommendation_md === "string"
116
+ ? sectionsRaw.recommendation_md
117
+ : undefined,
118
+ poc_md: typeof sectionsRaw.poc_md === "string" ? sectionsRaw.poc_md : undefined,
119
+ },
120
+ };
121
+ }
122
+
123
+ function parseFindings(raw: unknown): ScvdFinding[] {
124
+ if (!Array.isArray(raw)) {
125
+ if (isRecord(raw) && Array.isArray(raw.data)) {
126
+ return raw.data.map(parseFinding).filter((value): value is ScvdFinding => value !== null);
127
+ }
128
+ return [];
129
+ }
130
+
131
+ return raw.map(parseFinding).filter((value): value is ScvdFinding => value !== null);
132
+ }
133
+
134
+ function parseStats(raw: unknown): ScvdStats {
135
+ if (!isRecord(raw)) {
136
+ throw new Error("Invalid SCVD stats response payload");
137
+ }
138
+
139
+ const total = raw.total;
140
+ const lastUpdated = raw.last_updated;
141
+
142
+ if (typeof total !== "number" || typeof lastUpdated !== "string") {
143
+ throw new Error("Invalid SCVD stats fields in response");
144
+ }
145
+
146
+ return {
147
+ total,
148
+ by_severity: toNumberRecord(raw.by_severity),
149
+ last_updated: lastUpdated,
150
+ };
151
+ }
152
+
153
+ export class ScvdClient {
154
+ private readonly baseUrl: string;
155
+ private readonly signal?: AbortSignal;
156
+
157
+ constructor(apiUrl: string, signal?: AbortSignal) {
158
+ this.baseUrl = apiUrl.replace(/\/$/, "");
159
+ this.signal = signal;
160
+ }
161
+
162
+ async fetchStats(): Promise<ScvdStats> {
163
+ const url = `${this.baseUrl}/stats`;
164
+
165
+ let response: Response;
166
+ try {
167
+ response = await fetch(url, { signal: this.signal });
168
+ } catch (error) {
169
+ const message = error instanceof Error ? error.message : "unknown network error";
170
+ throw new Error(`Failed to fetch SCVD stats from ${url}: ${message}`);
171
+ }
172
+
173
+ if (!response.ok) {
174
+ throw new Error(`Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`);
175
+ }
176
+
177
+ const body = (await response.json()) as unknown;
178
+ return parseStats(body);
179
+ }
180
+
181
+ async fetchFindings(params: {
182
+ severity?: string;
183
+ limit?: number;
184
+ offset?: number;
185
+ }): Promise<ScvdFinding[]> {
186
+ const searchParams = new URLSearchParams();
187
+
188
+ if (params.severity) {
189
+ searchParams.set("severity", params.severity);
190
+ }
191
+ if (typeof params.limit === "number") {
192
+ searchParams.set("limit", String(params.limit));
193
+ }
194
+ if (typeof params.offset === "number") {
195
+ searchParams.set("offset", String(params.offset));
196
+ }
197
+
198
+ const query = searchParams.toString();
199
+ const url = `${this.baseUrl}/findings${query.length > 0 ? `?${query}` : ""}`;
200
+
201
+ try {
202
+ const response = await fetch(url, { signal: this.signal });
203
+ if (!response.ok) {
204
+ return [];
205
+ }
206
+
207
+ const body = (await response.json()) as unknown;
208
+ return parseFindings(body);
209
+ } catch {
210
+ return []; // network error — treat as empty page
211
+ }
212
+ }
213
+
214
+ async fetchAllFindings(onProgress?: (count: number) => void): Promise<ScvdFinding[]> {
215
+ const results: ScvdFinding[] = [];
216
+ let offset = 0;
217
+
218
+ while (true) {
219
+ const page = await this.fetchFindings({
220
+ limit: DEFAULT_PAGE_SIZE,
221
+ offset,
222
+ });
223
+
224
+ if (page.length === 0) {
225
+ break;
226
+ }
227
+
228
+ results.push(...page);
229
+ offset += page.length;
230
+
231
+ if (onProgress) {
232
+ onProgress(results.length);
233
+ }
234
+
235
+ if (page.length < DEFAULT_PAGE_SIZE) {
236
+ break;
237
+ }
238
+ }
239
+
240
+ return results;
241
+ }
242
+ }
@@ -0,0 +1,183 @@
1
+ import type { ScvdFinding } from "./scvd-client";
2
+
3
+ export interface ScvdIndexEntry {
4
+ id: string;
5
+ title: string;
6
+ severity: string;
7
+ swc: string[];
8
+ cwe: string[];
9
+ keywords: string[];
10
+ repoUrl: string;
11
+ }
12
+
13
+ export interface ScvdIndex {
14
+ version: number;
15
+ lastSync: string;
16
+ totalFindings: number;
17
+ entries: ScvdIndexEntry[];
18
+ }
19
+
20
+ const INDEX_VERSION = 1;
21
+ const DEFAULT_LIMIT = 10;
22
+
23
+ function normalizeKeywordInput(value: string): string[] {
24
+ return value
25
+ .toLowerCase()
26
+ .split(/[^a-z0-9]+/g)
27
+ .map((word) => word.trim())
28
+ .filter((word) => word.length > 1);
29
+ }
30
+
31
+ function uniqueWords(words: string[]): string[] {
32
+ return Array.from(new Set(words));
33
+ }
34
+
35
+ function findingToEntry(finding: ScvdFinding): ScvdIndexEntry {
36
+ const keywordSource = `${finding.title} ${finding.description_md}`;
37
+
38
+ return {
39
+ id: finding.scvd_id,
40
+ title: finding.title,
41
+ severity: finding.severity,
42
+ swc: finding.taxonomy.swc,
43
+ cwe: finding.taxonomy.cwe,
44
+ keywords: uniqueWords(normalizeKeywordInput(keywordSource)),
45
+ repoUrl: finding.repo.url,
46
+ };
47
+ }
48
+
49
+ export function buildIndex(findings: ScvdFinding[]): ScvdIndex {
50
+ const now = new Date().toISOString();
51
+ const entries = findings.map(findingToEntry);
52
+
53
+ return {
54
+ version: INDEX_VERSION,
55
+ lastSync: now,
56
+ totalFindings: entries.length,
57
+ entries,
58
+ };
59
+ }
60
+
61
+ export function searchIndex(
62
+ index: ScvdIndex,
63
+ query: {
64
+ swc?: string;
65
+ severity?: string;
66
+ keyword?: string;
67
+ limit?: number;
68
+ }
69
+ ): ScvdIndexEntry[] {
70
+ const normalizedKeyword = query.keyword?.toLowerCase().trim();
71
+ const limit = query.limit ?? DEFAULT_LIMIT;
72
+
73
+ const filtered = index.entries.filter((entry) => {
74
+ if (query.swc && !entry.swc.includes(query.swc)) {
75
+ return false;
76
+ }
77
+
78
+ if (query.severity && entry.severity !== query.severity) {
79
+ return false;
80
+ }
81
+
82
+ if (normalizedKeyword && normalizedKeyword.length > 0) {
83
+ const matchesKeyword = entry.keywords.some((keyword) =>
84
+ keyword.includes(normalizedKeyword)
85
+ );
86
+
87
+ if (!matchesKeyword) {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ return true;
93
+ });
94
+
95
+ return filtered.slice(0, limit);
96
+ }
97
+
98
+ export async function saveIndex(index: ScvdIndex, filePath: string): Promise<void> {
99
+ const json = JSON.stringify(index, null, 2);
100
+ await Bun.write(filePath, json);
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return typeof value === "object" && value !== null;
105
+ }
106
+
107
+ function parseStringArray(value: unknown): string[] {
108
+ if (!Array.isArray(value)) {
109
+ return [];
110
+ }
111
+
112
+ return value.filter((item): item is string => typeof item === "string");
113
+ }
114
+
115
+ function parseEntry(value: unknown): ScvdIndexEntry | null {
116
+ if (!isRecord(value)) {
117
+ return null;
118
+ }
119
+
120
+ const id = value.id;
121
+ const title = value.title;
122
+ const severity = value.severity;
123
+ const repoUrl = value.repoUrl;
124
+
125
+ if (
126
+ typeof id !== "string" ||
127
+ typeof title !== "string" ||
128
+ typeof severity !== "string" ||
129
+ typeof repoUrl !== "string"
130
+ ) {
131
+ return null;
132
+ }
133
+
134
+ return {
135
+ id,
136
+ title,
137
+ severity,
138
+ swc: parseStringArray(value.swc),
139
+ cwe: parseStringArray(value.cwe),
140
+ keywords: parseStringArray(value.keywords),
141
+ repoUrl,
142
+ };
143
+ }
144
+
145
+ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
146
+ const file = Bun.file(filePath);
147
+ const exists = await file.exists();
148
+
149
+ if (!exists) {
150
+ return null;
151
+ }
152
+
153
+ const raw = (await file.json()) as unknown;
154
+
155
+ if (!isRecord(raw)) {
156
+ return null;
157
+ }
158
+
159
+ const version = raw.version;
160
+ const lastSync = raw.lastSync;
161
+ const totalFindings = raw.totalFindings;
162
+ const rawEntries = raw.entries;
163
+
164
+ if (
165
+ typeof version !== "number" ||
166
+ typeof lastSync !== "string" ||
167
+ typeof totalFindings !== "number" ||
168
+ !Array.isArray(rawEntries)
169
+ ) {
170
+ return null;
171
+ }
172
+
173
+ const entries = rawEntries
174
+ .map(parseEntry)
175
+ .filter((entry): entry is ScvdIndexEntry => entry !== null);
176
+
177
+ return {
178
+ version,
179
+ lastSync,
180
+ totalFindings,
181
+ entries,
182
+ };
183
+ }