solaudit 0.2.0 → 0.3.1
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/README.md +27 -9
- package/dist/cli.js +487 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# SolAudit
|
|
2
2
|
|
|
3
3
|
[](./LICENSE)
|
|
4
|
-
[](https://www.npmjs.com/package/solaudit)
|
|
5
5
|
[](./.github/workflows/ci.yml)
|
|
6
6
|
|
|
7
7
|
**A zero-dependency static-analysis engine that scans Anchor/Solana Rust programs for the vulnerability patterns behind the most common on-chain exploits — available as a CLI and as a free web app.**
|
|
@@ -44,6 +44,8 @@ solaudit [path] [options]
|
|
|
44
44
|
| `--json` | Print raw scan results as JSON |
|
|
45
45
|
| `--sarif` | Print results as SARIF 2.1.0 (for GitHub Code Scanning) |
|
|
46
46
|
| `--min-severity <level>` | Only report findings at or above `CRITICAL`\|`HIGH`\|`MEDIUM`\|`LOW`\|`INFO` |
|
|
47
|
+
| `--experimental` | Also run opt-in experimental rules (`SOL-012` Duplicate Mutable Accounts, `SOL-013` Token Account Not Bound to Mint); higher signal, more false positives |
|
|
48
|
+
| `--include-tests` | Also scan test/fuzz/fixture/example code (skipped by default; findings there are rarely actionable) |
|
|
47
49
|
| `--help` | Show usage |
|
|
48
50
|
|
|
49
51
|
### Example
|
|
@@ -53,7 +55,7 @@ $ solaudit ./examples
|
|
|
53
55
|
```
|
|
54
56
|
|
|
55
57
|
```
|
|
56
|
-
SolAudit v0.
|
|
58
|
+
SolAudit v0.3.1 — static analysis for Anchor/Solana programs
|
|
57
59
|
Scanned 1 file(s) at 2026-08-27T06:00:00.000Z
|
|
58
60
|
──────────────────────────────────────────────────────────────────────
|
|
59
61
|
|
|
@@ -94,30 +96,42 @@ bun run dev
|
|
|
94
96
|
|
|
95
97
|
| ID | Severity | Name | What it catches |
|
|
96
98
|
|---|---|---|---|
|
|
97
|
-
| `SOL-001` | CRITICAL | Missing Signer Check | `transfer`/`mint_to` calls
|
|
98
|
-
| `SOL-002` | HIGH | Unchecked Account Deserialization | `Account::try_from` / `AccountLoader::try_from`
|
|
99
|
+
| `SOL-001` | CRITICAL | Missing Signer Check | `transfer`/`mint_to` calls with no signer evidence anywhere: no nearby `is_signer` check, no `Signer<'info>` field on the governing Accounts struct, and no PDA signing (`invoke_signed` / `new_with_signer`) |
|
|
100
|
+
| `SOL-002` | HIGH | Unchecked Account Deserialization | `Account::try_from` / `AccountLoader::try_from` on an account with no validating `#[account(...)]` constraint (constraint-aware: fields carrying `owner`/`constraint`/`has_one` bindings are recognized as validated) |
|
|
99
101
|
| `SOL-003` | HIGH | Arbitrary CPI | `invoke`/`invoke_signed` targeting a program ID sourced from a caller-supplied account instead of a verified constant |
|
|
100
|
-
| `SOL-004` | HIGH | Missing Bump Seed Canonicalization | `create_program_address`
|
|
101
|
-
| `SOL-005` | MEDIUM | Integer Overflow Risk | Raw `+`/`-`/`*` arithmetic on balance-like
|
|
102
|
+
| `SOL-004` | HIGH | Missing Bump Seed Canonicalization | `create_program_address` called directly with a bump that didn't come from a nearby `find_program_address` (which is canonical by construction) |
|
|
103
|
+
| `SOL-005` | MEDIUM | Integer Overflow Risk | Raw `+`/`-`/`*` arithmetic *written into account state* (field/compound assignment) on balance-like values instead of `checked_*`/`saturating_*` |
|
|
102
104
|
| `SOL-006` | HIGH | Type Cosplay / Missing Discriminator Check | Manual `try_from_slice` deserialization with no discriminator validation or `Account<'info, T>` wrapper |
|
|
103
105
|
| `SOL-007` | MEDIUM | Insecure Account Initialization | `#[account(init, ...)]` constraints missing `payer` and/or `space` |
|
|
104
|
-
| `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo` fields
|
|
106
|
+
| `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo`/`UncheckedAccount` fields with no constraint binding whose data is trusted (deserialized/read) or whose key is assigned into persistent state, with no `.owner` check anywhere in the file |
|
|
105
107
|
| `SOL-009` | HIGH | Insecure Manual Account Closure | An account's lamports manually drained to zero without discriminator zeroing or owner reassignment (unlike Anchor's `close = destination`), enabling closed-account revival |
|
|
108
|
+
| `SOL-010` | MEDIUM | Reinitialization Risk via init_if_needed | `#[account(init_if_needed, ...)]` without canonical PDA seeds / `has_one` constraints, letting a pre-existing attacker-crafted account pass as freshly initialized |
|
|
106
109
|
| `SOL-011` | MEDIUM | Missing Rent-Exemption Check | A partial lamport withdrawal with no nearby `Rent::get()?.minimum_balance(...)` check, risking runtime garbage-collection of the account |
|
|
107
110
|
|
|
108
111
|
Each rule is implemented as real regex/line-scanning logic in [`packages/core/src/rules/index.ts`](./packages/core/src/rules/index.ts) — see that file (and its accompanying [tests](./packages/core/src/rules/index.test.ts)) for the exact matching heuristics and false-positive guards.
|
|
109
112
|
|
|
110
113
|
## Accuracy
|
|
111
114
|
|
|
112
|
-
On the committed labeled suite in [`benchmark/`](./benchmark), the deterministic engine scores **
|
|
115
|
+
On the committed labeled suite in [`benchmark/`](./benchmark), the deterministic engine scores **100% precision, 100% recall, 100% F1** on rule-covered categories.
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
### Real-world validation (0.3.1)
|
|
118
|
+
|
|
119
|
+
0.3.0 was scanned against 9 major open-source protocols (marginfi, Drift, Orca Whirlpools, Marinade, Squads, Phoenix, Clockwork, Saber, and Cashio — the real $50M exploit, as a control). The result drove a rewrite of how the engine reads Anchor code: it now parses `#[derive(Accounts)]` structs and understands the constraint layer (`Signer`, `owner`/`has_one`/`constraint`/`seeds`, handler `Context<T>` mapping, `#[cfg(test)]` exclusion), instead of pattern-matching handler bodies alone.
|
|
120
|
+
|
|
121
|
+
- **Default findings across the 9 repos dropped from 1,232 to 158 (−87%)** — marginfi went from 521 findings to 20; Drift from 454 to 77.
|
|
122
|
+
- The remaining findings are *more* actionable, not fewer ideas: suppressions only happen on positive evidence of validation.
|
|
123
|
+
- Under `--experimental`, the new `SOL-013` rule fires on Cashio's actual vulnerable pattern — a token account whose mint is never bound despite a mint account being in scope (the class behind the infinite-mint exploit).
|
|
124
|
+
|
|
125
|
+
Benchmark label integrity note: three samples' labels were revised in 0.3.1 to match SOL-008's sharpened definition (raw accounts whose *data* is trusted or whose key is *assigned into state*); each revision is documented inline in [`benchmark/labels.json`](./benchmark/labels.json).
|
|
126
|
+
|
|
127
|
+
**Duplicate mutable accounts** and **token accounts unbound to their mint** (the Cashio class) are covered by the experimental `SOL-012` and `SOL-013` rules, available via `solaudit --experimental`. A “distinctness” heuristic false-positives on common legitimate patterns (for example a deposit instruction’s user/vault token accounts of the same type with no explicit `!=` constraint), so it stays opt-in to protect the default ruleset's precision. These numbers describe this suite, not general security effectiveness.
|
|
115
128
|
|
|
116
129
|
## Output formats (CLI)
|
|
117
130
|
|
|
118
131
|
- **Human** (default) — colorized terminal report grouped by severity.
|
|
119
132
|
- **JSON** (`--json`) — the full `ScanResult` object (`scannedFiles`, `findings[]`, `summary`).
|
|
120
133
|
- **SARIF** (`--sarif`) — [SARIF 2.1.0](https://sarifweb.azurewebsites.net/) output compatible with GitHub Code Scanning.
|
|
134
|
+
- **Experimental rules** (`--experimental`) — opt into noisier, higher-signal heuristics (`SOL-012` Duplicate Mutable Accounts, `SOL-013` Token Account Not Bound to Mint). Recommended for deep manual reviews rather than CI gates.
|
|
121
135
|
|
|
122
136
|
## GitHub Actions
|
|
123
137
|
|
|
@@ -179,6 +193,10 @@ Contributions are very welcome — new rules, false-positive fixes, additional o
|
|
|
179
193
|
|
|
180
194
|
Please open an issue first for larger changes so we can discuss the approach.
|
|
181
195
|
|
|
196
|
+
## Support
|
|
197
|
+
|
|
198
|
+
SolAudit is free and open source, and development continues with or without tips. If it catches a bug or saves you real time and you'd like to say thanks, tips are appreciated — never expected: [flipcash.com/d142ad60-e966-4967-bfa3-6d2332e59342](https://flipcash.com/d142ad60-e966-4967-bfa3-6d2332e59342)
|
|
199
|
+
|
|
182
200
|
## License
|
|
183
201
|
|
|
184
202
|
[MIT](./LICENSE) © 2026 Cody Pack
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,222 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// ../packages/core/src/anchor.ts
|
|
4
|
+
function stripComments(text) {
|
|
5
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, "").split(/\r\n|\r|\n/).map((line) => line.replace(/\/\/.*$/, "")).join(`
|
|
6
|
+
`);
|
|
7
|
+
}
|
|
8
|
+
function stripStringLiterals(line) {
|
|
9
|
+
return line.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
10
|
+
}
|
|
11
|
+
function matchBraces(content, openIdx) {
|
|
12
|
+
let depth = 0;
|
|
13
|
+
let i = openIdx;
|
|
14
|
+
let inLineComment = false;
|
|
15
|
+
let inBlockComment = false;
|
|
16
|
+
let inString = false;
|
|
17
|
+
let inChar = false;
|
|
18
|
+
while (i < content.length) {
|
|
19
|
+
const c = content[i];
|
|
20
|
+
const next = i + 1 < content.length ? content[i + 1] : "";
|
|
21
|
+
if (inLineComment) {
|
|
22
|
+
if (c === `
|
|
23
|
+
`)
|
|
24
|
+
inLineComment = false;
|
|
25
|
+
} else if (inBlockComment) {
|
|
26
|
+
if (c === "*" && next === "/") {
|
|
27
|
+
inBlockComment = false;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
} else if (inString) {
|
|
31
|
+
if (c === "\\")
|
|
32
|
+
i++;
|
|
33
|
+
else if (c === '"')
|
|
34
|
+
inString = false;
|
|
35
|
+
} else if (inChar) {
|
|
36
|
+
if (c === "\\")
|
|
37
|
+
i++;
|
|
38
|
+
else if (c === "'")
|
|
39
|
+
inChar = false;
|
|
40
|
+
} else if (c === "/" && next === "/") {
|
|
41
|
+
inLineComment = true;
|
|
42
|
+
i++;
|
|
43
|
+
} else if (c === "/" && next === "*") {
|
|
44
|
+
inBlockComment = true;
|
|
45
|
+
i++;
|
|
46
|
+
} else if (c === '"') {
|
|
47
|
+
inString = true;
|
|
48
|
+
} else if (c === "'" && /[a-zA-Z0-9_]/.test(next) && content[i + 2] === "'") {
|
|
49
|
+
inChar = true;
|
|
50
|
+
} else if (c === "{") {
|
|
51
|
+
depth++;
|
|
52
|
+
} else if (c === "}") {
|
|
53
|
+
depth--;
|
|
54
|
+
if (depth === 0)
|
|
55
|
+
return i + 1;
|
|
56
|
+
}
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
return content.length;
|
|
60
|
+
}
|
|
61
|
+
var RAW_WRAPPERS = new Set(["AccountInfo", "UncheckedAccount"]);
|
|
62
|
+
function parseFields(structBody, structBodyOffset, content) {
|
|
63
|
+
const fields = [];
|
|
64
|
+
const fieldRe = /((?:\s*(?:#\[[\s\S]*?\]|\/\/[^\n]*))*)\s*pub\s+(\w+)\s*:\s*((?:Box\s*<\s*)*)([\w:]+)\s*<\s*'info\s*(?:,\s*([\w:]+)\s*)?>[\s>]*/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = fieldRe.exec(structBody)) !== null) {
|
|
67
|
+
const attrs = stripComments(m[1] || "");
|
|
68
|
+
const name = m[2];
|
|
69
|
+
const wrapperType = m[4];
|
|
70
|
+
const genericArg = m[5] || null;
|
|
71
|
+
const offset = structBodyOffset + m.index;
|
|
72
|
+
const line = content.slice(0, offset).split(/\r\n|\r|\n/).length;
|
|
73
|
+
fields.push({
|
|
74
|
+
name,
|
|
75
|
+
wrapperType,
|
|
76
|
+
genericArg,
|
|
77
|
+
attrs,
|
|
78
|
+
isRaw: RAW_WRAPPERS.has(wrapperType),
|
|
79
|
+
isSignerTyped: wrapperType === "Signer" || /#\s*\[\s*account\(\s*signer\s*\)\s*\]/.test(attrs),
|
|
80
|
+
isTokenAccount: genericArg === "TokenAccount",
|
|
81
|
+
isMint: genericArg === "Mint",
|
|
82
|
+
validated: /\b(owner|constraint|has_one|address|seeds)\s*=/.test(attrs),
|
|
83
|
+
bindsTokenMint: /\b(?:token|associated_token)::mint\b/.test(attrs),
|
|
84
|
+
hasInit: /\binit(_if_needed)?\b/.test(attrs),
|
|
85
|
+
line
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return fields;
|
|
89
|
+
}
|
|
90
|
+
function parseStructs(content) {
|
|
91
|
+
const structs = [];
|
|
92
|
+
const deriveRe = /#\s*\[\s*derive\(\s*Accounts(?:\s*,\s*[\w:]+)*\s*\)\s*\]/g;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = deriveRe.exec(content)) !== null) {
|
|
95
|
+
const afterIdx = m.index + m[0].length;
|
|
96
|
+
const structRe = /pub\s+struct\s+(\w+)[^{]*\{/g;
|
|
97
|
+
structRe.lastIndex = afterIdx;
|
|
98
|
+
const sm = structRe.exec(content);
|
|
99
|
+
if (!sm || sm.index - afterIdx > 400)
|
|
100
|
+
continue;
|
|
101
|
+
const openBrace = sm.index + sm[0].length - 1;
|
|
102
|
+
const end = matchBraces(content, openBrace);
|
|
103
|
+
const body = content.slice(openBrace + 1, end - 1);
|
|
104
|
+
structs.push({
|
|
105
|
+
name: sm[1],
|
|
106
|
+
start: m.index,
|
|
107
|
+
end,
|
|
108
|
+
fields: parseFields(body, openBrace + 1, content)
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return structs;
|
|
112
|
+
}
|
|
113
|
+
function parseRegions(content) {
|
|
114
|
+
const regions = [];
|
|
115
|
+
const implRe = /\bimpl(?:\s*<[^>]*>)?\s+(\w+)(?:\s*<[^>]*>)?(?:\s+for\s+\w+(?:\s*<[^>]*>)?)?\s*\{/g;
|
|
116
|
+
let im;
|
|
117
|
+
while ((im = implRe.exec(content)) !== null) {
|
|
118
|
+
const forMatch = im[0].match(/\bfor\s+(\w+)/);
|
|
119
|
+
const implType = forMatch ? forMatch[1] : im[1];
|
|
120
|
+
const openBrace = im.index + im[0].length - 1;
|
|
121
|
+
const end = matchBraces(content, openBrace);
|
|
122
|
+
regions.push({ start: im.index, end, contextType: null, implType });
|
|
123
|
+
}
|
|
124
|
+
const fnRe = /\bfn\s+\w+\s*(?:<[^>]*>)?\s*\(\s*(?:mut\s+)?ctx\s*:\s*Context\s*<\s*(?:(?:'\w+|'_)\s*,\s*)*(\w+)(?:\s*<[^>]*>)?\s*[,>]/g;
|
|
125
|
+
let fm;
|
|
126
|
+
while ((fm = fnRe.exec(content)) !== null) {
|
|
127
|
+
let i = fm.index + fm[0].length;
|
|
128
|
+
let parenDepth = 0;
|
|
129
|
+
parenDepth = 1;
|
|
130
|
+
while (i < content.length && parenDepth > 0) {
|
|
131
|
+
if (content[i] === "(")
|
|
132
|
+
parenDepth++;
|
|
133
|
+
else if (content[i] === ")")
|
|
134
|
+
parenDepth--;
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
while (i < content.length && content[i] !== "{") {
|
|
138
|
+
if (content[i] === ";")
|
|
139
|
+
break;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
if (i >= content.length || content[i] !== "{")
|
|
143
|
+
continue;
|
|
144
|
+
const end = matchBraces(content, i);
|
|
145
|
+
regions.push({ start: fm.index, end, contextType: fm[1], implType: null });
|
|
146
|
+
}
|
|
147
|
+
return regions;
|
|
148
|
+
}
|
|
149
|
+
function parseTestRanges(content) {
|
|
150
|
+
const ranges = [];
|
|
151
|
+
const re = /#\s*\[\s*cfg\(\s*test\s*\)\s*\]/g;
|
|
152
|
+
let m;
|
|
153
|
+
while ((m = re.exec(content)) !== null) {
|
|
154
|
+
const braceIdx = content.indexOf("{", m.index + m[0].length);
|
|
155
|
+
if (braceIdx === -1)
|
|
156
|
+
continue;
|
|
157
|
+
ranges.push([m.index, matchBraces(content, braceIdx)]);
|
|
158
|
+
}
|
|
159
|
+
return ranges;
|
|
160
|
+
}
|
|
161
|
+
function buildModel(content) {
|
|
162
|
+
return {
|
|
163
|
+
structs: parseStructs(content),
|
|
164
|
+
regions: parseRegions(content),
|
|
165
|
+
testRanges: parseTestRanges(content)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
var cached = null;
|
|
169
|
+
function getConstraintModel(content) {
|
|
170
|
+
if (cached && cached.content === content)
|
|
171
|
+
return cached.model;
|
|
172
|
+
const model = buildModel(content);
|
|
173
|
+
cached = { content, model };
|
|
174
|
+
return model;
|
|
175
|
+
}
|
|
176
|
+
function inRanges(offset, ranges) {
|
|
177
|
+
return ranges.some(([s, e]) => offset >= s && offset < e);
|
|
178
|
+
}
|
|
179
|
+
function innermostRegionAt(model, offset) {
|
|
180
|
+
let best = null;
|
|
181
|
+
for (const r of model.regions) {
|
|
182
|
+
if (offset >= r.start && offset < r.end) {
|
|
183
|
+
if (!best || r.end - r.start < best.end - best.start)
|
|
184
|
+
best = r;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return best;
|
|
188
|
+
}
|
|
189
|
+
function structAt(model, offset) {
|
|
190
|
+
const region = innermostRegionAt(model, offset);
|
|
191
|
+
if (!region)
|
|
192
|
+
return null;
|
|
193
|
+
const name = region.contextType ?? region.implType;
|
|
194
|
+
if (!name)
|
|
195
|
+
return null;
|
|
196
|
+
return model.structs.find((s) => s.name === name) ?? null;
|
|
197
|
+
}
|
|
198
|
+
function findField(model, name) {
|
|
199
|
+
for (const s of model.structs) {
|
|
200
|
+
const f = s.fields.find((f) => f.name === name);
|
|
201
|
+
if (f)
|
|
202
|
+
return f;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function lineOffset(lines, idx) {
|
|
207
|
+
let off = 0;
|
|
208
|
+
for (let i = 0;i < idx && i < lines.length; i++)
|
|
209
|
+
off += lines[i].length + 1;
|
|
210
|
+
return off;
|
|
211
|
+
}
|
|
212
|
+
function isTestLikePath(filePath) {
|
|
213
|
+
const p = filePath.replace(/\\/g, "/");
|
|
214
|
+
if (/(^|\/)(tests?|test[-_]utils?|testing|fuzz|fixtures?|benches|benchmarks|examples?)(\/|$)/i.test(p))
|
|
215
|
+
return true;
|
|
216
|
+
const base = (p.split("/").pop() || "").toLowerCase();
|
|
217
|
+
return /^(tests|e2e)\.rs$/.test(base) || /^test_.*\.rs$/.test(base) || /(_tests?|_e2e)\.rs$/.test(base);
|
|
218
|
+
}
|
|
219
|
+
|
|
3
220
|
// ../packages/core/src/rules/index.ts
|
|
4
221
|
function makeFinding(rule, filePath, line, snippet, description) {
|
|
5
222
|
return {
|
|
@@ -23,23 +240,51 @@ var missingSignerCheck = {
|
|
|
23
240
|
id: "SOL-001",
|
|
24
241
|
name: "Missing Signer Check",
|
|
25
242
|
severity: "CRITICAL",
|
|
26
|
-
description: "A transfer or mint_to call was found
|
|
243
|
+
description: "A transfer or mint_to call was found with no signer evidence: no is_signer check nearby, no Signer<'info> field on the governing Accounts struct, and no PDA signing (invoke_signed / new_with_signer). Add a `Signer<'info>` account type or an explicit `require!(ctx.accounts.authority.is_signer, ...)` check before authorizing fund movement.",
|
|
27
244
|
check(fileContent, filePath) {
|
|
28
245
|
const findings = [];
|
|
29
246
|
const lines = splitLines(fileContent);
|
|
30
|
-
const callPattern = /\b(transfer|mint_to)\s*\(/;
|
|
31
|
-
|
|
247
|
+
const callPattern = /\b(?:transfer|mint_to|transfer_checked|mint_to_checked)\s*\(/;
|
|
248
|
+
const declPattern = /\bfn\s+(?:transfer|mint_to|transfer_checked|mint_to_checked)\s*\(/;
|
|
249
|
+
const model = getConstraintModel(fileContent);
|
|
250
|
+
lines.forEach((rawLine, idx) => {
|
|
251
|
+
if (declPattern.test(rawLine))
|
|
252
|
+
return;
|
|
253
|
+
const line = stripStringLiterals(rawLine);
|
|
32
254
|
if (!callPattern.test(line))
|
|
33
255
|
return;
|
|
34
256
|
if (!line.includes("ctx.accounts.") && !fileContent.includes("ctx.accounts."))
|
|
35
257
|
return;
|
|
258
|
+
const offset = lineOffset(lines, idx);
|
|
259
|
+
if (inRanges(offset, model.testRanges))
|
|
260
|
+
return;
|
|
36
261
|
const windowStart = Math.max(0, idx - 10);
|
|
37
262
|
const window = lines.slice(windowStart, idx + 1).join(`
|
|
38
263
|
`);
|
|
39
264
|
const hasSignerEvidence = /is_signer/.test(window) || /Signer<\s*'info\s*>/.test(window) || /#\[account\([^)]*signer[^)]*\)\]/i.test(window);
|
|
40
|
-
if (
|
|
41
|
-
|
|
265
|
+
if (hasSignerEvidence)
|
|
266
|
+
return;
|
|
267
|
+
const extWindow = lines.slice(windowStart, Math.min(lines.length, idx + 16)).join(`
|
|
268
|
+
`);
|
|
269
|
+
if (/\binvoke_signed\b/.test(extWindow))
|
|
270
|
+
return;
|
|
271
|
+
if (/\bnew_with_signer\b/.test(extWindow))
|
|
272
|
+
return;
|
|
273
|
+
if (/system_instruction::transfer/.test(line) && /\binvoke(?:_signed)?\s*\(/.test(extWindow)) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!line.includes("::")) {
|
|
277
|
+
const beforeMatch = line.slice(0, line.search(callPattern));
|
|
278
|
+
if (/\binvoke(?:_signed)?\s*\([^)]*$/.test(beforeMatch))
|
|
279
|
+
return;
|
|
280
|
+
const prevLines = lines.slice(Math.max(0, idx - 3), idx);
|
|
281
|
+
if (prevLines.some((l) => /\binvoke(?:_signed)?\s*\([^)]*$/.test(l)))
|
|
282
|
+
return;
|
|
42
283
|
}
|
|
284
|
+
const struct = structAt(model, offset);
|
|
285
|
+
if (struct && struct.fields.some((f) => f.isSignerTyped))
|
|
286
|
+
return;
|
|
287
|
+
findings.push(makeFinding(missingSignerCheck, filePath, idx + 1, rawLine, "Verify the authority/owner account is a Signer or explicitly check `.is_signer` before transferring or minting tokens."));
|
|
43
288
|
});
|
|
44
289
|
return findings;
|
|
45
290
|
}
|
|
@@ -53,14 +298,25 @@ var uncheckedDeserialization = {
|
|
|
53
298
|
const findings = [];
|
|
54
299
|
const lines = splitLines(fileContent);
|
|
55
300
|
const pattern = /(Account|AccountLoader)::try_from\s*\(/;
|
|
301
|
+
const model = getConstraintModel(fileContent);
|
|
56
302
|
lines.forEach((line, idx) => {
|
|
57
303
|
if (!pattern.test(line))
|
|
58
304
|
return;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
305
|
+
const offset = lineOffset(lines, idx);
|
|
306
|
+
if (inRanges(offset, model.testRanges))
|
|
307
|
+
return;
|
|
308
|
+
const windowStart = Math.max(0, idx - 3);
|
|
309
|
+
const window = lines.slice(windowStart, idx + 1).join(`
|
|
310
|
+
`);
|
|
311
|
+
if (/#\[account\(/.test(window))
|
|
312
|
+
return;
|
|
313
|
+
const argMatch = line.match(/::try_from\s*\(\s*&?\s*(?:ctx\.accounts\.|self\.)?(\w+)/);
|
|
314
|
+
if (argMatch) {
|
|
315
|
+
const field = findField(model, argMatch[1]);
|
|
316
|
+
if (field && field.validated)
|
|
317
|
+
return;
|
|
63
318
|
}
|
|
319
|
+
findings.push(makeFinding(uncheckedDeserialization, filePath, idx + 1, line, "Add a `#[account(constraint = ..., owner = ...)]` attribute, or replace with a typed `Account<'info, T>` field so Anchor validates ownership and discriminator automatically."));
|
|
64
320
|
});
|
|
65
321
|
return findings;
|
|
66
322
|
}
|
|
@@ -93,23 +349,24 @@ var missingBumpCanonicalization = {
|
|
|
93
349
|
id: "SOL-004",
|
|
94
350
|
name: "Missing Bump Seed Canonicalization",
|
|
95
351
|
severity: "HIGH",
|
|
96
|
-
description: "PDA bump seeds must be derived via `find_program_address`
|
|
352
|
+
description: "PDA bump seeds must be derived via `find_program_address` (which returns the canonical bump by construction) and stored/validated on-chain. Calling `create_program_address` directly with a caller-supplied bump allows bump-seed forgery attacks.",
|
|
97
353
|
check(fileContent, filePath) {
|
|
98
354
|
const findings = [];
|
|
99
355
|
const lines = splitLines(fileContent);
|
|
356
|
+
const model = getConstraintModel(fileContent);
|
|
100
357
|
lines.forEach((line, idx) => {
|
|
101
|
-
if (
|
|
102
|
-
findings.push(makeFinding(missingBumpCanonicalization, filePath, idx + 1, line, "Use `Pubkey::find_program_address` to derive the canonical bump instead of calling `create_program_address` directly with a caller-supplied bump."));
|
|
358
|
+
if (!/create_program_address\s*\(/.test(line))
|
|
103
359
|
return;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
360
|
+
const offset = lineOffset(lines, idx);
|
|
361
|
+
if (inRanges(offset, model.testRanges))
|
|
362
|
+
return;
|
|
363
|
+
const windowStart = Math.max(0, idx - 10);
|
|
364
|
+
const windowEnd = Math.min(lines.length, idx + 3);
|
|
365
|
+
const window = stripLineComments(lines.slice(windowStart, windowEnd).join(`
|
|
366
|
+
`));
|
|
367
|
+
if (/find_program_address/.test(window))
|
|
368
|
+
return;
|
|
369
|
+
findings.push(makeFinding(missingBumpCanonicalization, filePath, idx + 1, line, "Use `Pubkey::find_program_address` to derive the canonical bump instead of calling `create_program_address` directly with a caller-supplied bump."));
|
|
113
370
|
});
|
|
114
371
|
return findings;
|
|
115
372
|
}
|
|
@@ -118,19 +375,29 @@ var integerOverflowRisk = {
|
|
|
118
375
|
id: "SOL-005",
|
|
119
376
|
name: "Integer Overflow Risk",
|
|
120
377
|
severity: "MEDIUM",
|
|
121
|
-
description: "Raw arithmetic (`+`, `-`, `*`) was
|
|
378
|
+
description: "Raw arithmetic (`+`, `-`, `*`) was written into account state (a field assignment or compound assignment) on amount-like values without checked or saturating arithmetic. Unchecked overflow can wrap silently in release builds without `overflow-checks = true`, corrupting balances. Use `.checked_add`/`.checked_sub`/`.checked_mul` or `saturating_*` and handle the `None` case.",
|
|
122
379
|
check(fileContent, filePath) {
|
|
123
380
|
const findings = [];
|
|
124
381
|
const lines = splitLines(fileContent);
|
|
125
|
-
|
|
382
|
+
if (isTestLikePath(filePath))
|
|
383
|
+
return findings;
|
|
384
|
+
const model = getConstraintModel(fileContent);
|
|
385
|
+
const arithmeticPattern = /\b(\w*(amount|balance|supply|total|shares|stake|reserve|collateral|liquidity|principal)\w*)\s*(\+|\-|\*)\s*\w/i;
|
|
126
386
|
const alreadySafe = /checked_(add|sub|mul|div)|saturating_(add|sub|mul)|wrapping_(add|sub|mul)/;
|
|
387
|
+
const stateWritePattern = /^\s*(?:[\w.\[\]]+\.)[\w.\[\]]+\s*(?:=|\+=|-=|\*=)\s*[^=>]|\b\w+\s*(?:\+=|-=|\*=)\s*[^=]/;
|
|
127
388
|
lines.forEach((line, idx) => {
|
|
128
389
|
if (alreadySafe.test(line))
|
|
129
390
|
return;
|
|
130
391
|
if (line.trim().startsWith("//"))
|
|
131
392
|
return;
|
|
132
|
-
if (
|
|
133
|
-
|
|
393
|
+
if (/\d\.\d/.test(line))
|
|
394
|
+
return;
|
|
395
|
+
const offset = lineOffset(lines, idx);
|
|
396
|
+
if (inRanges(offset, model.testRanges))
|
|
397
|
+
return;
|
|
398
|
+
const code = stripStringLiterals(line);
|
|
399
|
+
if (arithmeticPattern.test(code) && stateWritePattern.test(code)) {
|
|
400
|
+
findings.push(makeFinding(integerOverflowRisk, filePath, idx + 1, line, "Replace raw arithmetic written to state with `.checked_add()/.checked_sub()/.checked_mul()` (propagating errors via `ok_or(ErrorCode::MathOverflow)?`) or `saturating_*` equivalents."));
|
|
134
401
|
}
|
|
135
402
|
});
|
|
136
403
|
return findings;
|
|
@@ -174,10 +441,11 @@ var insecureInit = {
|
|
|
174
441
|
continue;
|
|
175
442
|
const hasPayer = /\bpayer\s*=/.test(block);
|
|
176
443
|
const hasSpace = /\bspace\s*=/.test(block);
|
|
177
|
-
|
|
444
|
+
const isTokenInit = /\b(associated_token|token)::/.test(block);
|
|
445
|
+
if (!hasPayer || !hasSpace && !isTokenInit) {
|
|
178
446
|
const upToMatch = fileContent.slice(0, match.index);
|
|
179
447
|
const line = upToMatch.split(/\r\n|\r|\n/).length;
|
|
180
|
-
const missing = [!hasPayer && "payer", !hasSpace && "space"].filter(Boolean).join(" and ");
|
|
448
|
+
const missing = [!hasPayer && "payer", !hasSpace && !isTokenInit && "space"].filter(Boolean).join(" and ");
|
|
181
449
|
findings.push(makeFinding(insecureInit, filePath, line, `#[account(${block.trim()})]`, `Add the missing \`${missing}\` argument(s) to this init constraint so Anchor allocates and rent-exempts the account correctly.`));
|
|
182
450
|
}
|
|
183
451
|
}
|
|
@@ -188,10 +456,62 @@ var missingOwnerCheck = {
|
|
|
188
456
|
id: "SOL-008",
|
|
189
457
|
name: "Missing Owner Check",
|
|
190
458
|
severity: "LOW",
|
|
191
|
-
description: "A raw `AccountInfo` field
|
|
459
|
+
description: "A raw `AccountInfo`/`UncheckedAccount` field carries no `owner`/`constraint`/`has_one` binding in its Accounts struct and no `.owner` comparison anywhere in the file. Raw accounts bypass Anchor's automatic ownership validation, so the program must manually verify `account.owner == expected_program_id` before trusting its data.",
|
|
192
460
|
check(fileContent, filePath) {
|
|
193
461
|
const findings = [];
|
|
194
462
|
const lines = splitLines(fileContent);
|
|
463
|
+
const model = getConstraintModel(fileContent);
|
|
464
|
+
if (model.structs.length > 0) {
|
|
465
|
+
const validatedNames = new Set;
|
|
466
|
+
for (const s of model.structs) {
|
|
467
|
+
for (const f of s.fields) {
|
|
468
|
+
if (f.validated)
|
|
469
|
+
validatedNames.add(f.name);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const suspects = new Map;
|
|
473
|
+
for (const s of model.structs) {
|
|
474
|
+
for (const f of s.fields) {
|
|
475
|
+
if (!f.isRaw || validatedNames.has(f.name))
|
|
476
|
+
continue;
|
|
477
|
+
if (new RegExp(`\\b${f.name}\\s*\\.owner\\b`).test(stripLineComments(fileContent))) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (!suspects.has(f.name))
|
|
481
|
+
suspects.set(f.name, f.line);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const [name, declLine] of suspects) {
|
|
485
|
+
const usagePattern = new RegExp(`ctx\\.accounts\\.${name}\\b`);
|
|
486
|
+
let reportLine = declLine;
|
|
487
|
+
let snippet = lines[declLine - 1] || name;
|
|
488
|
+
let touchesData = false;
|
|
489
|
+
for (let i = 0;i < lines.length; i++) {
|
|
490
|
+
if (!usagePattern.test(lines[i]) || i + 1 === declLine)
|
|
491
|
+
continue;
|
|
492
|
+
if (reportLine === declLine) {
|
|
493
|
+
reportLine = i + 1;
|
|
494
|
+
snippet = lines[i];
|
|
495
|
+
}
|
|
496
|
+
if (/try_borrow_data|\.load\s*\(|data\.borrow|try_from|\.lamports\s*\(|accessor::/.test(lines[i])) {
|
|
497
|
+
touchesData = true;
|
|
498
|
+
reportLine = i + 1;
|
|
499
|
+
snippet = lines[i];
|
|
500
|
+
}
|
|
501
|
+
if (new RegExp(`=\\s*(?:&)?ctx\\.accounts\\.${name}\\.key\\s*\\(`).test(lines[i]) && !/==|require_keys_eq/.test(lines[i])) {
|
|
502
|
+
touchesData = true;
|
|
503
|
+
reportLine = i + 1;
|
|
504
|
+
snippet = lines[i];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (reportLine === declLine)
|
|
508
|
+
continue;
|
|
509
|
+
if (!touchesData)
|
|
510
|
+
continue;
|
|
511
|
+
findings.push(makeFinding(missingOwnerCheck, filePath, reportLine, snippet, `\`${name}\` is a raw AccountInfo with no constraint binding. Add an explicit \`require!(ctx.accounts.${name}.owner == &expected_program_id, ErrorCode::InvalidOwner)\` check (or an \`#[account(owner = ...)]\` constraint) before trusting its data.`));
|
|
512
|
+
}
|
|
513
|
+
return findings;
|
|
514
|
+
}
|
|
195
515
|
const rawFieldPattern = /pub\s+(\w+)\s*:\s*AccountInfo<\s*'info\s*>/;
|
|
196
516
|
const rawFields = new Set;
|
|
197
517
|
lines.forEach((line) => {
|
|
@@ -201,8 +521,11 @@ var missingOwnerCheck = {
|
|
|
201
521
|
});
|
|
202
522
|
if (rawFields.size === 0)
|
|
203
523
|
return findings;
|
|
524
|
+
const flagged = new Set;
|
|
204
525
|
lines.forEach((line, idx) => {
|
|
205
526
|
for (const field of rawFields) {
|
|
527
|
+
if (flagged.has(field))
|
|
528
|
+
continue;
|
|
206
529
|
const usagePattern = new RegExp(`ctx\\.accounts\\.${field}\\b`);
|
|
207
530
|
if (!usagePattern.test(line))
|
|
208
531
|
continue;
|
|
@@ -212,6 +535,7 @@ var missingOwnerCheck = {
|
|
|
212
535
|
`);
|
|
213
536
|
const hasOwnerCheck = new RegExp(`${field}\\.owner`).test(window) || /\.owner\s*==/.test(window);
|
|
214
537
|
if (!hasOwnerCheck) {
|
|
538
|
+
flagged.add(field);
|
|
215
539
|
findings.push(makeFinding(missingOwnerCheck, filePath, idx + 1, line, `Add an explicit \`require!(ctx.accounts.${field}.owner == &expected_program_id, ErrorCode::InvalidOwner)\` check before using this raw AccountInfo.`));
|
|
216
540
|
}
|
|
217
541
|
}
|
|
@@ -243,6 +567,81 @@ var insecureAccountClosure = {
|
|
|
243
567
|
return findings;
|
|
244
568
|
}
|
|
245
569
|
};
|
|
570
|
+
var initIfNeededReinit = {
|
|
571
|
+
id: "SOL-010",
|
|
572
|
+
name: "Reinitialization Risk via init_if_needed",
|
|
573
|
+
severity: "MEDIUM",
|
|
574
|
+
description: "`#[account(init_if_needed, ...)]` initializes the account only when it does not already exist, so a pre-existing account flows through the same validation path. Without canonical PDA seeds/bump and constraints binding the account to expected state, this pattern has enabled reinitialization attacks where an attacker crafts or front-runs account state. Anchor's own documentation recommends avoiding `init_if_needed` unless strictly necessary.",
|
|
575
|
+
check(fileContent, filePath) {
|
|
576
|
+
const findings = [];
|
|
577
|
+
const blockRegex = /#\[account\(([\s\S]*?)\)\]/g;
|
|
578
|
+
let match;
|
|
579
|
+
while ((match = blockRegex.exec(fileContent)) !== null) {
|
|
580
|
+
const block = match[1];
|
|
581
|
+
if (!/\binit_if_needed\b/.test(block))
|
|
582
|
+
continue;
|
|
583
|
+
const line = fileContent.slice(0, match.index).split(/\r\n|\r|\n/).length;
|
|
584
|
+
findings.push(makeFinding(initIfNeededReinit, filePath, line, `#[account(${block.trim()})]`, "Prefer `init` plus a separate existence check. If `init_if_needed` is unavoidable, make the account a PDA with canonical `seeds`/`bump` and add `has_one`/`constraint` bindings so a pre-existing attacker-controlled account cannot pass validation."));
|
|
585
|
+
}
|
|
586
|
+
return findings;
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
var duplicateMutableAccounts = {
|
|
590
|
+
id: "SOL-012",
|
|
591
|
+
name: "Duplicate Mutable Accounts (experimental)",
|
|
592
|
+
severity: "HIGH",
|
|
593
|
+
description: "Two or more mutable accounts of the same type in one instruction with no `constraint = a.key() != b.key()` forcing distinctness. If the surrounding logic assumes the accounts are different (e.g. reading one balance and writing the other, or debiting one and crediting the other), passing the same account twice can corrupt accounting. Add an explicit inequality constraint or restructure the instruction.",
|
|
594
|
+
check(fileContent, filePath) {
|
|
595
|
+
const findings = [];
|
|
596
|
+
const lines = splitLines(fileContent);
|
|
597
|
+
const structStart = /#\[derive\(\s*Accounts\s*\)\][\s\S]*?pub struct\s+\w+[^\{]*\{/g;
|
|
598
|
+
let sm;
|
|
599
|
+
while ((sm = structStart.exec(fileContent)) !== null) {
|
|
600
|
+
let depth = 1;
|
|
601
|
+
let i = sm.index + sm[0].length;
|
|
602
|
+
while (i < fileContent.length && depth > 0) {
|
|
603
|
+
if (fileContent[i] === "{")
|
|
604
|
+
depth++;
|
|
605
|
+
else if (fileContent[i] === "}")
|
|
606
|
+
depth--;
|
|
607
|
+
i++;
|
|
608
|
+
}
|
|
609
|
+
const body = fileContent.slice(sm.index, i);
|
|
610
|
+
const bodyNoComments = stripLineComments(body);
|
|
611
|
+
const fields = [];
|
|
612
|
+
const fieldRegex = /((?:#\[[\s\S]*?\]\s*)*)pub\s+(\w+)\s*:\s*([\w:]+)\s*<\s*'info\s*(?:,\s*([\w:]+)\s*)?>/g;
|
|
613
|
+
let fm;
|
|
614
|
+
while ((fm = fieldRegex.exec(body)) !== null) {
|
|
615
|
+
const attrs = fm[1] || "";
|
|
616
|
+
const isMut = /#\[account\([\s\S]*?\bmut\b[\s\S]*?\)\]/.test(attrs);
|
|
617
|
+
const typeName = fm[4] || fm[3];
|
|
618
|
+
fields.push({ name: fm[2], typeName, isMut, offset: sm.index + fm.index });
|
|
619
|
+
}
|
|
620
|
+
const mutByType = new Map;
|
|
621
|
+
for (const f of fields) {
|
|
622
|
+
if (!f.isMut)
|
|
623
|
+
continue;
|
|
624
|
+
const group = mutByType.get(f.typeName) || [];
|
|
625
|
+
group.push(f);
|
|
626
|
+
mutByType.set(f.typeName, group);
|
|
627
|
+
}
|
|
628
|
+
for (const group of mutByType.values()) {
|
|
629
|
+
if (group.length < 2)
|
|
630
|
+
continue;
|
|
631
|
+
const first = group[0];
|
|
632
|
+
for (let g = 1;g < group.length; g++) {
|
|
633
|
+
const other = group[g];
|
|
634
|
+
const distinct = new RegExp(`${first.name}\\.key\\(\\)\\s*!=\\s*${other.name}\\.key\\(\\)|` + `${other.name}\\.key\\(\\)\\s*!=\\s*${first.name}\\.key\\(\\)`);
|
|
635
|
+
if (distinct.test(bodyNoComments))
|
|
636
|
+
continue;
|
|
637
|
+
const line = fileContent.slice(0, other.offset).split(/\r\n|\r|\n/).length;
|
|
638
|
+
findings.push(makeFinding(duplicateMutableAccounts, filePath, line, lines[line - 1] || other.name, `Fields \`${first.name}\` and \`${other.name}\` are both mutable \`${other.typeName}\` accounts with no distinctness constraint. Add \`constraint = ${other.name}.key() != ${first.name}.key()\` (if the instruction requires them to differ) or document why aliasing is safe.`));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return findings;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
246
645
|
var missingRentExemptCheck = {
|
|
247
646
|
id: "SOL-011",
|
|
248
647
|
name: "Missing Rent-Exemption Check on Partial Withdrawal",
|
|
@@ -269,6 +668,30 @@ var missingRentExemptCheck = {
|
|
|
269
668
|
return findings;
|
|
270
669
|
}
|
|
271
670
|
};
|
|
671
|
+
var unboundTokenMint = {
|
|
672
|
+
id: "SOL-013",
|
|
673
|
+
name: "Token Account Not Bound to Mint",
|
|
674
|
+
severity: "MEDIUM",
|
|
675
|
+
description: "A token-account field and a mint field coexist in the same Accounts struct, but the token account is never bound to the mint (`token::mint = ...`, `associated_token::mint = ...`, or a constraint/address pin). The instruction may accept a token account of an unintended mint — the vulnerability class behind the Cashio infinite-mint exploit. Bind the token account to the mint in the `#[account(...)]` constraint.",
|
|
676
|
+
check(fileContent, filePath) {
|
|
677
|
+
const findings = [];
|
|
678
|
+
const lines = splitLines(fileContent);
|
|
679
|
+
const model = getConstraintModel(fileContent);
|
|
680
|
+
for (const s of model.structs) {
|
|
681
|
+
const mintFields = s.fields.filter((f) => f.isMint);
|
|
682
|
+
if (mintFields.length === 0)
|
|
683
|
+
continue;
|
|
684
|
+
for (const f of s.fields) {
|
|
685
|
+
if (!f.isTokenAccount)
|
|
686
|
+
continue;
|
|
687
|
+
if (f.bindsTokenMint || f.hasInit || f.validated)
|
|
688
|
+
continue;
|
|
689
|
+
findings.push(makeFinding(unboundTokenMint, filePath, f.line, lines[f.line - 1] || f.name, `\`${f.name}\` is a TokenAccount but is never bound to a mint, although this instruction also takes a mint account (${mintFields.map((m) => `\`${m.name}\``).join(", ")}). Add \`token::mint = ${mintFields[0].name}\` (and usually \`token::authority\`) to the field's #[account(...)] constraint so a token account of an unintended mint is rejected.`));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return findings;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
272
695
|
var rules = [
|
|
273
696
|
missingSignerCheck,
|
|
274
697
|
uncheckedDeserialization,
|
|
@@ -279,8 +702,10 @@ var rules = [
|
|
|
279
702
|
insecureInit,
|
|
280
703
|
missingOwnerCheck,
|
|
281
704
|
insecureAccountClosure,
|
|
705
|
+
initIfNeededReinit,
|
|
282
706
|
missingRentExemptCheck
|
|
283
707
|
];
|
|
708
|
+
var experimentalRules = [duplicateMutableAccounts, unboundTokenMint];
|
|
284
709
|
// ../packages/core/src/scanner.ts
|
|
285
710
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
286
711
|
import { join } from "node:path";
|
|
@@ -300,16 +725,36 @@ function scanFile(filePath, rules) {
|
|
|
300
725
|
const content = readFileSync(filePath, "utf8");
|
|
301
726
|
return scanContent(content, filePath, rules);
|
|
302
727
|
}
|
|
303
|
-
|
|
728
|
+
var EXCLUDED_DIR_NAMES = new Set([
|
|
729
|
+
"tests",
|
|
730
|
+
"test",
|
|
731
|
+
"test-utils",
|
|
732
|
+
"test_utils",
|
|
733
|
+
"testing",
|
|
734
|
+
"fuzz",
|
|
735
|
+
"fixtures",
|
|
736
|
+
"fixture",
|
|
737
|
+
"benches",
|
|
738
|
+
"benchmarks",
|
|
739
|
+
"examples",
|
|
740
|
+
"example"
|
|
741
|
+
]);
|
|
742
|
+
function walk(dirPath, files = [], includeTests = false, rootPath) {
|
|
743
|
+
const root = rootPath ?? dirPath;
|
|
304
744
|
const entries = readdirSync(dirPath);
|
|
305
745
|
for (const entry of entries) {
|
|
306
746
|
if (entry === "node_modules" || entry === "target" || entry === ".git")
|
|
307
747
|
continue;
|
|
748
|
+
if (!includeTests && EXCLUDED_DIR_NAMES.has(entry.toLowerCase()))
|
|
749
|
+
continue;
|
|
308
750
|
const fullPath = join(dirPath, entry);
|
|
309
751
|
const stat = statSync(fullPath);
|
|
310
752
|
if (stat.isDirectory()) {
|
|
311
|
-
walk(fullPath, files);
|
|
753
|
+
walk(fullPath, files, includeTests, root);
|
|
312
754
|
} else if (stat.isFile() && fullPath.endsWith(".rs")) {
|
|
755
|
+
const rel = fullPath.slice(root.length).replace(/^[\/\\]/, "");
|
|
756
|
+
if (!includeTests && isTestLikePath(rel))
|
|
757
|
+
continue;
|
|
313
758
|
files.push(fullPath);
|
|
314
759
|
}
|
|
315
760
|
}
|
|
@@ -357,9 +802,9 @@ function sortAndSummarize(rsFileCount, allFindings) {
|
|
|
357
802
|
summary: tallySummary(allFindings)
|
|
358
803
|
};
|
|
359
804
|
}
|
|
360
|
-
async function scanDirectory(targetPath, rules) {
|
|
805
|
+
async function scanDirectory(targetPath, rules, opts = {}) {
|
|
361
806
|
const stat = statSync(targetPath);
|
|
362
|
-
const rsFiles = stat.isDirectory() ? walk(targetPath) : targetPath.endsWith(".rs") ? [targetPath] : [];
|
|
807
|
+
const rsFiles = stat.isDirectory() ? walk(targetPath, [], opts.includeTests ?? false) : targetPath.endsWith(".rs") ? [targetPath] : [];
|
|
363
808
|
const allFindings = [];
|
|
364
809
|
for (const file of rsFiles) {
|
|
365
810
|
allFindings.push(...scanFile(file, rules));
|
|
@@ -398,7 +843,7 @@ function badge(severity) {
|
|
|
398
843
|
var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
|
|
399
844
|
function printReport(result) {
|
|
400
845
|
const timestamp = new Date().toISOString();
|
|
401
|
-
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.
|
|
846
|
+
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.1 — static analysis for Anchor/Solana programs${RESET}`);
|
|
402
847
|
console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
|
|
403
848
|
console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
|
|
404
849
|
if (result.findings.length === 0) {
|
|
@@ -433,7 +878,7 @@ function printReport(result) {
|
|
|
433
878
|
}
|
|
434
879
|
// ../packages/core/src/output/sarif.ts
|
|
435
880
|
var SARIF_VERSION = "2.1.0";
|
|
436
|
-
var TOOL_VERSION = "0.
|
|
881
|
+
var TOOL_VERSION = "0.3.0";
|
|
437
882
|
function severityToSarifLevel(severity) {
|
|
438
883
|
switch (severity) {
|
|
439
884
|
case "CRITICAL":
|
|
@@ -510,6 +955,10 @@ Options:
|
|
|
510
955
|
--sarif Print scan results as SARIF 2.1.0 JSON (for GitHub code scanning)
|
|
511
956
|
--min-severity <level> Only report findings at or above this severity
|
|
512
957
|
(CRITICAL | HIGH | MEDIUM | LOW | INFO)
|
|
958
|
+
--experimental Also run experimental rules (e.g. SOL-012 duplicate
|
|
959
|
+
mutable accounts). Higher signal, more false positives.
|
|
960
|
+
--include-tests Also scan test/fuzz/fixture/example code (skipped by
|
|
961
|
+
default; findings there are rarely actionable)
|
|
513
962
|
--help Show this help message
|
|
514
963
|
|
|
515
964
|
Examples:
|
|
@@ -530,6 +979,8 @@ async function main() {
|
|
|
530
979
|
}
|
|
531
980
|
const wantsJson = args.includes("--json");
|
|
532
981
|
const wantsSarif = args.includes("--sarif");
|
|
982
|
+
const wantsExperimental = args.includes("--experimental");
|
|
983
|
+
const includeTests = args.includes("--include-tests");
|
|
533
984
|
let minSeverity = "INFO";
|
|
534
985
|
const minSeverityIdx = args.indexOf("--min-severity");
|
|
535
986
|
if (minSeverityIdx !== -1) {
|
|
@@ -549,7 +1000,8 @@ async function main() {
|
|
|
549
1000
|
return true;
|
|
550
1001
|
});
|
|
551
1002
|
const targetPath = positional[0] || ".";
|
|
552
|
-
const
|
|
1003
|
+
const activeRules = wantsExperimental ? [...rules, ...experimentalRules] : rules;
|
|
1004
|
+
const result = await scanDirectory(targetPath, activeRules, { includeTests });
|
|
553
1005
|
result.findings = result.findings.filter((finding) => SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[minSeverity]);
|
|
554
1006
|
if (wantsSarif) {
|
|
555
1007
|
console.log(JSON.stringify(toSarif(result), null, 2));
|