solidity-argus 0.3.0 → 0.3.3
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 +7 -15
- package/src/agents/sentinel-prompt.ts +13 -2
- package/src/config/schema.ts +2 -0
- package/src/create-hooks.ts +6 -1
- 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 +170 -5
- 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.3",
|
|
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.
|
|
@@ -8,7 +8,7 @@ Your core responsibilities are:
|
|
|
8
8
|
1. **Aggregation**: Collecting findings from various tools and subagents.
|
|
9
9
|
2. **Deduplication**: Merging similar findings (e.g., multiple Slither warnings for the same issue).
|
|
10
10
|
3. **Contextualization**: Explaining *why* a finding matters in the context of the specific protocol.
|
|
11
|
-
4. **Report Generation**: Producing the final Markdown artifact
|
|
11
|
+
4. **Report Generation**: Producing the final Markdown artifact and writing it to disk.
|
|
12
12
|
|
|
13
13
|
## REPORT STRUCTURE
|
|
14
14
|
|
|
@@ -41,21 +41,13 @@ You must adhere to these strict writing standards:
|
|
|
41
41
|
|
|
42
42
|
## HOW TO GENERATE THE REPORT
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Argus passes you findings in natural language. Write the full report yourself in Markdown following the Report Structure above.
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- \`severity_threshold\` (string): "critical", "high", "medium", "low", or "informational". Usually "low" or "informational" to include everything.
|
|
52
|
-
- \`audit_state\` (string): JSON string of findings. Format each finding as: \`{"id":"f1","check":"name","severity":"High","confidence":"High","description":"...","file":"Contract.sol","lines":[1,10],"source":"manual"}\`
|
|
53
|
-
|
|
54
|
-
### Approach 2: Write the report directly as Markdown
|
|
55
|
-
If Argus passes findings in natural language (which is common), write the full report yourself in Markdown following the Report Structure below. This is often faster and produces better results than trying to serialize findings into JSON for the tool.
|
|
56
|
-
|
|
57
|
-
**Choose Approach 2 when**: Argus gives you a natural language list of findings, descriptions, and context. Just write the report.
|
|
58
|
-
**Choose Approach 1 when**: You have structured JSON finding data ready to pass.
|
|
46
|
+
**Your workflow**:
|
|
47
|
+
1. Read the findings Argus provides. Deduplicate, cross-reference, and assess severity.
|
|
48
|
+
2. Write the complete report in Markdown following the Report Structure and Output Format sections.
|
|
49
|
+
3. Save the report to disk using the \`write\` tool. Path: \`.opencode/reports/{ProjectName}-audit-{YYYY-MM-DD}.md\` relative to the project root.
|
|
50
|
+
4. Confirm the file path in your response to Argus: "Report written to: {filePath}".
|
|
59
51
|
|
|
60
52
|
## QUALITY STANDARDS
|
|
61
53
|
|
|
@@ -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,
|
package/src/create-hooks.ts
CHANGED
|
@@ -176,11 +176,16 @@ export function createHooks(args: {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
if (type === "session.deleted") {
|
|
179
|
+
await debouncedSave.flush()
|
|
180
|
+
if (auditState) {
|
|
181
|
+
await auditStateManager.save(auditState)
|
|
182
|
+
}
|
|
183
|
+
await auditStateManager.archive()
|
|
184
|
+
|
|
179
185
|
if (sessionId) {
|
|
180
186
|
agentTracker.clearSession(sessionId)
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
await auditStateManager.archive()
|
|
184
189
|
runJournal.log({
|
|
185
190
|
type: "session.deleted",
|
|
186
191
|
timestamp: Date.now(),
|
|
@@ -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"]
|
|
@@ -74,6 +84,118 @@ function emptyAuditState(findings: Finding[] = []): AuditState {
|
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
|
|
89
|
+
* Returns undefined if the string doesn't match a recognized format.
|
|
90
|
+
*/
|
|
91
|
+
export function parseLocationString(
|
|
92
|
+
location: string,
|
|
93
|
+
): { file: string; lines: [number, number] } | undefined {
|
|
94
|
+
// "File.sol:18-22" or "File.sol:L18-L22"
|
|
95
|
+
const rangeMatch = location.match(/^(.+?):L?(\d+)\s*-\s*L?(\d+)$/)
|
|
96
|
+
if (rangeMatch) {
|
|
97
|
+
const file = rangeMatch.at(1)
|
|
98
|
+
const start = rangeMatch.at(2)
|
|
99
|
+
const end = rangeMatch.at(3)
|
|
100
|
+
if (file && start && end) {
|
|
101
|
+
return { file, lines: [Number(start), Number(end)] }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// "File.sol:18"
|
|
105
|
+
const singleMatch = location.match(/^(.+?):L?(\d+)$/)
|
|
106
|
+
if (singleMatch) {
|
|
107
|
+
const file = singleMatch.at(1)
|
|
108
|
+
const lineNum = singleMatch.at(2)
|
|
109
|
+
if (file && lineNum) {
|
|
110
|
+
const n = Number(lineNum)
|
|
111
|
+
return { file, lines: [n, n] }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Normalize a raw finding object from agent output into the canonical field format.
|
|
119
|
+
* Handles common aliases:
|
|
120
|
+
* - title/name → check
|
|
121
|
+
* - location (string) → file + lines
|
|
122
|
+
* - case-insensitive severity → capitalized
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeRawFinding(raw: Record<string, unknown>): Record<string, unknown> {
|
|
125
|
+
const result = { ...raw }
|
|
126
|
+
|
|
127
|
+
// check: accept title, name as aliases
|
|
128
|
+
if (typeof result.check !== "string" || (result.check as string).length === 0) {
|
|
129
|
+
const alias = result.title ?? result.name
|
|
130
|
+
if (typeof alias === "string" && alias.length > 0) {
|
|
131
|
+
result.check = alias
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// file + lines: accept location string as alias
|
|
136
|
+
if (typeof result.file !== "string" && typeof result.location === "string") {
|
|
137
|
+
const parsed = parseLocationString(result.location as string)
|
|
138
|
+
if (parsed) {
|
|
139
|
+
result.file = parsed.file
|
|
140
|
+
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
141
|
+
result.lines = parsed.lines
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// lines: accept [start] as [start, start], accept line_start/line_end
|
|
147
|
+
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
148
|
+
if (Array.isArray(result.lines) && (result.lines as unknown[]).length === 1) {
|
|
149
|
+
const n = Number((result.lines as unknown[])[0])
|
|
150
|
+
if (!Number.isNaN(n)) {
|
|
151
|
+
result.lines = [n, n]
|
|
152
|
+
}
|
|
153
|
+
} else if (typeof result.line_start === "number" && typeof result.line_end === "number") {
|
|
154
|
+
result.lines = [result.line_start, result.line_end]
|
|
155
|
+
} else if (typeof result.line === "number") {
|
|
156
|
+
result.lines = [result.line, result.line]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// severity: case-insensitive normalization
|
|
161
|
+
if (typeof result.severity === "string") {
|
|
162
|
+
const lower = (result.severity as string).toLowerCase()
|
|
163
|
+
const SEVERITY_MAP: Record<string, string> = {
|
|
164
|
+
critical: "Critical",
|
|
165
|
+
high: "High",
|
|
166
|
+
medium: "Medium",
|
|
167
|
+
low: "Low",
|
|
168
|
+
informational: "Informational",
|
|
169
|
+
info: "Informational",
|
|
170
|
+
}
|
|
171
|
+
const mapped = SEVERITY_MAP[lower]
|
|
172
|
+
if (mapped) {
|
|
173
|
+
result.severity = mapped
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// confidence: case-insensitive normalization
|
|
178
|
+
if (typeof result.confidence === "string") {
|
|
179
|
+
const lower = (result.confidence as string).toLowerCase()
|
|
180
|
+
const CONFIDENCE_MAP: Record<string, string> = {
|
|
181
|
+
high: "High",
|
|
182
|
+
medium: "Medium",
|
|
183
|
+
low: "Low",
|
|
184
|
+
}
|
|
185
|
+
const mapped = CONFIDENCE_MAP[lower]
|
|
186
|
+
if (mapped) {
|
|
187
|
+
result.confidence = mapped
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// description: fall back to check if missing
|
|
192
|
+
if (typeof result.description !== "string" && typeof result.check === "string") {
|
|
193
|
+
result.description = result.check
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
|
|
77
199
|
function hasMinimumFindingFields(
|
|
78
200
|
f: unknown,
|
|
79
201
|
): f is { check: string; file: string; lines: [number, number] } {
|
|
@@ -143,10 +265,22 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
143
265
|
)
|
|
144
266
|
}
|
|
145
267
|
|
|
268
|
+
const logger = createLogger()
|
|
269
|
+
|
|
146
270
|
if (Array.isArray(parsed)) {
|
|
147
|
-
const
|
|
271
|
+
const rawItems = parsed as unknown[]
|
|
272
|
+
const normalized = rawItems
|
|
273
|
+
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
274
|
+
.map((item) => normalizeRawFinding(item))
|
|
275
|
+
const validFindings = normalized
|
|
148
276
|
.filter(hasMinimumFindingFields)
|
|
149
277
|
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
278
|
+
const dropped = rawItems.length - validFindings.length
|
|
279
|
+
if (dropped > 0) {
|
|
280
|
+
logger.warn(
|
|
281
|
+
`parseAuditState: ${dropped}/${rawItems.length} findings dropped (missing required fields after normalization)`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
150
284
|
return emptyAuditState(validFindings)
|
|
151
285
|
}
|
|
152
286
|
|
|
@@ -156,9 +290,19 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
156
290
|
Array.isArray((parsed as AuditState).findings)
|
|
157
291
|
) {
|
|
158
292
|
const state = parsed as AuditState
|
|
159
|
-
const
|
|
293
|
+
const rawFindings = state.findings as unknown[]
|
|
294
|
+
const normalized = rawFindings
|
|
295
|
+
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
296
|
+
.map((item) => normalizeRawFinding(item))
|
|
297
|
+
const validFindings = normalized
|
|
160
298
|
.filter(hasMinimumFindingFields)
|
|
161
|
-
.map((f) => normalizeFinding(f as
|
|
299
|
+
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
300
|
+
const dropped = rawFindings.length - validFindings.length
|
|
301
|
+
if (dropped > 0) {
|
|
302
|
+
logger.warn(
|
|
303
|
+
`parseAuditState: ${dropped}/${rawFindings.length} findings dropped (missing required fields after normalization)`,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
162
306
|
return {
|
|
163
307
|
...emptyAuditState(),
|
|
164
308
|
...state,
|
|
@@ -414,6 +558,7 @@ export function buildProvenanceAppendix(
|
|
|
414
558
|
export async function executeReportGeneration(
|
|
415
559
|
args: ReportGeneratorArgs,
|
|
416
560
|
context: ToolContext,
|
|
561
|
+
deps: ReportGenerationDependencies = {},
|
|
417
562
|
): Promise<ReportGenerationResult> {
|
|
418
563
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
419
564
|
const threshold = args.severity_threshold ?? "low"
|
|
@@ -473,11 +618,31 @@ export async function executeReportGeneration(
|
|
|
473
618
|
|
|
474
619
|
sections.push(buildProvenanceAppendix(state, threshold, findings.length))
|
|
475
620
|
|
|
476
|
-
|
|
477
|
-
|
|
621
|
+
const reportMarkdown = sections.join("\n\n")
|
|
622
|
+
const safeName = args.project_name.replace(/[^a-zA-Z0-9-_]/g, "-")
|
|
623
|
+
const diskFilename = `${safeName}-${Date.now()}.md`
|
|
624
|
+
|
|
625
|
+
const result: ReportGenerationResult = {
|
|
626
|
+
report: reportMarkdown,
|
|
478
627
|
findingsCount: counts,
|
|
479
628
|
filename: `${args.project_name}-audit-report-${auditDate}.md`,
|
|
480
629
|
}
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
633
|
+
const projectDir = resolveProjectDir(context)
|
|
634
|
+
const config = loadConfig(projectDir)
|
|
635
|
+
const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
|
|
636
|
+
const fullPath = path.join(projectDir, outputDir, diskFilename)
|
|
637
|
+
await Bun.write(fullPath, reportMarkdown)
|
|
638
|
+
result.filePath = fullPath
|
|
639
|
+
} catch (err: unknown) {
|
|
640
|
+
const logger = createLogger()
|
|
641
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
642
|
+
logger.warn(`Failed to write report to disk: ${message}`)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return result
|
|
481
646
|
}
|
|
482
647
|
|
|
483
648
|
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
|