solidity-argus 0.3.0 → 0.3.2
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/package.json +2 -1
- package/src/agents/argus-prompt.ts +10 -0
- package/src/agents/pythia-prompt.ts +10 -0
- package/src/agents/scribe-prompt.ts +13 -0
- package/src/agents/sentinel-prompt.ts +13 -2
- package/src/config/schema.ts +2 -0
- package/src/hooks/system-prompt-hook.ts +18 -1
- package/src/hooks/tool-tracking-hook.ts +6 -1
- package/src/index.ts +1 -1
- package/src/solodit-lifecycle.ts +15 -14
- package/src/tools/contract-analyzer-tool.ts +83 -1
- package/src/tools/forge-test-tool.ts +49 -2
- package/src/tools/pattern-checker-tool.ts +20 -3
- package/src/tools/pattern-schema.ts +3 -0
- package/src/tools/report-generator-tool.ts +33 -2
- package/src/tools/solodit-search-tool.ts +19 -0
- package/src/utils/solidity-parser.ts +73 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solidity-argus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Solidity smart contract security auditing plugin for OpenCode — 4 specialized agents, 12 tools (11 core + optional Solodit), and a curated vulnerability knowledge base",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"solidity",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@opencode-ai/plugin": "^1.2.10",
|
|
54
|
+
"@solidity-parser/parser": "^0.20.2",
|
|
54
55
|
"yaml": "^2.8.2",
|
|
55
56
|
"zod": "^4.1.8"
|
|
56
57
|
},
|
|
@@ -225,6 +225,16 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
|
|
|
225
225
|
\`\`\`
|
|
226
226
|
- Wait for both to complete before synthesizing their results.
|
|
227
227
|
|
|
228
|
+
## TASK COMPLETION TRACKING
|
|
229
|
+
|
|
230
|
+
You must track which audit phases are complete to avoid redundant work and tool re-execution.
|
|
231
|
+
|
|
232
|
+
- **Read the context**: At the start of each response, check the \`<argus-context>\` block injected by the system. It contains the current phase (Reconnaissance, Automated Scanning, Manual Review, etc.) and a list of completed phases.
|
|
233
|
+
- **Skip completed phases**: If a phase is marked complete in the context, do NOT re-run it. Proceed directly to the next incomplete phase.
|
|
234
|
+
- **Avoid tool re-execution**: If Slither, Forge, or Solodit results already appear in the \`Tools:\` section of the context, do not re-dispatch the same tool. Reference the existing results instead.
|
|
235
|
+
- **Mark phase completion**: After completing a phase, explicitly state "Phase X complete" in your response before moving to the next phase. This signals to the system that the phase is done.
|
|
236
|
+
- **Example flow**: If context shows "Reconnaissance: complete, Automated Scanning: complete", skip both and begin Manual Review. After Manual Review, state "Phase 3 (Manual Review) complete" before proceeding to Attack Surface Mapping.
|
|
237
|
+
|
|
228
238
|
## TOOL AWARENESS & USAGE
|
|
229
239
|
|
|
230
240
|
Your subagents have access to these specialized tools. Know when to delegate each.
|
|
@@ -84,6 +84,16 @@ You have two primary tools. Master them.
|
|
|
84
84
|
- Returns a list of matches with line numbers.
|
|
85
85
|
- **Crucial**: You must verify the context. A regex match for \`selfdestruct\` is not a bug if it's in a test file or a legitimate upgrade mechanism (though still risky).
|
|
86
86
|
|
|
87
|
+
## EMPTY RESULTS STRATEGY
|
|
88
|
+
|
|
89
|
+
When \`argus_solodit_search\` returns zero results for a query:
|
|
90
|
+
|
|
91
|
+
1. **Retry with alternative keywords** (2-3 variations). Example: If "ERC4626 inflation" returns nothing, try "vault share manipulation" or "exchange rate attack".
|
|
92
|
+
2. **If still empty**, fall back to \`argus_check_patterns\` with relevant pattern categories (e.g., \`["access-control", "logic-error"]\`).
|
|
93
|
+
3. **Never report empty-handed**. Pattern-based findings are valid research output. Combine them with manual code review to provide actionable intelligence.
|
|
94
|
+
|
|
95
|
+
This ensures Pythia always delivers research value, even when Solodit has no direct precedent.
|
|
96
|
+
|
|
87
97
|
## SKILLS SYSTEM
|
|
88
98
|
|
|
89
99
|
OpenCode has a powerful **Skills** system that allows you to load specialized knowledge modules. The Argus knowledge base includes 75+ curated SKILL.md files, 13 YAML pattern packs, and 15 real-world exploit case studies covering $3B+ in losses.
|
|
@@ -57,6 +57,19 @@ If Argus passes findings in natural language (which is common), write the full r
|
|
|
57
57
|
**Choose Approach 2 when**: Argus gives you a natural language list of findings, descriptions, and context. Just write the report.
|
|
58
58
|
**Choose Approach 1 when**: You have structured JSON finding data ready to pass.
|
|
59
59
|
|
|
60
|
+
## FILE PERSISTENCE
|
|
61
|
+
|
|
62
|
+
**Critical Operational Block**: You must ALWAYS use the \`argus_generate_report\` tool to write the audit report to disk. This tool now automatically writes the report to the filesystem via \`Bun.write()\` and returns the file path in its result.
|
|
63
|
+
|
|
64
|
+
**Your workflow**:
|
|
65
|
+
1. Prepare your findings data (either structured JSON or natural language context).
|
|
66
|
+
2. Call \`argus_generate_report\` with the appropriate parameters.
|
|
67
|
+
3. After the tool returns, extract the \`filePath\` field from the result.
|
|
68
|
+
4. **Always confirm the file path in your response to Argus**: "Report written to: {filePath}".
|
|
69
|
+
5. If the result does not include a \`filePath\` field, warn Argus: "Warning: filePath missing from tool result. The report may not have been written to disk."
|
|
70
|
+
|
|
71
|
+
This ensures the audit report is persisted and Argus can verify the output location.
|
|
72
|
+
|
|
60
73
|
## QUALITY STANDARDS
|
|
61
74
|
|
|
62
75
|
Before generating the report, verify:
|
|
@@ -31,8 +31,19 @@ You operate in a loop of **Scan -> Analyze -> Verify**.
|
|
|
31
31
|
- Use \`argus_gas_analysis\` to identify gas-intensive functions that may indicate inefficient or vulnerable logic.
|
|
32
32
|
|
|
33
33
|
4. **Reporting**:
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
- Format your findings strictly according to the Output Format section.
|
|
35
|
+
- Report back to Argus with confirmed findings.
|
|
36
|
+
|
|
37
|
+
## POC VERIFICATION
|
|
38
|
+
|
|
39
|
+
After writing a Proof of Concept test to reproduce a suspected vulnerability:
|
|
40
|
+
|
|
41
|
+
1. **Always run \`argus_forge_test\`** on the PoC test file immediately after writing it.
|
|
42
|
+
2. **Report the result** to Argus: pass count, fail count, and any revert reasons.
|
|
43
|
+
3. **If the PoC fails** (test does not trigger the bug as expected), revise the test logic and retry. Do not assume the bug exists if the PoC cannot reproduce it.
|
|
44
|
+
4. **If the PoC passes**, the vulnerability is confirmed. Escalate to Argus with full details.
|
|
45
|
+
|
|
46
|
+
This ensures every PoC is verified before reporting, eliminating false positives.
|
|
36
47
|
|
|
37
48
|
## TOOL USAGE GUIDE
|
|
38
49
|
|
package/src/config/schema.ts
CHANGED
|
@@ -31,6 +31,7 @@ const ReportingConfigSchema = z.object({
|
|
|
31
31
|
format: z.enum(["markdown"]).default("markdown"),
|
|
32
32
|
severityThreshold: z.enum(["critical", "high", "medium", "low", "informational"]).default("low"),
|
|
33
33
|
gasAnalysis: z.boolean().default(false),
|
|
34
|
+
output_dir: z.string().default(".opencode/reports/"),
|
|
34
35
|
})
|
|
35
36
|
|
|
36
37
|
const SoloditConfigSchema = z.object({
|
|
@@ -69,6 +70,7 @@ export const ArgusConfigSchema = z.object({
|
|
|
69
70
|
format: "markdown",
|
|
70
71
|
severityThreshold: "low",
|
|
71
72
|
gasAnalysis: false,
|
|
73
|
+
output_dir: ".opencode/reports/",
|
|
72
74
|
}),
|
|
73
75
|
solodit: SoloditConfigSchema.default({
|
|
74
76
|
enabled: true,
|
|
@@ -3,6 +3,15 @@ import type { AuditState, FindingSeverity } from "../state/types"
|
|
|
3
3
|
const DEFAULT_TOKEN_BUDGET = 2000
|
|
4
4
|
const TOKENS_PER_CHAR = 4
|
|
5
5
|
|
|
6
|
+
const TOOL_SHORT_NAMES: Record<string, string> = {
|
|
7
|
+
argus_slither_analyze: "slither",
|
|
8
|
+
argus_forge_test: "forge-test",
|
|
9
|
+
argus_check_patterns: "patterns",
|
|
10
|
+
argus_solodit_search: "solodit",
|
|
11
|
+
argus_analyze_contract: "analyzer",
|
|
12
|
+
}
|
|
13
|
+
const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
|
|
14
|
+
|
|
6
15
|
export interface SystemPromptHookDeps {
|
|
7
16
|
getAuditState: () => AuditState | null
|
|
8
17
|
getAgentForSession: (sessionID: string) => string | undefined
|
|
@@ -52,7 +61,13 @@ export function buildDynamicContext(
|
|
|
52
61
|
severityCounts[finding.severity]++
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
const executedToolNames = new Set(
|
|
65
|
+
auditState.toolsExecuted.map((t) => TOOL_SHORT_NAMES[t.tool] ?? t.tool),
|
|
66
|
+
)
|
|
55
67
|
const tools = auditState.toolsExecuted.map((tool) => tool.tool).join(", ") || "none"
|
|
68
|
+
const taskStatus = KEY_TOOLS.map(
|
|
69
|
+
(t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
|
|
70
|
+
).join(" ")
|
|
56
71
|
const unavailable = auditState.unavailableTools ?? []
|
|
57
72
|
const lines: string[] = [
|
|
58
73
|
`<argus-context agent="${agent}">`,
|
|
@@ -60,6 +75,7 @@ export function buildDynamicContext(
|
|
|
60
75
|
`Contracts: ${auditState.contractsReviewed.length} reviewed`,
|
|
61
76
|
`Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
|
|
62
77
|
`Tools: ${tools}`,
|
|
78
|
+
`Tasks: ${taskStatus}`,
|
|
63
79
|
]
|
|
64
80
|
|
|
65
81
|
if (unavailable.length > 0) {
|
|
@@ -72,9 +88,10 @@ export function buildDynamicContext(
|
|
|
72
88
|
let summary = lines.join("\n")
|
|
73
89
|
|
|
74
90
|
if (estimateTokens(summary) > tokenBudget) {
|
|
91
|
+
const doneCount = KEY_TOOLS.filter((t) => executedToolNames.has(t)).length
|
|
75
92
|
summary = [
|
|
76
93
|
`<argus-context agent="${agent}">`,
|
|
77
|
-
`Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length}`,
|
|
94
|
+
`Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length} | Tasks: ${doneCount}/${KEY_TOOLS.length} done`,
|
|
78
95
|
"</argus-context>",
|
|
79
96
|
].join("\n")
|
|
80
97
|
}
|
|
@@ -321,8 +321,13 @@ export function createToolTrackingHook(
|
|
|
321
321
|
case "argus_solodit_search":
|
|
322
322
|
processSoloditResult(record, auditState)
|
|
323
323
|
break
|
|
324
|
-
case "argus_forge_test":
|
|
324
|
+
case "argus_forge_test": {
|
|
325
|
+
const summary = toRecord(record.summary)
|
|
326
|
+
if (summary && typeof summary.failed === "number") {
|
|
327
|
+
findingsCount = summary.failed
|
|
328
|
+
}
|
|
325
329
|
break
|
|
330
|
+
}
|
|
326
331
|
case "argus_forge_fuzz":
|
|
327
332
|
processFuzzResult(record, auditState)
|
|
328
333
|
break
|
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ const ArgusPlugin: Plugin = async (ctx) => {
|
|
|
13
13
|
const config = loadArgusConfig(projectDir)
|
|
14
14
|
|
|
15
15
|
if (config.solodit?.enabled !== false) {
|
|
16
|
-
startSoloditMcp(config.solodit?.port ?? 3000)
|
|
16
|
+
await startSoloditMcp(config.solodit?.port ?? 3000)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const isHookEnabled = createHookGuard(config.disabled_hooks)
|
package/src/solodit-lifecycle.ts
CHANGED
|
@@ -182,21 +182,22 @@ export async function startSoloditMcp(port: number): Promise<void> {
|
|
|
182
182
|
soloditChild = spawnSoloditChild(port)
|
|
183
183
|
trackChildExit(soloditChild)
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
185
|
+
const deadline = AbortSignal.timeout(5000)
|
|
186
|
+
const delays = [1000, 2000]
|
|
187
|
+
for (const delay of delays) {
|
|
188
|
+
if (deadline.aborted) break
|
|
189
|
+
await Bun.sleep(delay)
|
|
190
|
+
if (deadline.aborted) break
|
|
191
|
+
const healthResult = await checkSoloditHealth(port, true)
|
|
192
|
+
if (healthResult.reachable) {
|
|
193
|
+
soloditAvailable = true
|
|
194
|
+
logger.debug(`Solodit MCP healthy on port ${port}`)
|
|
195
|
+
break
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
)
|
|
199
|
-
}
|
|
197
|
+
}
|
|
198
|
+
if (!soloditAvailable) {
|
|
199
|
+
logger.warn(`Solodit MCP not reachable after startup — monitoring will retry`)
|
|
200
|
+
}
|
|
200
201
|
|
|
201
202
|
startMonitoring(port)
|
|
202
203
|
}
|
|
@@ -3,7 +3,7 @@ import { basename } from "node:path"
|
|
|
3
3
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
4
4
|
import { findFoundryProjectDir } from "../shared/project-utils"
|
|
5
5
|
import type { ContractProfile } from "../state/types"
|
|
6
|
-
import { extractContractInfo } from "../utils/solidity-parser"
|
|
6
|
+
import { extractContractInfo, parseExternalCalls } from "../utils/solidity-parser"
|
|
7
7
|
|
|
8
8
|
type ContractAnalyzerArgs = {
|
|
9
9
|
file_path: string
|
|
@@ -56,6 +56,24 @@ function collectRiskIndicators(source: string, existing: string[]): string[] {
|
|
|
56
56
|
if (/\btx\.origin\b/.test(normalized)) {
|
|
57
57
|
indicators.add("uses-tx-origin")
|
|
58
58
|
}
|
|
59
|
+
if (/\.call\s*\{\s*value\s*:/.test(normalized)) {
|
|
60
|
+
indicators.add("uses-low-level-value-call")
|
|
61
|
+
}
|
|
62
|
+
if (normalized.includes(".call(")) {
|
|
63
|
+
indicators.add("uses-low-level-call")
|
|
64
|
+
}
|
|
65
|
+
if (normalized.includes("block.timestamp")) {
|
|
66
|
+
indicators.add("uses-block-timestamp")
|
|
67
|
+
}
|
|
68
|
+
if (normalized.includes("block.number")) {
|
|
69
|
+
indicators.add("uses-block-number")
|
|
70
|
+
}
|
|
71
|
+
if (normalized.includes("abi.encodepacked")) {
|
|
72
|
+
indicators.add("uses-abi-encode-packed")
|
|
73
|
+
}
|
|
74
|
+
if (/\becrecover\b/.test(normalized)) {
|
|
75
|
+
indicators.add("uses-ecrecover")
|
|
76
|
+
}
|
|
59
77
|
|
|
60
78
|
const importLines = source
|
|
61
79
|
.split("\n")
|
|
@@ -129,10 +147,74 @@ export async function executeContractAnalyzer(
|
|
|
129
147
|
return createFailureProfile(contractName, filePath, "contract analysis aborted")
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
const inheritanceRegex = /contract\s+(\w+)\s+is\s+([^{]+)/g
|
|
151
|
+
let sourceInheritance: string[] = []
|
|
152
|
+
let firstMatchParents: string[] | undefined
|
|
153
|
+
let regexMatch: RegExpExecArray | null = null
|
|
154
|
+
|
|
155
|
+
regexMatch = inheritanceRegex.exec(sourceText)
|
|
156
|
+
while (regexMatch !== null) {
|
|
157
|
+
const matchedName = regexMatch.at(1) ?? ""
|
|
158
|
+
const parents = (regexMatch.at(2) ?? "")
|
|
159
|
+
.split(",")
|
|
160
|
+
.map((p) => p.trim())
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
|
|
163
|
+
if (!firstMatchParents) {
|
|
164
|
+
firstMatchParents = parents
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (matchedName === contractName) {
|
|
168
|
+
sourceInheritance = parents
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
regexMatch = inheritanceRegex.exec(sourceText)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (sourceInheritance.length === 0 && firstMatchParents) {
|
|
176
|
+
sourceInheritance = firstMatchParents
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const mergedInheritance = [...new Set([...contractProfile.inheritance, ...sourceInheritance])]
|
|
180
|
+
const mergedExternalCalls = [
|
|
181
|
+
...new Set([...contractProfile.externalCalls, ...parseExternalCalls(sourceText)]),
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
// Extract modifiers from source text for each function
|
|
185
|
+
const visibilityKeywords = new Set([
|
|
186
|
+
"external",
|
|
187
|
+
"public",
|
|
188
|
+
"internal",
|
|
189
|
+
"private",
|
|
190
|
+
"view",
|
|
191
|
+
"pure",
|
|
192
|
+
"payable",
|
|
193
|
+
"virtual",
|
|
194
|
+
"override",
|
|
195
|
+
"returns",
|
|
196
|
+
])
|
|
197
|
+
for (const fn of contractProfile.functions) {
|
|
198
|
+
if (!fn.name) continue
|
|
199
|
+
const escapedName = fn.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
200
|
+
const fnPattern = new RegExp(`function\\s+${escapedName}\\s*\\([^)]*\\)\\s*([^{;]*)`)
|
|
201
|
+
const fnMatch = fnPattern.exec(sourceText)
|
|
202
|
+
if (!fnMatch?.[1]) continue
|
|
203
|
+
|
|
204
|
+
const afterParams = fnMatch[1]
|
|
205
|
+
.replace(/returns\s*\([^)]*\)/g, "")
|
|
206
|
+
.replace(/\([^)]*\)/g, "")
|
|
207
|
+
.trim()
|
|
208
|
+
const tokens = afterParams.match(/\b\w+\b/g) ?? []
|
|
209
|
+
fn.modifiers = tokens.filter((t) => !visibilityKeywords.has(t))
|
|
210
|
+
}
|
|
211
|
+
|
|
132
212
|
return {
|
|
133
213
|
...contractProfile,
|
|
134
214
|
name: contractProfile.name || contractName,
|
|
135
215
|
filePath,
|
|
216
|
+
inheritance: mergedInheritance,
|
|
217
|
+
externalCalls: mergedExternalCalls,
|
|
136
218
|
riskIndicators: collectRiskIndicators(sourceText, contractProfile.riskIndicators),
|
|
137
219
|
}
|
|
138
220
|
} catch (error) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { resolveProjectDir } from "../shared/project-utils"
|
|
3
|
+
import { extractJson } from "../utils/solidity-parser"
|
|
3
4
|
|
|
4
5
|
type ForgeTestArgs = {
|
|
5
6
|
target?: string
|
|
@@ -106,7 +107,53 @@ function parseTests(payload: ForgeTestPayload): {
|
|
|
106
107
|
} {
|
|
107
108
|
const collected: Array<ForgeTestItem | { skipped: true }> = []
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
const topLevelEntries = Object.entries(payload as unknown as Record<string, unknown>)
|
|
111
|
+
if (topLevelEntries.some(([key]) => key.includes(":"))) {
|
|
112
|
+
for (const [topLevelKey, suite] of topLevelEntries) {
|
|
113
|
+
if (!suite || typeof suite !== "object") {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const suiteRecord = suite as Record<string, unknown>
|
|
118
|
+
const testResults = suiteRecord.test_results
|
|
119
|
+
if (!testResults || typeof testResults !== "object") {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const contract = topLevelKey.split(":").at(1) ?? topLevelKey
|
|
124
|
+
for (const [name, details] of Object.entries(testResults)) {
|
|
125
|
+
if (!details || typeof details !== "object") {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const detailsRecord = details as Record<string, unknown>
|
|
130
|
+
const statusValue =
|
|
131
|
+
typeof detailsRecord.status === "string" ? detailsRecord.status : undefined
|
|
132
|
+
const status = mapStatus(statusValue)
|
|
133
|
+
if (status === "skip") {
|
|
134
|
+
collected.push({ skipped: true })
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const kind = detailsRecord.kind
|
|
139
|
+
const kindRecord =
|
|
140
|
+
kind && typeof kind === "object" ? (kind as Record<string, unknown>) : undefined
|
|
141
|
+
const unit = kindRecord?.Unit
|
|
142
|
+
const unitRecord =
|
|
143
|
+
unit && typeof unit === "object" ? (unit as Record<string, unknown>) : undefined
|
|
144
|
+
const fuzz = kindRecord?.Fuzz
|
|
145
|
+
const fuzzRecord =
|
|
146
|
+
fuzz && typeof fuzz === "object" ? (fuzz as Record<string, unknown>) : undefined
|
|
147
|
+
|
|
148
|
+
collected.push({
|
|
149
|
+
name,
|
|
150
|
+
contract,
|
|
151
|
+
status,
|
|
152
|
+
gas: toNumber(unitRecord?.gas ?? fuzzRecord?.mean_gas),
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (Array.isArray(payload.tests)) {
|
|
110
157
|
for (const item of payload.tests) {
|
|
111
158
|
const status = mapStatus(item.status)
|
|
112
159
|
if (status === "skip") {
|
|
@@ -311,7 +358,7 @@ export async function executeForgeTest(
|
|
|
311
358
|
|
|
312
359
|
let payload: ForgeTestPayload
|
|
313
360
|
try {
|
|
314
|
-
payload = JSON.parse(testResult.stdout) as ForgeTestPayload
|
|
361
|
+
payload = JSON.parse(extractJson(testResult.stdout, "{")) as ForgeTestPayload
|
|
315
362
|
} catch {
|
|
316
363
|
return fail("Invalid JSON output from forge test")
|
|
317
364
|
}
|
|
@@ -62,7 +62,7 @@ type PatternCheckDependencies = {
|
|
|
62
62
|
) => ScvdIndexEntry[]
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
type LoadedPattern = {
|
|
65
|
+
export type LoadedPattern = {
|
|
66
66
|
name: string
|
|
67
67
|
category: string
|
|
68
68
|
severity: Match["severity"]
|
|
@@ -70,6 +70,9 @@ type LoadedPattern = {
|
|
|
70
70
|
description: string
|
|
71
71
|
exploitReference?: string
|
|
72
72
|
source?: PatternSource
|
|
73
|
+
confidence?: "High" | "Medium" | "Low"
|
|
74
|
+
applies_to?: string[]
|
|
75
|
+
exclude_if?: string[]
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
export const PATTERN_PACK_VERSION = "1.0.0"
|
|
@@ -107,6 +110,9 @@ function normalizePatternDefinitions(
|
|
|
107
110
|
regex: new RegExp(patternDef.regex),
|
|
108
111
|
description: patternDef.description,
|
|
109
112
|
...(patternDef.exploit_ref ? { exploitReference: patternDef.exploit_ref } : {}),
|
|
113
|
+
...(patternDef.confidence ? { confidence: patternDef.confidence } : {}),
|
|
114
|
+
...(patternDef.applies_to ? { applies_to: patternDef.applies_to } : {}),
|
|
115
|
+
...(patternDef.exclude_if ? { exclude_if: patternDef.exclude_if } : {}),
|
|
110
116
|
source,
|
|
111
117
|
}))
|
|
112
118
|
}
|
|
@@ -235,16 +241,27 @@ function lineWindow(content: string, index: number): [number, number] {
|
|
|
235
241
|
return [start, end]
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
function findMatches(file: string, patterns: LoadedPattern[]): Match[] {
|
|
244
|
+
export function findMatches(file: string, patterns: LoadedPattern[]): Match[] {
|
|
239
245
|
const content = readFileSync(file, "utf8")
|
|
240
246
|
const matches: Match[] = []
|
|
241
247
|
|
|
248
|
+
// Strip comments and string literals to reduce false positives.
|
|
249
|
+
// Use a space-preserving approach so line numbers remain valid.
|
|
250
|
+
// Order: multi-line comments first (can contain //), then single-line, then strings.
|
|
251
|
+
const stripped = content
|
|
252
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
|
253
|
+
.replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length))
|
|
254
|
+
.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, (m) => {
|
|
255
|
+
const quote = m[0]
|
|
256
|
+
return `${quote}${" ".repeat(Math.max(0, m.length - 2))}${quote}`
|
|
257
|
+
})
|
|
258
|
+
|
|
242
259
|
for (const pattern of patterns) {
|
|
243
260
|
const regex = new RegExp(
|
|
244
261
|
pattern.regex.source,
|
|
245
262
|
pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`,
|
|
246
263
|
)
|
|
247
|
-
for (const found of
|
|
264
|
+
for (const found of stripped.matchAll(regex)) {
|
|
248
265
|
const index = found.index ?? 0
|
|
249
266
|
matches.push({
|
|
250
267
|
pattern: pattern.name,
|
|
@@ -37,6 +37,9 @@ export const PatternDefinitionSchema = z.object({
|
|
|
37
37
|
description: z.string().min(1),
|
|
38
38
|
exploit_ref: z.string().url().optional(),
|
|
39
39
|
remediation: z.string().optional(),
|
|
40
|
+
context: z.enum(["function-body", "contract-body", "file-level"]).optional(),
|
|
41
|
+
applies_to: z.array(z.string()).optional(),
|
|
42
|
+
exclude_if: z.array(z.string()).optional(),
|
|
40
43
|
})
|
|
41
44
|
|
|
42
45
|
export type PatternDefinition = z.infer<typeof PatternDefinitionSchema>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import path from "node:path"
|
|
1
2
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
3
|
+
import { loadArgusConfig } from "../config/loader"
|
|
4
|
+
import type { ArgusConfig } from "../config/types"
|
|
5
|
+
import { createLogger } from "../shared/logger"
|
|
6
|
+
import { resolveProjectDir } from "../shared/project-utils"
|
|
2
7
|
import type { AuditState, Finding, FindingSeverity } from "../state/types"
|
|
3
8
|
|
|
4
9
|
type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
|
|
@@ -23,6 +28,11 @@ export type ReportGenerationResult = {
|
|
|
23
28
|
report: string
|
|
24
29
|
findingsCount: FindingsCount
|
|
25
30
|
filename: string
|
|
31
|
+
filePath?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ReportGenerationDependencies = {
|
|
35
|
+
loadConfig?: (projectDir: string) => ArgusConfig
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
|
|
@@ -414,6 +424,7 @@ export function buildProvenanceAppendix(
|
|
|
414
424
|
export async function executeReportGeneration(
|
|
415
425
|
args: ReportGeneratorArgs,
|
|
416
426
|
context: ToolContext,
|
|
427
|
+
deps: ReportGenerationDependencies = {},
|
|
417
428
|
): Promise<ReportGenerationResult> {
|
|
418
429
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
419
430
|
const threshold = args.severity_threshold ?? "low"
|
|
@@ -473,11 +484,31 @@ export async function executeReportGeneration(
|
|
|
473
484
|
|
|
474
485
|
sections.push(buildProvenanceAppendix(state, threshold, findings.length))
|
|
475
486
|
|
|
476
|
-
|
|
477
|
-
|
|
487
|
+
const reportMarkdown = sections.join("\n\n")
|
|
488
|
+
const safeName = args.project_name.replace(/[^a-zA-Z0-9-_]/g, "-")
|
|
489
|
+
const diskFilename = `${safeName}-${Date.now()}.md`
|
|
490
|
+
|
|
491
|
+
const result: ReportGenerationResult = {
|
|
492
|
+
report: reportMarkdown,
|
|
478
493
|
findingsCount: counts,
|
|
479
494
|
filename: `${args.project_name}-audit-report-${auditDate}.md`,
|
|
480
495
|
}
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
499
|
+
const projectDir = resolveProjectDir(context)
|
|
500
|
+
const config = loadConfig(projectDir)
|
|
501
|
+
const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
|
|
502
|
+
const fullPath = path.join(projectDir, outputDir, diskFilename)
|
|
503
|
+
await Bun.write(fullPath, reportMarkdown)
|
|
504
|
+
result.filePath = fullPath
|
|
505
|
+
} catch (err: unknown) {
|
|
506
|
+
const logger = createLogger()
|
|
507
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
508
|
+
logger.warn(`Failed to write report to disk: ${message}`)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return result
|
|
481
512
|
}
|
|
482
513
|
|
|
483
514
|
export const reportGeneratorTool = tool({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDefinition } from "@opencode-ai/plugin"
|
|
2
2
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
3
3
|
import { createLogger } from "../shared/logger"
|
|
4
|
+
import { soloditAvailable } from "../solodit-lifecycle"
|
|
4
5
|
|
|
5
6
|
const logger = createLogger()
|
|
6
7
|
|
|
@@ -244,6 +245,24 @@ export async function executeSoloditSearch(
|
|
|
244
245
|
|
|
245
246
|
context.metadata({ title: `Solodit search: ${query}` })
|
|
246
247
|
|
|
248
|
+
// Belt-and-suspenders: check if Solodit MCP is available, with 3s retry
|
|
249
|
+
// Skip check in test environment
|
|
250
|
+
if (!soloditAvailable && process.env.NODE_ENV !== "test") {
|
|
251
|
+
// Wait up to 3s for monitoring to flip the flag
|
|
252
|
+
for (let i = 0; i < 3 && !soloditAvailable; i++) {
|
|
253
|
+
await Bun.sleep(1000)
|
|
254
|
+
}
|
|
255
|
+
if (!soloditAvailable) {
|
|
256
|
+
return {
|
|
257
|
+
results: [],
|
|
258
|
+
totalFound: 0,
|
|
259
|
+
query,
|
|
260
|
+
error:
|
|
261
|
+
"Solodit MCP not available — server did not start. Results limited to local patterns.",
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
247
266
|
const mcpCaller = callMcpTool ?? (hasMcpCapability(context) ? context.callMcpTool : undefined)
|
|
248
267
|
|
|
249
268
|
if (!mcpCaller) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import * as parser from "@solidity-parser/parser"
|
|
1
2
|
import type { ContractProfile } from "../state/types"
|
|
2
3
|
|
|
4
|
+
const EXTERNAL_CALL_METHODS = new Set(["call", "transfer", "send", "delegatecall", "staticcall"])
|
|
5
|
+
|
|
3
6
|
interface ABIFunction {
|
|
4
7
|
type: string
|
|
5
8
|
name: string
|
|
@@ -24,8 +27,7 @@ interface StorageLayout {
|
|
|
24
27
|
* prefix (e.g. forge table-format output, compilation progress).
|
|
25
28
|
* Falls back to the original string if no JSON delimiter is found.
|
|
26
29
|
*/
|
|
27
|
-
function extractJson(raw: string, opener: "[" | "{"): string {
|
|
28
|
-
const _closer = opener === "[" ? "]" : "}"
|
|
30
|
+
export function extractJson(raw: string, opener: "[" | "{"): string {
|
|
29
31
|
const start = raw.indexOf(opener)
|
|
30
32
|
if (start === -1) return raw
|
|
31
33
|
|
|
@@ -69,6 +71,75 @@ function extractJson(raw: string, opener: "[" | "{"): string {
|
|
|
69
71
|
return raw
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
75
|
+
if (typeof value === "object" && value !== null) {
|
|
76
|
+
return value as Record<string, unknown>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractNodeExpressionName(node: unknown): string | undefined {
|
|
83
|
+
const record = toRecord(node)
|
|
84
|
+
if (!record) return undefined
|
|
85
|
+
|
|
86
|
+
const type = typeof record.type === "string" ? record.type : undefined
|
|
87
|
+
if (!type) return undefined
|
|
88
|
+
|
|
89
|
+
if (type === "Identifier") {
|
|
90
|
+
return typeof record.name === "string" ? record.name : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (type === "ThisExpression") {
|
|
94
|
+
return "this"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (type === "MemberAccess") {
|
|
98
|
+
const expressionName = extractNodeExpressionName(record.expression)
|
|
99
|
+
const memberName = typeof record.memberName === "string" ? record.memberName : undefined
|
|
100
|
+
|
|
101
|
+
if (expressionName && memberName) {
|
|
102
|
+
return `${expressionName}.${memberName}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return expressionName ?? memberName
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (type === "IndexAccess") {
|
|
109
|
+
return extractNodeExpressionName(record.base)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (type === "FunctionCall") {
|
|
113
|
+
return extractNodeExpressionName(record.expression)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseExternalCalls(sourceText: string): string[] {
|
|
120
|
+
try {
|
|
121
|
+
const ast = parser.parse(sourceText, { tolerant: true, loc: false, range: false })
|
|
122
|
+
const externalCalls = new Set<string>()
|
|
123
|
+
|
|
124
|
+
parser.visit(ast, {
|
|
125
|
+
MemberAccess(node: unknown) {
|
|
126
|
+
const record = toRecord(node)
|
|
127
|
+
if (!record) return
|
|
128
|
+
|
|
129
|
+
const memberName = typeof record.memberName === "string" ? record.memberName : undefined
|
|
130
|
+
if (!memberName || !EXTERNAL_CALL_METHODS.has(memberName)) return
|
|
131
|
+
|
|
132
|
+
const expressionName = extractNodeExpressionName(record.expression)
|
|
133
|
+
externalCalls.add(expressionName ? `${expressionName}.${memberName}` : memberName)
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return [...externalCalls]
|
|
138
|
+
} catch {
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
72
143
|
/**
|
|
73
144
|
* Extract contract information using forge inspect
|
|
74
145
|
* Runs forge inspect <contractName> abi and storage-layout
|