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.
- package/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/package.json +43 -0
- package/skills/INVENTORY.md +79 -0
- package/skills/README.md +56 -0
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +424 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +157 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +373 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +412 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +55 -0
- package/skills/checklists/general-audit/SKILL.md +433 -0
- package/skills/methodology/audit-workflow/SKILL.md +129 -0
- package/skills/methodology/report-template/SKILL.md +190 -0
- package/skills/methodology/severity-classification/SKILL.md +179 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +229 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +317 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +281 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +221 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +247 -0
- package/skills/references/exploit-reference/SKILL.md +259 -0
- package/skills/references/smartbugs-examples/SKILL.md +296 -0
- package/skills/vulnerability-patterns/access-control/SKILL.md +298 -0
- package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +59 -0
- package/skills/vulnerability-patterns/assert-violation/SKILL.md +59 -0
- package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +61 -0
- package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +55 -0
- package/skills/vulnerability-patterns/default-visibility/SKILL.md +62 -0
- package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +60 -0
- package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +59 -0
- package/skills/vulnerability-patterns/dos-revert/SKILL.md +72 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +249 -0
- package/skills/vulnerability-patterns/floating-pragma/SKILL.md +51 -0
- package/skills/vulnerability-patterns/hash-collision/SKILL.md +52 -0
- package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +61 -0
- package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +60 -0
- package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +59 -0
- package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +61 -0
- package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +61 -0
- package/skills/vulnerability-patterns/logic-errors/SKILL.md +333 -0
- package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +60 -0
- package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +66 -0
- package/skills/vulnerability-patterns/off-by-one/SKILL.md +67 -0
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +252 -0
- package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +65 -0
- package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +61 -0
- package/skills/vulnerability-patterns/reentrancy/SKILL.md +266 -0
- package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +72 -0
- package/skills/vulnerability-patterns/signature-malleability/SKILL.md +59 -0
- package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +63 -0
- package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +52 -0
- package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +65 -0
- package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +61 -0
- package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +63 -0
- package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +56 -0
- package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +80 -0
- package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +69 -0
- package/skills/vulnerability-patterns/unused-variables/SKILL.md +70 -0
- package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +81 -0
- package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +77 -0
- package/skills/vulnerability-patterns/weird-tokens/SKILL.md +294 -0
- package/src/agents/argus-prompt.ts +407 -0
- package/src/agents/pythia-prompt.ts +134 -0
- package/src/agents/scribe-prompt.ts +87 -0
- package/src/agents/sentinel-prompt.ts +133 -0
- package/src/cli/cli-program.ts +67 -0
- package/src/cli/commands/doctor.ts +83 -0
- package/src/cli/commands/init.ts +46 -0
- package/src/cli/commands/install.ts +55 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/tui-prompts.ts +75 -0
- package/src/cli/types.ts +9 -0
- package/src/config/index.ts +3 -0
- package/src/config/loader.ts +36 -0
- package/src/config/schema.ts +82 -0
- package/src/config/types.ts +4 -0
- package/src/constants/defaults.ts +6 -0
- package/src/create-hooks.ts +84 -0
- package/src/create-managers.ts +26 -0
- package/src/create-tools.ts +30 -0
- package/src/features/audit-enforcer/audit-enforcer.ts +34 -0
- package/src/features/audit-enforcer/index.ts +1 -0
- package/src/features/background-agent/background-manager.ts +200 -0
- package/src/features/background-agent/index.ts +1 -0
- package/src/features/context-monitor/context-monitor.ts +48 -0
- package/src/features/context-monitor/index.ts +4 -0
- package/src/features/context-monitor/tool-output-truncator.ts +17 -0
- package/src/features/error-recovery/index.ts +2 -0
- package/src/features/error-recovery/session-recovery.ts +27 -0
- package/src/features/error-recovery/tool-error-recovery.ts +35 -0
- package/src/features/index.ts +5 -0
- package/src/features/persistent-state/audit-state-manager.ts +121 -0
- package/src/features/persistent-state/index.ts +1 -0
- package/src/hooks/compaction-hook.ts +50 -0
- package/src/hooks/config-handler.ts +116 -0
- package/src/hooks/event-hook-v2.ts +93 -0
- package/src/hooks/event-hook.ts +74 -0
- package/src/hooks/hook-system.ts +9 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/knowledge-sync-hook.ts +57 -0
- package/src/hooks/safe-create-hook.ts +15 -0
- package/src/hooks/system-prompt-hook.ts +126 -0
- package/src/hooks/tool-tracking-hook.ts +234 -0
- package/src/hooks/types.ts +16 -0
- package/src/index.ts +36 -0
- package/src/knowledge/scvd-client.ts +242 -0
- package/src/knowledge/scvd-index.ts +183 -0
- package/src/knowledge/scvd-sync.ts +85 -0
- package/src/managers/index.ts +1 -0
- package/src/managers/types.ts +85 -0
- package/src/plugin-interface.ts +38 -0
- package/src/shared/binary-utils.ts +63 -0
- package/src/shared/deep-merge.ts +71 -0
- package/src/shared/file-utils.ts +56 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/jsonc-parser.ts +39 -0
- package/src/shared/logger.ts +36 -0
- package/src/state/audit-state.ts +27 -0
- package/src/state/finding-store.ts +126 -0
- package/src/state/plugin-state.ts +14 -0
- package/src/state/types.ts +61 -0
- package/src/tools/contract-analyzer-tool.ts +184 -0
- package/src/tools/forge-fuzz-tool.ts +311 -0
- package/src/tools/forge-test-tool.ts +397 -0
- package/src/tools/pattern-checker-tool.ts +337 -0
- package/src/tools/report-generator-tool.ts +308 -0
- package/src/tools/slither-tool.ts +465 -0
- package/src/tools/solodit-search-tool.ts +131 -0
- package/src/tools/sync-knowledge-tool.ts +116 -0
- package/src/utils/project-detector.ts +133 -0
- 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
|
+
}
|