solidity-argus 0.3.2 → 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
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",
|
|
@@ -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,34 +41,13 @@ You must adhere to these strict writing standards:
|
|
|
41
41
|
|
|
42
42
|
## HOW TO GENERATE THE REPORT
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
### Approach 1: Use \`argus_generate_report\` tool
|
|
47
|
-
If you have structured findings data, call the tool:
|
|
48
|
-
- \`project_name\` (string): The name of the protocol or project.
|
|
49
|
-
- \`scope\` (string[]): List of files or contracts that were audited.
|
|
50
|
-
- \`include_executive_summary\` (boolean): Default \`true\`.
|
|
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.
|
|
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.
|
|
44
|
+
Argus passes you findings in natural language. Write the full report yourself in Markdown following the Report Structure above.
|
|
63
45
|
|
|
64
46
|
**Your workflow**:
|
|
65
|
-
1.
|
|
66
|
-
2.
|
|
67
|
-
3.
|
|
68
|
-
4.
|
|
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.
|
|
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}".
|
|
72
51
|
|
|
73
52
|
## QUALITY STANDARDS
|
|
74
53
|
|
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(),
|
|
@@ -84,6 +84,118 @@ function emptyAuditState(findings: Finding[] = []): AuditState {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
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
|
+
|
|
87
199
|
function hasMinimumFindingFields(
|
|
88
200
|
f: unknown,
|
|
89
201
|
): f is { check: string; file: string; lines: [number, number] } {
|
|
@@ -153,10 +265,22 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
153
265
|
)
|
|
154
266
|
}
|
|
155
267
|
|
|
268
|
+
const logger = createLogger()
|
|
269
|
+
|
|
156
270
|
if (Array.isArray(parsed)) {
|
|
157
|
-
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
|
|
158
276
|
.filter(hasMinimumFindingFields)
|
|
159
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
|
+
}
|
|
160
284
|
return emptyAuditState(validFindings)
|
|
161
285
|
}
|
|
162
286
|
|
|
@@ -166,9 +290,19 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
166
290
|
Array.isArray((parsed as AuditState).findings)
|
|
167
291
|
) {
|
|
168
292
|
const state = parsed as AuditState
|
|
169
|
-
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
|
|
170
298
|
.filter(hasMinimumFindingFields)
|
|
171
|
-
.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
|
+
}
|
|
172
306
|
return {
|
|
173
307
|
...emptyAuditState(),
|
|
174
308
|
...state,
|