solaudit 0.2.0 → 0.3.0
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 +11 -4
- package/dist/cli.js +84 -3
- 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,7 @@ 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
48
|
| `--help` | Show usage |
|
|
48
49
|
|
|
49
50
|
### Example
|
|
@@ -53,7 +54,7 @@ $ solaudit ./examples
|
|
|
53
54
|
```
|
|
54
55
|
|
|
55
56
|
```
|
|
56
|
-
SolAudit v0.
|
|
57
|
+
SolAudit v0.3.0 — static analysis for Anchor/Solana programs
|
|
57
58
|
Scanned 1 file(s) at 2026-08-27T06:00:00.000Z
|
|
58
59
|
──────────────────────────────────────────────────────────────────────
|
|
59
60
|
|
|
@@ -103,21 +104,23 @@ bun run dev
|
|
|
103
104
|
| `SOL-007` | MEDIUM | Insecure Account Initialization | `#[account(init, ...)]` constraints missing `payer` and/or `space` |
|
|
104
105
|
| `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo` fields used without a nearby `.owner` comparison |
|
|
105
106
|
| `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
|
+
| `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
108
|
| `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
109
|
|
|
108
110
|
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
111
|
|
|
110
112
|
## Accuracy
|
|
111
113
|
|
|
112
|
-
On the committed labeled suite in [`benchmark/`](./benchmark), the deterministic engine scores **
|
|
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.
|
|
113
115
|
|
|
114
|
-
|
|
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.
|
|
115
117
|
|
|
116
118
|
## Output formats (CLI)
|
|
117
119
|
|
|
118
120
|
- **Human** (default) — colorized terminal report grouped by severity.
|
|
119
121
|
- **JSON** (`--json`) — the full `ScanResult` object (`scannedFiles`, `findings[]`, `summary`).
|
|
120
122
|
- **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.
|
|
121
124
|
|
|
122
125
|
## GitHub Actions
|
|
123
126
|
|
|
@@ -179,6 +182,10 @@ Contributions are very welcome — new rules, false-positive fixes, additional o
|
|
|
179
182
|
|
|
180
183
|
Please open an issue first for larger changes so we can discuss the approach.
|
|
181
184
|
|
|
185
|
+
## Support
|
|
186
|
+
|
|
187
|
+
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)
|
|
188
|
+
|
|
182
189
|
## License
|
|
183
190
|
|
|
184
191
|
[MIT](./LICENSE) © 2026 Cody Pack
|
package/dist/cli.js
CHANGED
|
@@ -243,6 +243,81 @@ var insecureAccountClosure = {
|
|
|
243
243
|
return findings;
|
|
244
244
|
}
|
|
245
245
|
};
|
|
246
|
+
var initIfNeededReinit = {
|
|
247
|
+
id: "SOL-010",
|
|
248
|
+
name: "Reinitialization Risk via init_if_needed",
|
|
249
|
+
severity: "MEDIUM",
|
|
250
|
+
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.",
|
|
251
|
+
check(fileContent, filePath) {
|
|
252
|
+
const findings = [];
|
|
253
|
+
const blockRegex = /#\[account\(([\s\S]*?)\)\]/g;
|
|
254
|
+
let match;
|
|
255
|
+
while ((match = blockRegex.exec(fileContent)) !== null) {
|
|
256
|
+
const block = match[1];
|
|
257
|
+
if (!/\binit_if_needed\b/.test(block))
|
|
258
|
+
continue;
|
|
259
|
+
const line = fileContent.slice(0, match.index).split(/\r\n|\r|\n/).length;
|
|
260
|
+
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."));
|
|
261
|
+
}
|
|
262
|
+
return findings;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var duplicateMutableAccounts = {
|
|
266
|
+
id: "SOL-012",
|
|
267
|
+
name: "Duplicate Mutable Accounts (experimental)",
|
|
268
|
+
severity: "HIGH",
|
|
269
|
+
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.",
|
|
270
|
+
check(fileContent, filePath) {
|
|
271
|
+
const findings = [];
|
|
272
|
+
const lines = splitLines(fileContent);
|
|
273
|
+
const structStart = /#\[derive\(\s*Accounts\s*\)\][\s\S]*?pub struct\s+\w+[^\{]*\{/g;
|
|
274
|
+
let sm;
|
|
275
|
+
while ((sm = structStart.exec(fileContent)) !== null) {
|
|
276
|
+
let depth = 1;
|
|
277
|
+
let i = sm.index + sm[0].length;
|
|
278
|
+
while (i < fileContent.length && depth > 0) {
|
|
279
|
+
if (fileContent[i] === "{")
|
|
280
|
+
depth++;
|
|
281
|
+
else if (fileContent[i] === "}")
|
|
282
|
+
depth--;
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
const body = fileContent.slice(sm.index, i);
|
|
286
|
+
const bodyNoComments = stripLineComments(body);
|
|
287
|
+
const fields = [];
|
|
288
|
+
const fieldRegex = /((?:#\[[\s\S]*?\]\s*)*)pub\s+(\w+)\s*:\s*([\w:]+)\s*<\s*'info\s*(?:,\s*([\w:]+)\s*)?>/g;
|
|
289
|
+
let fm;
|
|
290
|
+
while ((fm = fieldRegex.exec(body)) !== null) {
|
|
291
|
+
const attrs = fm[1] || "";
|
|
292
|
+
const isMut = /#\[account\([\s\S]*?\bmut\b[\s\S]*?\)\]/.test(attrs);
|
|
293
|
+
const typeName = fm[4] || fm[3];
|
|
294
|
+
fields.push({ name: fm[2], typeName, isMut, offset: sm.index + fm.index });
|
|
295
|
+
}
|
|
296
|
+
const mutByType = new Map;
|
|
297
|
+
for (const f of fields) {
|
|
298
|
+
if (!f.isMut)
|
|
299
|
+
continue;
|
|
300
|
+
const group = mutByType.get(f.typeName) || [];
|
|
301
|
+
group.push(f);
|
|
302
|
+
mutByType.set(f.typeName, group);
|
|
303
|
+
}
|
|
304
|
+
for (const group of mutByType.values()) {
|
|
305
|
+
if (group.length < 2)
|
|
306
|
+
continue;
|
|
307
|
+
const first = group[0];
|
|
308
|
+
for (let g = 1;g < group.length; g++) {
|
|
309
|
+
const other = group[g];
|
|
310
|
+
const distinct = new RegExp(`${first.name}\\.key\\(\\)\\s*!=\\s*${other.name}\\.key\\(\\)|` + `${other.name}\\.key\\(\\)\\s*!=\\s*${first.name}\\.key\\(\\)`);
|
|
311
|
+
if (distinct.test(bodyNoComments))
|
|
312
|
+
continue;
|
|
313
|
+
const line = fileContent.slice(0, other.offset).split(/\r\n|\r|\n/).length;
|
|
314
|
+
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.`));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return findings;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
246
321
|
var missingRentExemptCheck = {
|
|
247
322
|
id: "SOL-011",
|
|
248
323
|
name: "Missing Rent-Exemption Check on Partial Withdrawal",
|
|
@@ -279,8 +354,10 @@ var rules = [
|
|
|
279
354
|
insecureInit,
|
|
280
355
|
missingOwnerCheck,
|
|
281
356
|
insecureAccountClosure,
|
|
357
|
+
initIfNeededReinit,
|
|
282
358
|
missingRentExemptCheck
|
|
283
359
|
];
|
|
360
|
+
var experimentalRules = [duplicateMutableAccounts];
|
|
284
361
|
// ../packages/core/src/scanner.ts
|
|
285
362
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
286
363
|
import { join } from "node:path";
|
|
@@ -398,7 +475,7 @@ function badge(severity) {
|
|
|
398
475
|
var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
|
|
399
476
|
function printReport(result) {
|
|
400
477
|
const timestamp = new Date().toISOString();
|
|
401
|
-
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.
|
|
478
|
+
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.0 — static analysis for Anchor/Solana programs${RESET}`);
|
|
402
479
|
console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
|
|
403
480
|
console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
|
|
404
481
|
if (result.findings.length === 0) {
|
|
@@ -433,7 +510,7 @@ function printReport(result) {
|
|
|
433
510
|
}
|
|
434
511
|
// ../packages/core/src/output/sarif.ts
|
|
435
512
|
var SARIF_VERSION = "2.1.0";
|
|
436
|
-
var TOOL_VERSION = "0.
|
|
513
|
+
var TOOL_VERSION = "0.3.0";
|
|
437
514
|
function severityToSarifLevel(severity) {
|
|
438
515
|
switch (severity) {
|
|
439
516
|
case "CRITICAL":
|
|
@@ -510,6 +587,8 @@ Options:
|
|
|
510
587
|
--sarif Print scan results as SARIF 2.1.0 JSON (for GitHub code scanning)
|
|
511
588
|
--min-severity <level> Only report findings at or above this severity
|
|
512
589
|
(CRITICAL | HIGH | MEDIUM | LOW | INFO)
|
|
590
|
+
--experimental Also run experimental rules (e.g. SOL-012 duplicate
|
|
591
|
+
mutable accounts). Higher signal, more false positives.
|
|
513
592
|
--help Show this help message
|
|
514
593
|
|
|
515
594
|
Examples:
|
|
@@ -530,6 +609,7 @@ async function main() {
|
|
|
530
609
|
}
|
|
531
610
|
const wantsJson = args.includes("--json");
|
|
532
611
|
const wantsSarif = args.includes("--sarif");
|
|
612
|
+
const wantsExperimental = args.includes("--experimental");
|
|
533
613
|
let minSeverity = "INFO";
|
|
534
614
|
const minSeverityIdx = args.indexOf("--min-severity");
|
|
535
615
|
if (minSeverityIdx !== -1) {
|
|
@@ -549,7 +629,8 @@ async function main() {
|
|
|
549
629
|
return true;
|
|
550
630
|
});
|
|
551
631
|
const targetPath = positional[0] || ".";
|
|
552
|
-
const
|
|
632
|
+
const activeRules = wantsExperimental ? [...rules, ...experimentalRules] : rules;
|
|
633
|
+
const result = await scanDirectory(targetPath, activeRules);
|
|
553
634
|
result.findings = result.findings.filter((finding) => SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[minSeverity]);
|
|
554
635
|
if (wantsSarif) {
|
|
555
636
|
console.log(JSON.stringify(toSarif(result), null, 2));
|