solaudit 0.1.2 → 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 +85 -70
- package/dist/cli.js +162 -23
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
# SolAudit
|
|
2
2
|
|
|
3
3
|
[](./LICENSE)
|
|
4
|
-
[](https://www.npmjs.com/package/solaudit)
|
|
5
5
|
[](./.github/workflows/ci.yml)
|
|
6
6
|
|
|
7
|
-
**A zero-dependency
|
|
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.**
|
|
8
8
|
|
|
9
|
-
SolAudit reads
|
|
9
|
+
SolAudit reads `.rs` source files, applies a set of pattern-matching security rules (missing signer checks, arbitrary CPIs, unchecked deserialization, integer overflow, and more), and gives you an actionable report in seconds — no RPC calls, no wallet, no build step required to run it.
|
|
10
|
+
|
|
11
|
+
- **CLI** — `npx solaudit ./program`, for local development and CI.
|
|
12
|
+
- **Web app** — [solaudit-web.vercel.app](https://solaudit-web.vercel.app/) — paste a public GitHub repo URL or upload a single `.rs` file, no install required.
|
|
10
13
|
|
|
11
14
|
---
|
|
12
15
|
|
|
13
|
-
##
|
|
16
|
+
## Repository layout
|
|
17
|
+
|
|
18
|
+
This is a Bun workspaces monorepo. The CLI and the web app share one scanning engine, so there is a single source of truth for every rule — no drift between the two.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
packages/core/ Shared engine: rules, scanner, output formatters (published nowhere; workspace-only)
|
|
22
|
+
cli/ npm package "solaudit" — the command-line tool
|
|
23
|
+
web/ Next.js app deployed to Vercel — the web version
|
|
24
|
+
examples/ Intentionally vulnerable sample Anchor program, used by both the CLI and web tests
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CLI
|
|
14
28
|
|
|
15
29
|
```bash
|
|
16
30
|
# Run instantly with npx (no install)
|
|
@@ -19,15 +33,8 @@ npx solaudit ./program
|
|
|
19
33
|
# Or install globally with Bun
|
|
20
34
|
bun add -g solaudit
|
|
21
35
|
solaudit ./program
|
|
22
|
-
|
|
23
|
-
# Or clone and run from source
|
|
24
|
-
git clone https://github.com/blueeyez425-art/solaudit.git
|
|
25
|
-
cd solaudit
|
|
26
|
-
bun run solaudit ./examples
|
|
27
36
|
```
|
|
28
37
|
|
|
29
|
-
## Usage
|
|
30
|
-
|
|
31
38
|
```bash
|
|
32
39
|
solaudit [path] [options]
|
|
33
40
|
```
|
|
@@ -37,6 +44,7 @@ solaudit [path] [options]
|
|
|
37
44
|
| `--json` | Print raw scan results as JSON |
|
|
38
45
|
| `--sarif` | Print results as SARIF 2.1.0 (for GitHub Code Scanning) |
|
|
39
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 |
|
|
40
48
|
| `--help` | Show usage |
|
|
41
49
|
|
|
42
50
|
### Example
|
|
@@ -46,8 +54,8 @@ $ solaudit ./examples
|
|
|
46
54
|
```
|
|
47
55
|
|
|
48
56
|
```
|
|
49
|
-
SolAudit v0.
|
|
50
|
-
Scanned 1 file(s) at 2026-08-
|
|
57
|
+
SolAudit v0.3.0 — static analysis for Anchor/Solana programs
|
|
58
|
+
Scanned 1 file(s) at 2026-08-27T06:00:00.000Z
|
|
51
59
|
──────────────────────────────────────────────────────────────────────
|
|
52
60
|
|
|
53
61
|
CRITICAL (2)
|
|
@@ -57,26 +65,6 @@ CRITICAL (2)
|
|
|
57
65
|
└─ Verify the authority/owner account is a Signer or explicitly
|
|
58
66
|
check `.is_signer` before transferring or minting tokens.
|
|
59
67
|
|
|
60
|
-
HIGH (3)
|
|
61
|
-
HIGH SOL-003 Arbitrary CPI (Cross-Program Invocation)
|
|
62
|
-
examples/vulnerable.rs:68
|
|
63
|
-
│ invoke(&ix, &[ctx.accounts.target_program.to_account_info()])?;
|
|
64
|
-
└─ Validate the target program ID against a known constant or type
|
|
65
|
-
the account as `Program<'info, T>` before invoking it.
|
|
66
|
-
|
|
67
|
-
HIGH SOL-004 Missing Bump Seed Canonicalization
|
|
68
|
-
examples/vulnerable.rs:76
|
|
69
|
-
│ let signer_pda = Pubkey::create_program_address(seeds, ctx.program_id)
|
|
70
|
-
└─ Use `Pubkey::find_program_address` to derive the canonical bump
|
|
71
|
-
instead of calling `create_program_address` directly.
|
|
72
|
-
|
|
73
|
-
MEDIUM (3)
|
|
74
|
-
MEDIUM SOL-005 Integer Overflow Risk
|
|
75
|
-
examples/vulnerable.rs:27
|
|
76
|
-
│ vault.total_staked = vault.total_staked - amount;
|
|
77
|
-
└─ Replace raw arithmetic with `.checked_sub()` / `.checked_add()`
|
|
78
|
-
or `saturating_*` equivalents.
|
|
79
|
-
|
|
80
68
|
──────────────────────────────────────────────────────────────────────
|
|
81
69
|
Summary
|
|
82
70
|
CRITICAL 2 HIGH 3 MEDIUM 3 LOW 6 INFO 0
|
|
@@ -85,6 +73,24 @@ Summary
|
|
|
85
73
|
|
|
86
74
|
Exit code is `1` when any `CRITICAL` or `HIGH` finding is present — making SolAudit a natural CI gate.
|
|
87
75
|
|
|
76
|
+
## Web app
|
|
77
|
+
|
|
78
|
+
**Live at [solaudit-web.vercel.app](https://solaudit-web.vercel.app/)** — no CLI, no local Anchor toolchain, no wallet required:
|
|
79
|
+
|
|
80
|
+
1. Paste a public GitHub repo URL **or** upload a single `.rs` file.
|
|
81
|
+
2. The server fetches `.rs` files via GitHub's API (no `git clone`) and scans them with the exact same engine as the CLI.
|
|
82
|
+
3. Get a report grouped by severity, right in the browser.
|
|
83
|
+
|
|
84
|
+
Optional: set `GITHUB_TOKEN` (see `web/.env.example`) to raise the GitHub API rate limit from 60 to 5,000 requests/hour. No special token scopes are needed for public repos.
|
|
85
|
+
|
|
86
|
+
### Running the web app locally
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
bun install # from the repo root — links the workspace packages
|
|
90
|
+
cd web
|
|
91
|
+
bun run dev
|
|
92
|
+
```
|
|
93
|
+
|
|
88
94
|
## Rules
|
|
89
95
|
|
|
90
96
|
| ID | Severity | Name | What it catches |
|
|
@@ -97,79 +103,88 @@ Exit code is `1` when any `CRITICAL` or `HIGH` finding is present — making Sol
|
|
|
97
103
|
| `SOL-006` | HIGH | Type Cosplay / Missing Discriminator Check | Manual `try_from_slice` deserialization with no discriminator validation or `Account<'info, T>` wrapper |
|
|
98
104
|
| `SOL-007` | MEDIUM | Insecure Account Initialization | `#[account(init, ...)]` constraints missing `payer` and/or `space` |
|
|
99
105
|
| `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo` fields used without a nearby `.owner` comparison |
|
|
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 |
|
|
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 |
|
|
100
109
|
|
|
101
|
-
Each rule is implemented as real regex/line-scanning logic in [`src/rules/index.ts`](./src/rules/index.ts) — see that file (and its accompanying [tests](./src/rules/index.test.ts)) for the exact matching heuristics and false-positive guards.
|
|
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.
|
|
102
111
|
|
|
103
|
-
##
|
|
112
|
+
## Accuracy
|
|
104
113
|
|
|
105
|
-
|
|
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.
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
- **JSON** (`--json`) — the full `ScanResult` object (`scannedFiles`, `findings[]`, `summary`), for piping into other tooling.
|
|
109
|
-
- **SARIF** (`--sarif`) — [SARIF 2.1.0](https://sarifweb.azurewebsites.net/) output compatible with GitHub Code Scanning, so findings show up as annotations directly on your pull requests.
|
|
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.
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
## Output formats (CLI)
|
|
119
|
+
|
|
120
|
+
- **Human** (default) — colorized terminal report grouped by severity.
|
|
121
|
+
- **JSON** (`--json`) — the full `ScanResult` object (`scannedFiles`, `findings[]`, `summary`).
|
|
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.
|
|
114
124
|
|
|
115
125
|
## GitHub Actions
|
|
116
126
|
|
|
117
|
-
|
|
127
|
+
SolAudit ships as a ready-to-use [GitHub Action](./action.yml). Drop this into `.github/workflows/solaudit.yml` in your own repo and every push/PR gets scanned automatically, with results uploaded straight to your repo's Security tab:
|
|
118
128
|
|
|
119
129
|
```yaml
|
|
120
130
|
name: SolAudit
|
|
121
131
|
|
|
122
132
|
on: [push, pull_request]
|
|
123
133
|
|
|
134
|
+
permissions:
|
|
135
|
+
contents: read
|
|
136
|
+
security-events: write # required to upload SARIF results
|
|
137
|
+
|
|
124
138
|
jobs:
|
|
125
139
|
scan:
|
|
126
140
|
runs-on: ubuntu-latest
|
|
127
141
|
steps:
|
|
128
142
|
- uses: actions/checkout@v4
|
|
129
|
-
- uses:
|
|
143
|
+
- uses: blueeyez425-art/solaudit@main
|
|
130
144
|
with:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
with:
|
|
136
|
-
sarif_file: results.sarif
|
|
145
|
+
path: ./programs # file or directory to scan (default: .)
|
|
146
|
+
min-severity: INFO # lowest severity to report (default: INFO)
|
|
147
|
+
fail-on-findings: true # fail the build on any CRITICAL/HIGH finding (default: true)
|
|
148
|
+
upload-sarif: true # upload results to code scanning (default: true)
|
|
137
149
|
```
|
|
138
150
|
|
|
139
|
-
|
|
151
|
+
No `security-events: write` permission in your workflow? Set `upload-sarif: false` and the action will still run the scan and fail the build on CRITICAL/HIGH findings — it just won't publish to the Security tab.
|
|
140
152
|
|
|
141
|
-
|
|
153
|
+
Prefer to wire it up manually instead of using the packaged action:
|
|
142
154
|
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
output/sarif.ts # SARIF 2.1.0 conversion
|
|
150
|
-
cli.ts # Argument parsing + entrypoint
|
|
151
|
-
examples/vulnerable.rs # Intentionally vulnerable sample Anchor program
|
|
155
|
+
```yaml
|
|
156
|
+
- uses: actions/checkout@v4
|
|
157
|
+
- run: npx solaudit ./programs --sarif > results.sarif
|
|
158
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
159
|
+
with:
|
|
160
|
+
sarif_file: results.sarif
|
|
152
161
|
```
|
|
153
162
|
|
|
163
|
+
This repository's own [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) runs the full test suite, builds the CLI and web app, and uses this repo's own action against `examples/` on every push.
|
|
164
|
+
|
|
154
165
|
## Development
|
|
155
166
|
|
|
156
167
|
```bash
|
|
157
|
-
bun install
|
|
158
|
-
bun test
|
|
159
|
-
|
|
160
|
-
bun run build
|
|
168
|
+
bun install # installs and links all workspaces
|
|
169
|
+
cd packages/core && bun test # run the engine's unit test suite
|
|
170
|
+
cd cli && bun run build # bundle cli/src/cli.ts -> cli/dist/cli.js
|
|
171
|
+
cd web && bun run build # production-build the web app
|
|
161
172
|
```
|
|
162
173
|
|
|
163
174
|
## Contributing
|
|
164
175
|
|
|
165
|
-
Contributions are very welcome — new rules, false-positive fixes, additional output formats, or
|
|
176
|
+
Contributions are very welcome — new rules, false-positive fixes, additional output formats, or web UI improvements are all in scope.
|
|
166
177
|
|
|
167
178
|
1. Fork the repo and create a branch from `main`.
|
|
168
|
-
2. Add or update a rule in `src/rules/index.ts` (each rule is a self-contained `Rule` object).
|
|
169
|
-
3. Add unit tests covering both a triggering and a non-triggering case (`src/rules/index.test.ts`).
|
|
170
|
-
4. Run `bun test`
|
|
179
|
+
2. Add or update a rule in `packages/core/src/rules/index.ts` (each rule is a self-contained `Rule` object).
|
|
180
|
+
3. Add unit tests covering both a triggering and a non-triggering case (`packages/core/src/rules/index.test.ts`).
|
|
181
|
+
4. Run `bun test` (from `packages/core`) before opening a PR.
|
|
182
|
+
|
|
183
|
+
Please open an issue first for larger changes so we can discuss the approach.
|
|
184
|
+
|
|
185
|
+
## Support
|
|
171
186
|
|
|
172
|
-
|
|
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)
|
|
173
188
|
|
|
174
189
|
## License
|
|
175
190
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/rules/index.ts
|
|
3
|
+
// ../packages/core/src/rules/index.ts
|
|
4
4
|
function makeFinding(rule, filePath, line, snippet, description) {
|
|
5
5
|
return {
|
|
6
6
|
ruleId: rule.id,
|
|
@@ -15,6 +15,10 @@ function makeFinding(rule, filePath, line, snippet, description) {
|
|
|
15
15
|
function splitLines(fileContent) {
|
|
16
16
|
return fileContent.split(/\r\n|\r|\n/);
|
|
17
17
|
}
|
|
18
|
+
function stripLineComments(text) {
|
|
19
|
+
return text.split(/\r\n|\r|\n/).map((line) => line.replace(/\/\/.*$/, "")).join(`
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
18
22
|
var missingSignerCheck = {
|
|
19
23
|
id: "SOL-001",
|
|
20
24
|
name: "Missing Signer Check",
|
|
@@ -215,6 +219,131 @@ var missingOwnerCheck = {
|
|
|
215
219
|
return findings;
|
|
216
220
|
}
|
|
217
221
|
};
|
|
222
|
+
var insecureAccountClosure = {
|
|
223
|
+
id: "SOL-009",
|
|
224
|
+
name: "Insecure Manual Account Closure",
|
|
225
|
+
severity: "HIGH",
|
|
226
|
+
description: "An account's lamports were manually drained to zero without zeroing its data/discriminator or reassigning ownership to the System Program, unlike Anchor's `close = destination` constraint (which does both automatically). A 'closed' account that still carries its original discriminator and data can be revived/reused within the same or a later transaction before garbage collection, letting an attacker re-claim already-withdrawn funds or reopen closed state. Use `#[account(mut, close = destination)]` instead of manual lamport-draining, or manually zero the account's discriminator/data and reassign owner to the System Program.",
|
|
227
|
+
check(fileContent, filePath) {
|
|
228
|
+
const findings = [];
|
|
229
|
+
const lines = splitLines(fileContent);
|
|
230
|
+
const zeroLamportsPattern = /\.lamports\.borrow_mut\(\)\s*=\s*0\s*;/;
|
|
231
|
+
lines.forEach((line, idx) => {
|
|
232
|
+
if (!zeroLamportsPattern.test(line))
|
|
233
|
+
return;
|
|
234
|
+
const windowStart = Math.max(0, idx - 15);
|
|
235
|
+
const windowEnd = Math.min(lines.length, idx + 15);
|
|
236
|
+
const window = stripLineComments(lines.slice(windowStart, windowEnd).join(`
|
|
237
|
+
`));
|
|
238
|
+
const hasSafeClosure = /close\s*=/.test(window) || /discriminator/i.test(window) || /CLOSED_ACCOUNT_DISCRIMINATOR/.test(window) || /\.data\.borrow_mut\(\)/.test(window);
|
|
239
|
+
if (!hasSafeClosure) {
|
|
240
|
+
findings.push(makeFinding(insecureAccountClosure, filePath, idx + 1, line, "Use Anchor's `#[account(mut, close = destination)]` constraint, or manually zero the account's 8-byte discriminator and reassign its owner to the System Program, before treating this account as closed."));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return findings;
|
|
244
|
+
}
|
|
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
|
+
};
|
|
321
|
+
var missingRentExemptCheck = {
|
|
322
|
+
id: "SOL-011",
|
|
323
|
+
name: "Missing Rent-Exemption Check on Partial Withdrawal",
|
|
324
|
+
severity: "MEDIUM",
|
|
325
|
+
description: "Lamports are subtracted from an account's balance (a partial withdrawal) with no nearby check against `Rent::get()?.minimum_balance(...)`. If the withdrawal can drop the account below the rent-exempt threshold, the runtime may purge the account on the next epoch boundary, destroying any remaining state and potentially enabling accounting-drift or re-initialization bugs. Compare the post-withdrawal balance against the account's rent-exempt minimum before allowing a partial (non-zeroing) withdrawal.",
|
|
326
|
+
check(fileContent, filePath) {
|
|
327
|
+
const findings = [];
|
|
328
|
+
const lines = splitLines(fileContent);
|
|
329
|
+
const partialWithdrawPattern = /\.lamports\.borrow_mut\(\)\s*=\s*[\w.]+\.lamports\(\)\.checked_sub\(/;
|
|
330
|
+
lines.forEach((line, idx) => {
|
|
331
|
+
if (!partialWithdrawPattern.test(line))
|
|
332
|
+
return;
|
|
333
|
+
if (/=\s*0\s*;/.test(line))
|
|
334
|
+
return;
|
|
335
|
+
const windowStart = Math.max(0, idx - 15);
|
|
336
|
+
const windowEnd = Math.min(lines.length, idx + 15);
|
|
337
|
+
const window = stripLineComments(lines.slice(windowStart, windowEnd).join(`
|
|
338
|
+
`));
|
|
339
|
+
const hasRentCheck = /Rent::get\(\)|minimum_balance/.test(window);
|
|
340
|
+
if (!hasRentCheck) {
|
|
341
|
+
findings.push(makeFinding(missingRentExemptCheck, filePath, idx + 1, line, "Check the resulting balance against `Rent::get()?.minimum_balance(account.data_len())` before allowing a partial lamport withdrawal, or require the withdrawal to fully close the account via `close = destination`."));
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
return findings;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
218
347
|
var rules = [
|
|
219
348
|
missingSignerCheck,
|
|
220
349
|
uncheckedDeserialization,
|
|
@@ -223,16 +352,18 @@ var rules = [
|
|
|
223
352
|
integerOverflowRisk,
|
|
224
353
|
typeCosplay,
|
|
225
354
|
insecureInit,
|
|
226
|
-
missingOwnerCheck
|
|
355
|
+
missingOwnerCheck,
|
|
356
|
+
insecureAccountClosure,
|
|
357
|
+
initIfNeededReinit,
|
|
358
|
+
missingRentExemptCheck
|
|
227
359
|
];
|
|
228
|
-
|
|
229
|
-
// src/scanner.ts
|
|
360
|
+
var experimentalRules = [duplicateMutableAccounts];
|
|
361
|
+
// ../packages/core/src/scanner.ts
|
|
230
362
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
231
363
|
import { join } from "node:path";
|
|
232
|
-
function
|
|
233
|
-
const content = readFileSync(filePath, "utf8");
|
|
364
|
+
function scanContent(content, filePath, rules) {
|
|
234
365
|
const findings = [];
|
|
235
|
-
for (const rule of
|
|
366
|
+
for (const rule of rules) {
|
|
236
367
|
try {
|
|
237
368
|
const ruleFindings = rule.check(content, filePath);
|
|
238
369
|
findings.push(...ruleFindings);
|
|
@@ -242,6 +373,10 @@ function scanFile(filePath, rules2) {
|
|
|
242
373
|
}
|
|
243
374
|
return findings.sort((a, b) => a.line - b.line);
|
|
244
375
|
}
|
|
376
|
+
function scanFile(filePath, rules) {
|
|
377
|
+
const content = readFileSync(filePath, "utf8");
|
|
378
|
+
return scanContent(content, filePath, rules);
|
|
379
|
+
}
|
|
245
380
|
function walk(dirPath, files = []) {
|
|
246
381
|
const entries = readdirSync(dirPath);
|
|
247
382
|
for (const entry of entries) {
|
|
@@ -283,13 +418,7 @@ function tallySummary(findings) {
|
|
|
283
418
|
}
|
|
284
419
|
return summary;
|
|
285
420
|
}
|
|
286
|
-
|
|
287
|
-
const stat = statSync(targetPath);
|
|
288
|
-
const rsFiles = stat.isDirectory() ? walk(targetPath) : targetPath.endsWith(".rs") ? [targetPath] : [];
|
|
289
|
-
const allFindings = [];
|
|
290
|
-
for (const file of rsFiles) {
|
|
291
|
-
allFindings.push(...scanFile(file, rules2));
|
|
292
|
-
}
|
|
421
|
+
function sortAndSummarize(rsFileCount, allFindings) {
|
|
293
422
|
const severityOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4 };
|
|
294
423
|
allFindings.sort((a, b) => {
|
|
295
424
|
const sev = severityOrder[a.severity] - severityOrder[b.severity];
|
|
@@ -300,13 +429,21 @@ async function scanDirectory(targetPath, rules2) {
|
|
|
300
429
|
return a.line - b.line;
|
|
301
430
|
});
|
|
302
431
|
return {
|
|
303
|
-
scannedFiles:
|
|
432
|
+
scannedFiles: rsFileCount,
|
|
304
433
|
findings: allFindings,
|
|
305
434
|
summary: tallySummary(allFindings)
|
|
306
435
|
};
|
|
307
436
|
}
|
|
308
|
-
|
|
309
|
-
|
|
437
|
+
async function scanDirectory(targetPath, rules) {
|
|
438
|
+
const stat = statSync(targetPath);
|
|
439
|
+
const rsFiles = stat.isDirectory() ? walk(targetPath) : targetPath.endsWith(".rs") ? [targetPath] : [];
|
|
440
|
+
const allFindings = [];
|
|
441
|
+
for (const file of rsFiles) {
|
|
442
|
+
allFindings.push(...scanFile(file, rules));
|
|
443
|
+
}
|
|
444
|
+
return sortAndSummarize(rsFiles.length, allFindings);
|
|
445
|
+
}
|
|
446
|
+
// ../packages/core/src/output/report.ts
|
|
310
447
|
var RESET = "\x1B[0m";
|
|
311
448
|
var BOLD = "\x1B[1m";
|
|
312
449
|
var DIM = "\x1B[2m";
|
|
@@ -338,7 +475,7 @@ function badge(severity) {
|
|
|
338
475
|
var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
|
|
339
476
|
function printReport(result) {
|
|
340
477
|
const timestamp = new Date().toISOString();
|
|
341
|
-
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}`);
|
|
342
479
|
console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
|
|
343
480
|
console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
|
|
344
481
|
if (result.findings.length === 0) {
|
|
@@ -371,10 +508,9 @@ function printReport(result) {
|
|
|
371
508
|
console.log(` ${DIM}Total findings: ${result.findings.length} across ${result.scannedFiles} file(s)${RESET}`);
|
|
372
509
|
console.log("");
|
|
373
510
|
}
|
|
374
|
-
|
|
375
|
-
// src/output/sarif.ts
|
|
511
|
+
// ../packages/core/src/output/sarif.ts
|
|
376
512
|
var SARIF_VERSION = "2.1.0";
|
|
377
|
-
var TOOL_VERSION = "0.
|
|
513
|
+
var TOOL_VERSION = "0.3.0";
|
|
378
514
|
function severityToSarifLevel(severity) {
|
|
379
515
|
switch (severity) {
|
|
380
516
|
case "CRITICAL":
|
|
@@ -429,7 +565,6 @@ function toSarif(result) {
|
|
|
429
565
|
]
|
|
430
566
|
};
|
|
431
567
|
}
|
|
432
|
-
|
|
433
568
|
// src/cli.ts
|
|
434
569
|
var SEVERITY_RANK = {
|
|
435
570
|
CRITICAL: 0,
|
|
@@ -452,6 +587,8 @@ Options:
|
|
|
452
587
|
--sarif Print scan results as SARIF 2.1.0 JSON (for GitHub code scanning)
|
|
453
588
|
--min-severity <level> Only report findings at or above this severity
|
|
454
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.
|
|
455
592
|
--help Show this help message
|
|
456
593
|
|
|
457
594
|
Examples:
|
|
@@ -472,6 +609,7 @@ async function main() {
|
|
|
472
609
|
}
|
|
473
610
|
const wantsJson = args.includes("--json");
|
|
474
611
|
const wantsSarif = args.includes("--sarif");
|
|
612
|
+
const wantsExperimental = args.includes("--experimental");
|
|
475
613
|
let minSeverity = "INFO";
|
|
476
614
|
const minSeverityIdx = args.indexOf("--min-severity");
|
|
477
615
|
if (minSeverityIdx !== -1) {
|
|
@@ -491,7 +629,8 @@ async function main() {
|
|
|
491
629
|
return true;
|
|
492
630
|
});
|
|
493
631
|
const targetPath = positional[0] || ".";
|
|
494
|
-
const
|
|
632
|
+
const activeRules = wantsExperimental ? [...rules, ...experimentalRules] : rules;
|
|
633
|
+
const result = await scanDirectory(targetPath, activeRules);
|
|
495
634
|
result.findings = result.findings.filter((finding) => SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[minSeverity]);
|
|
496
635
|
if (wantsSarif) {
|
|
497
636
|
console.log(JSON.stringify(toSarif(result), null, 2));
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solaudit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Open-source static analysis CLI for Anchor/Solana programs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"solaudit": "dist/cli.js"
|
|
8
8
|
},
|
|
9
|
-
"main": "./dist/
|
|
9
|
+
"main": "./dist/cli.js",
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"examples",
|
|
@@ -18,9 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "bun build src/cli.ts --outdir dist --target node --sourcemap=none",
|
|
21
|
-
"prepublishOnly": "
|
|
22
|
-
"start": "bun src/cli.ts"
|
|
23
|
-
"test": "bun test"
|
|
21
|
+
"prepublishOnly": "bun run build && cp -r ../examples . && cp ../README.md ../LICENSE .",
|
|
22
|
+
"start": "bun src/cli.ts"
|
|
24
23
|
},
|
|
25
24
|
"keywords": [
|
|
26
25
|
"solana",
|
|
@@ -35,7 +34,9 @@
|
|
|
35
34
|
"type": "git",
|
|
36
35
|
"url": "git+https://github.com/blueeyez425-art/solaudit.git"
|
|
37
36
|
},
|
|
37
|
+
"dependencies": {},
|
|
38
38
|
"devDependencies": {
|
|
39
|
+
"@solaudit/core": "workspace:*",
|
|
39
40
|
"bun-types": "latest"
|
|
40
41
|
}
|
|
41
42
|
}
|