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.
Files changed (3) hide show
  1. package/README.md +85 -70
  2. package/dist/cli.js +162 -23
  3. package/package.json +6 -5
package/README.md CHANGED
@@ -1,16 +1,30 @@
1
1
  # SolAudit
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
4
- [![npm version](https://img.shields.io/badge/npm-v0.1.0-blue.svg)](https://www.npmjs.com/package/solaudit)
4
+ [![npm version](https://img.shields.io/badge/npm-v0.3.0-blue.svg)](https://www.npmjs.com/package/solaudit)
5
5
  [![build](https://img.shields.io/badge/build-passing-brightgreen.svg)](./.github/workflows/ci.yml)
6
6
 
7
- **A zero-dependency, static-analysis CLI that scans Anchor/Solana Rust programs for the vulnerability patterns behind the most common on-chain exploits.**
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 your `.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 — with no external dependencies, no RPC calls, and no build step required to run it.
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
- ## Install
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.1.0 — static analysis for Anchor/Solana programs
50
- Scanned 1 file(s) at 2026-08-22T18:32:37.205Z
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
- ## Output formats
112
+ ## Accuracy
104
113
 
105
- SolAudit supports three output modes, so it fits into a terminal, a script, or a CI pipeline:
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
- - **Human** (default) — colorized terminal report grouped by severity, for local development.
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
- ```bash
112
- solaudit ./programs --sarif > results.sarif
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
- Add SolAudit to your CI so every push and PR is scanned automatically:
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: oven-sh/setup-bun@v2
143
+ - uses: blueeyez425-art/solaudit@main
130
144
  with:
131
- bun-version: latest
132
- - run: bun install
133
- - run: bun src/cli.ts ./programs --sarif > results.sarif
134
- - uses: github/codeql-action/upload-sarif@v3
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
- This repository's own [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) runs SolAudit against [`examples/vulnerable.rs`](./examples/vulnerable.rs) on every push.
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
- ## Project layout
153
+ Prefer to wire it up manually instead of using the packaged action:
142
154
 
143
- ```
144
- src/
145
- types.ts # Finding, Rule, ScanResult types
146
- rules/index.ts # The 8 vulnerability rules (pattern-matching logic)
147
- scanner.ts # File/directory walking + aggregation
148
- output/report.ts # Colored terminal report
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 # run the unit test suite
159
- bun run start ./examples # run the CLI against the bundled example
160
- bun run build # bundle src/cli.ts -> dist/cli.js
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 better heuristics are all in scope.
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` and `bun run build` before opening a PR.
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
- Please open an issue first for larger changes (e.g. new output formats or a plugin system) so we can discuss the approach.
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 scanFile(filePath, rules2) {
233
- const content = readFileSync(filePath, "utf8");
364
+ function scanContent(content, filePath, rules) {
234
365
  const findings = [];
235
- for (const rule of rules2) {
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
- async function scanDirectory(targetPath, rules2) {
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: rsFiles.length,
432
+ scannedFiles: rsFileCount,
304
433
  findings: allFindings,
305
434
  summary: tallySummary(allFindings)
306
435
  };
307
436
  }
308
-
309
- // src/output/report.ts
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.1.0 — static analysis for Anchor/Solana programs${RESET}`);
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.1.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 result = await scanDirectory(targetPath, rules);
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.1.2",
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/index.js",
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": "npm run build",
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
  }