solaudit 0.3.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.
Files changed (3) hide show
  1. package/README.md +21 -10
  2. package/dist/cli.js +406 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,7 +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 (currently `SOL-012` Duplicate Mutable Accounts); higher signal, more false positives |
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) |
48
49
  | `--help` | Show usage |
49
50
 
50
51
  ### Example
@@ -54,7 +55,7 @@ $ solaudit ./examples
54
55
  ```
55
56
 
56
57
  ```
57
- SolAudit v0.3.0 — static analysis for Anchor/Solana programs
58
+ SolAudit v0.3.1 — static analysis for Anchor/Solana programs
58
59
  Scanned 1 file(s) at 2026-08-27T06:00:00.000Z
59
60
  ──────────────────────────────────────────────────────────────────────
60
61
 
@@ -95,14 +96,14 @@ bun run dev
95
96
 
96
97
  | ID | Severity | Name | What it catches |
97
98
  |---|---|---|---|
98
- | `SOL-001` | CRITICAL | Missing Signer Check | `transfer`/`mint_to` calls made without a nearby `Signer<'info>` type or `.is_signer` check |
99
- | `SOL-002` | HIGH | Unchecked Account Deserialization | `Account::try_from` / `AccountLoader::try_from` used without a validating `#[account(...)]` constraint |
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) |
100
101
  | `SOL-003` | HIGH | Arbitrary CPI | `invoke`/`invoke_signed` targeting a program ID sourced from a caller-supplied account instead of a verified constant |
101
- | `SOL-004` | HIGH | Missing Bump Seed Canonicalization | `create_program_address` used directly, or `find_program_address`'s bump discarded instead of stored/validated |
102
- | `SOL-005` | MEDIUM | Integer Overflow Risk | Raw `+`/`-`/`*` arithmetic on balance-like fields instead of `checked_*`/`saturating_*` |
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_*` |
103
104
  | `SOL-006` | HIGH | Type Cosplay / Missing Discriminator Check | Manual `try_from_slice` deserialization with no discriminator validation or `Account<'info, T>` wrapper |
104
105
  | `SOL-007` | MEDIUM | Insecure Account Initialization | `#[account(init, ...)]` constraints missing `payer` and/or `space` |
105
- | `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo` fields used without a nearby `.owner` comparison |
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 |
106
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 |
107
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 |
108
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 |
@@ -111,16 +112,26 @@ Each rule is implemented as real regex/line-scanning logic in [`packages/core/sr
111
112
 
112
113
  ## Accuracy
113
114
 
114
- On the committed labeled suite in [`benchmark/`](./benchmark), the deterministic engine scores **94.1% precision, 100% recall, 97.0% F1** on rule-covered categories.
115
+ On the committed labeled suite in [`benchmark/`](./benchmark), the deterministic engine scores **100% precision, 100% recall, 100% F1** on rule-covered categories.
115
116
 
116
- **Duplicate mutable accounts** is covered by the experimental `SOL-012` rule, 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.
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.
117
128
 
118
129
  ## Output formats (CLI)
119
130
 
120
131
  - **Human** (default) — colorized terminal report grouped by severity.
121
132
  - **JSON** (`--json`) — the full `ScanResult` object (`scannedFiles`, `findings[]`, `summary`).
122
133
  - **SARIF** (`--sarif`) — [SARIF 2.1.0](https://sarifweb.azurewebsites.net/) output compatible with GitHub Code Scanning.
123
- - **Experimental rules** (`--experimental`) — opt into noisier, higher-signal heuristics (currently `SOL-012` Duplicate Mutable Accounts). Recommended for deep manual reviews rather than CI gates.
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.
124
135
 
125
136
  ## GitHub Actions
126
137
 
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 without an is_signer / Signer<'info> check within the preceding 10 lines. Add a `Signer<'info>` account type or an explicit `require!(ctx.accounts.authority.is_signer, ...)` check before authorizing fund movement.",
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
- lines.forEach((line, idx) => {
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 (!hasSignerEvidence) {
41
- findings.push(makeFinding(missingSignerCheck, filePath, idx + 1, line, "Verify the authority/owner account is a Signer or explicitly check `.is_signer` before transferring or minting tokens."));
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;
42
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;
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 prevLine = idx > 0 ? lines[idx - 1] : "";
60
- const hasConstraint = /#\[account\(/.test(prevLine) || /#\[account\(/.test(line);
61
- if (!hasConstraint) {
62
- 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."));
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` and the canonical bump stored/validated on-chain. Calling `create_program_address` directly, or discarding the bump returned by `find_program_address`, allows bump-seed forgery attacks.",
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 (/create_program_address\s*\(/.test(line) && !/find_program_address/.test(line)) {
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
- const fpaMatch = line.match(/let\s+(\([^)]*\)|\w+)\s*=\s*[\w:]*find_program_address\s*\(/);
106
- if (fpaMatch) {
107
- const binding = fpaMatch[1];
108
- const bindsBump = /\(\s*\w+\s*,\s*\w+\s*\)/.test(binding);
109
- if (!bindsBump) {
110
- findings.push(makeFinding(missingBumpCanonicalization, filePath, idx + 1, line, "Destructure and store the bump returned by `find_program_address` (e.g. `let (pda, bump) = ...`) and persist/validate it instead of discarding it."));
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 performed on u64/u128-typed values without using 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.",
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
- const arithmeticPattern = /\b(\w*(amount|balance|supply|total|shares|stake)\w*)\s*(\+|\-|\*)\s*\w/i;
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 (arithmeticPattern.test(line)) {
133
- findings.push(makeFinding(integerOverflowRisk, filePath, idx + 1, line, "Replace raw arithmetic with `.checked_add()/.checked_sub()/.checked_mul()` (propagating errors via `ok_or(ErrorCode::MathOverflow)?`) or `saturating_*` equivalents."));
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
- if (!hasPayer || !hasSpace) {
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 is accessed via `ctx.accounts.` without a nearby `.owner` comparison. Raw `AccountInfo` accounts bypass Anchor's automatic ownership validation, so the program must manually verify `account.owner == expected_program_id` before trusting its data.",
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
  }
@@ -344,6 +668,30 @@ var missingRentExemptCheck = {
344
668
  return findings;
345
669
  }
346
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
+ };
347
695
  var rules = [
348
696
  missingSignerCheck,
349
697
  uncheckedDeserialization,
@@ -357,7 +705,7 @@ var rules = [
357
705
  initIfNeededReinit,
358
706
  missingRentExemptCheck
359
707
  ];
360
- var experimentalRules = [duplicateMutableAccounts];
708
+ var experimentalRules = [duplicateMutableAccounts, unboundTokenMint];
361
709
  // ../packages/core/src/scanner.ts
362
710
  import { readFileSync, readdirSync, statSync } from "node:fs";
363
711
  import { join } from "node:path";
@@ -377,16 +725,36 @@ function scanFile(filePath, rules) {
377
725
  const content = readFileSync(filePath, "utf8");
378
726
  return scanContent(content, filePath, rules);
379
727
  }
380
- function walk(dirPath, files = []) {
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;
381
744
  const entries = readdirSync(dirPath);
382
745
  for (const entry of entries) {
383
746
  if (entry === "node_modules" || entry === "target" || entry === ".git")
384
747
  continue;
748
+ if (!includeTests && EXCLUDED_DIR_NAMES.has(entry.toLowerCase()))
749
+ continue;
385
750
  const fullPath = join(dirPath, entry);
386
751
  const stat = statSync(fullPath);
387
752
  if (stat.isDirectory()) {
388
- walk(fullPath, files);
753
+ walk(fullPath, files, includeTests, root);
389
754
  } else if (stat.isFile() && fullPath.endsWith(".rs")) {
755
+ const rel = fullPath.slice(root.length).replace(/^[\/\\]/, "");
756
+ if (!includeTests && isTestLikePath(rel))
757
+ continue;
390
758
  files.push(fullPath);
391
759
  }
392
760
  }
@@ -434,9 +802,9 @@ function sortAndSummarize(rsFileCount, allFindings) {
434
802
  summary: tallySummary(allFindings)
435
803
  };
436
804
  }
437
- async function scanDirectory(targetPath, rules) {
805
+ async function scanDirectory(targetPath, rules, opts = {}) {
438
806
  const stat = statSync(targetPath);
439
- const rsFiles = stat.isDirectory() ? walk(targetPath) : targetPath.endsWith(".rs") ? [targetPath] : [];
807
+ const rsFiles = stat.isDirectory() ? walk(targetPath, [], opts.includeTests ?? false) : targetPath.endsWith(".rs") ? [targetPath] : [];
440
808
  const allFindings = [];
441
809
  for (const file of rsFiles) {
442
810
  allFindings.push(...scanFile(file, rules));
@@ -475,7 +843,7 @@ function badge(severity) {
475
843
  var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
476
844
  function printReport(result) {
477
845
  const timestamp = new Date().toISOString();
478
- console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.0 — static analysis for Anchor/Solana programs${RESET}`);
846
+ console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.1 — static analysis for Anchor/Solana programs${RESET}`);
479
847
  console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
480
848
  console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
481
849
  if (result.findings.length === 0) {
@@ -589,6 +957,8 @@ Options:
589
957
  (CRITICAL | HIGH | MEDIUM | LOW | INFO)
590
958
  --experimental Also run experimental rules (e.g. SOL-012 duplicate
591
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)
592
962
  --help Show this help message
593
963
 
594
964
  Examples:
@@ -610,6 +980,7 @@ async function main() {
610
980
  const wantsJson = args.includes("--json");
611
981
  const wantsSarif = args.includes("--sarif");
612
982
  const wantsExperimental = args.includes("--experimental");
983
+ const includeTests = args.includes("--include-tests");
613
984
  let minSeverity = "INFO";
614
985
  const minSeverityIdx = args.indexOf("--min-severity");
615
986
  if (minSeverityIdx !== -1) {
@@ -630,7 +1001,7 @@ async function main() {
630
1001
  });
631
1002
  const targetPath = positional[0] || ".";
632
1003
  const activeRules = wantsExperimental ? [...rules, ...experimentalRules] : rules;
633
- const result = await scanDirectory(targetPath, activeRules);
1004
+ const result = await scanDirectory(targetPath, activeRules, { includeTests });
634
1005
  result.findings = result.findings.filter((finding) => SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[minSeverity]);
635
1006
  if (wantsSarif) {
636
1007
  console.log(JSON.stringify(toSarif(result), null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solaudit",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Open-source static analysis CLI for Anchor/Solana programs",
5
5
  "type": "module",
6
6
  "bin": {