solaudit 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cody Pack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # SolAudit
2
+
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)
5
+ [![build](https://img.shields.io/badge/build-passing-brightgreen.svg)](./.github/workflows/ci.yml)
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.**
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.
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ # Run instantly with npx (no install)
17
+ npx solaudit ./program
18
+
19
+ # Or install globally with Bun
20
+ bun add -g solaudit
21
+ 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
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ solaudit [path] [options]
33
+ ```
34
+
35
+ | Flag | Description |
36
+ |---|---|
37
+ | `--json` | Print raw scan results as JSON |
38
+ | `--sarif` | Print results as SARIF 2.1.0 (for GitHub Code Scanning) |
39
+ | `--min-severity <level>` | Only report findings at or above `CRITICAL`\|`HIGH`\|`MEDIUM`\|`LOW`\|`INFO` |
40
+ | `--help` | Show usage |
41
+
42
+ ### Example
43
+
44
+ ```bash
45
+ $ solaudit ./examples
46
+ ```
47
+
48
+ ```
49
+ SolAudit v0.1.0 — static analysis for Anchor/Solana programs
50
+ Scanned 1 file(s) at 2026-08-22T18:32:37.205Z
51
+ ──────────────────────────────────────────────────────────────────────
52
+
53
+ CRITICAL (2)
54
+ CRITICAL SOL-001 Missing Signer Check
55
+ examples/vulnerable.rs:35
56
+ │ token::transfer(cpi_ctx, amount)?;
57
+ └─ Verify the authority/owner account is a Signer or explicitly
58
+ check `.is_signer` before transferring or minting tokens.
59
+
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
+ ──────────────────────────────────────────────────────────────────────
81
+ Summary
82
+ CRITICAL 2 HIGH 3 MEDIUM 3 LOW 6 INFO 0
83
+ Total findings: 14 across 1 file(s)
84
+ ```
85
+
86
+ Exit code is `1` when any `CRITICAL` or `HIGH` finding is present — making SolAudit a natural CI gate.
87
+
88
+ ## Rules
89
+
90
+ | ID | Severity | Name | What it catches |
91
+ |---|---|---|---|
92
+ | `SOL-001` | CRITICAL | Missing Signer Check | `transfer`/`mint_to` calls made without a nearby `Signer<'info>` type or `.is_signer` check |
93
+ | `SOL-002` | HIGH | Unchecked Account Deserialization | `Account::try_from` / `AccountLoader::try_from` used without a validating `#[account(...)]` constraint |
94
+ | `SOL-003` | HIGH | Arbitrary CPI | `invoke`/`invoke_signed` targeting a program ID sourced from a caller-supplied account instead of a verified constant |
95
+ | `SOL-004` | HIGH | Missing Bump Seed Canonicalization | `create_program_address` used directly, or `find_program_address`'s bump discarded instead of stored/validated |
96
+ | `SOL-005` | MEDIUM | Integer Overflow Risk | Raw `+`/`-`/`*` arithmetic on balance-like fields instead of `checked_*`/`saturating_*` |
97
+ | `SOL-006` | HIGH | Type Cosplay / Missing Discriminator Check | Manual `try_from_slice` deserialization with no discriminator validation or `Account<'info, T>` wrapper |
98
+ | `SOL-007` | MEDIUM | Insecure Account Initialization | `#[account(init, ...)]` constraints missing `payer` and/or `space` |
99
+ | `SOL-008` | LOW | Missing Owner Check | Raw `AccountInfo` fields used without a nearby `.owner` comparison |
100
+
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.
102
+
103
+ ## Output formats
104
+
105
+ SolAudit supports three output modes, so it fits into a terminal, a script, or a CI pipeline:
106
+
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.
110
+
111
+ ```bash
112
+ solaudit ./programs --sarif > results.sarif
113
+ ```
114
+
115
+ ## GitHub Actions
116
+
117
+ Add SolAudit to your CI so every push and PR is scanned automatically:
118
+
119
+ ```yaml
120
+ name: SolAudit
121
+
122
+ on: [push, pull_request]
123
+
124
+ jobs:
125
+ scan:
126
+ runs-on: ubuntu-latest
127
+ steps:
128
+ - uses: actions/checkout@v4
129
+ - uses: oven-sh/setup-bun@v2
130
+ 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
137
+ ```
138
+
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.
140
+
141
+ ## Project layout
142
+
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
152
+ ```
153
+
154
+ ## Development
155
+
156
+ ```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
161
+ ```
162
+
163
+ ## Contributing
164
+
165
+ Contributions are very welcome — new rules, false-positive fixes, additional output formats, or better heuristics are all in scope.
166
+
167
+ 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.
171
+
172
+ Please open an issue first for larger changes (e.g. new output formats or a plugin system) so we can discuss the approach.
173
+
174
+ ## License
175
+
176
+ [MIT](./LICENSE) © 2026 Cody Pack
package/dist/cli.js ADDED
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/rules/index.ts
4
+ function makeFinding(rule, filePath, line, snippet, description) {
5
+ return {
6
+ ruleId: rule.id,
7
+ ruleName: rule.name,
8
+ severity: rule.severity,
9
+ filePath,
10
+ line,
11
+ snippet: snippet.trim(),
12
+ description
13
+ };
14
+ }
15
+ function splitLines(fileContent) {
16
+ return fileContent.split(/\r\n|\r|\n/);
17
+ }
18
+ var missingSignerCheck = {
19
+ id: "SOL-001",
20
+ name: "Missing Signer Check",
21
+ severity: "CRITICAL",
22
+ 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.",
23
+ check(fileContent, filePath) {
24
+ const findings = [];
25
+ const lines = splitLines(fileContent);
26
+ const callPattern = /\b(transfer|mint_to)\s*\(/;
27
+ lines.forEach((line, idx) => {
28
+ if (!callPattern.test(line))
29
+ return;
30
+ if (!line.includes("ctx.accounts.") && !fileContent.includes("ctx.accounts."))
31
+ return;
32
+ const windowStart = Math.max(0, idx - 10);
33
+ const window = lines.slice(windowStart, idx + 1).join(`
34
+ `);
35
+ const hasSignerEvidence = /is_signer/.test(window) || /Signer<\s*'info\s*>/.test(window) || /#\[account\([^)]*signer[^)]*\)\]/i.test(window);
36
+ if (!hasSignerEvidence) {
37
+ 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."));
38
+ }
39
+ });
40
+ return findings;
41
+ }
42
+ };
43
+ var uncheckedDeserialization = {
44
+ id: "SOL-002",
45
+ name: "Unchecked Account Deserialization",
46
+ severity: "HIGH",
47
+ description: "`Account::try_from` / `AccountLoader::try_from` was used to manually deserialize account data without a corresponding `#[account(...)]` constraint validating ownership or discriminator. Prefer typed `Account<'info, T>` fields validated by Anchor, or add explicit owner/discriminator checks.",
48
+ check(fileContent, filePath) {
49
+ const findings = [];
50
+ const lines = splitLines(fileContent);
51
+ const pattern = /(Account|AccountLoader)::try_from\s*\(/;
52
+ lines.forEach((line, idx) => {
53
+ if (!pattern.test(line))
54
+ return;
55
+ const prevLine = idx > 0 ? lines[idx - 1] : "";
56
+ const hasConstraint = /#\[account\(/.test(prevLine) || /#\[account\(/.test(line);
57
+ if (!hasConstraint) {
58
+ 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."));
59
+ }
60
+ });
61
+ return findings;
62
+ }
63
+ };
64
+ var arbitraryCpi = {
65
+ id: "SOL-003",
66
+ name: "Arbitrary CPI (Cross-Program Invocation)",
67
+ severity: "HIGH",
68
+ description: "`invoke` or `invoke_signed` is called with a program id sourced from a user-supplied account rather than a hardcoded/verified program ID. This allows an attacker to substitute a malicious program. Add a `constraint = program.key() == expected_program::ID` check or use Anchor's `Program<'info, T>` type.",
69
+ check(fileContent, filePath) {
70
+ const findings = [];
71
+ const lines = splitLines(fileContent);
72
+ const cpiPattern = /\b(invoke|invoke_signed)\s*\(/;
73
+ lines.forEach((line, idx) => {
74
+ if (!cpiPattern.test(line))
75
+ return;
76
+ const windowStart = Math.max(0, idx - 3);
77
+ const windowEnd = Math.min(lines.length, idx + 4);
78
+ const window = lines.slice(windowStart, windowEnd).join(`
79
+ `);
80
+ const usesAccountsAsProgram = /ctx\.accounts\.\w+\.(to_account_info\(\)|key\(\))/.test(window) && !/Program<\s*'info/.test(window);
81
+ if (usesAccountsAsProgram) {
82
+ findings.push(makeFinding(arbitraryCpi, filePath, idx + 1, line, "Validate the target program ID against a known constant (e.g. `token_program.key() == spl_token::ID`) or type the account as `Program<'info, T>` before invoking it."));
83
+ }
84
+ });
85
+ return findings;
86
+ }
87
+ };
88
+ var missingBumpCanonicalization = {
89
+ id: "SOL-004",
90
+ name: "Missing Bump Seed Canonicalization",
91
+ severity: "HIGH",
92
+ 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.",
93
+ check(fileContent, filePath) {
94
+ const findings = [];
95
+ const lines = splitLines(fileContent);
96
+ lines.forEach((line, idx) => {
97
+ if (/create_program_address\s*\(/.test(line) && !/find_program_address/.test(line)) {
98
+ 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."));
99
+ return;
100
+ }
101
+ const fpaMatch = line.match(/let\s+(\([^)]*\)|\w+)\s*=\s*[\w:]*find_program_address\s*\(/);
102
+ if (fpaMatch) {
103
+ const binding = fpaMatch[1];
104
+ const bindsBump = /\(\s*\w+\s*,\s*\w+\s*\)/.test(binding);
105
+ if (!bindsBump) {
106
+ 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."));
107
+ }
108
+ }
109
+ });
110
+ return findings;
111
+ }
112
+ };
113
+ var integerOverflowRisk = {
114
+ id: "SOL-005",
115
+ name: "Integer Overflow Risk",
116
+ severity: "MEDIUM",
117
+ 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.",
118
+ check(fileContent, filePath) {
119
+ const findings = [];
120
+ const lines = splitLines(fileContent);
121
+ const arithmeticPattern = /\b(\w*(amount|balance|supply|total|shares|stake)\w*)\s*(\+|\-|\*)\s*\w/i;
122
+ const alreadySafe = /checked_(add|sub|mul|div)|saturating_(add|sub|mul)|wrapping_(add|sub|mul)/;
123
+ lines.forEach((line, idx) => {
124
+ if (alreadySafe.test(line))
125
+ return;
126
+ if (line.trim().startsWith("//"))
127
+ return;
128
+ if (arithmeticPattern.test(line)) {
129
+ 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."));
130
+ }
131
+ });
132
+ return findings;
133
+ }
134
+ };
135
+ var typeCosplay = {
136
+ id: "SOL-006",
137
+ name: "Type Cosplay / Missing Discriminator Check",
138
+ severity: "HIGH",
139
+ description: "Account data is manually deserialized with `try_from_slice` without validating an 8-byte Anchor discriminator or account type tag first. An attacker can pass an account of a different type with a compatible byte layout ('type cosplay') to bypass logic. Use Anchor's `Account<'info, T>` wrapper, which enforces discriminator checks automatically, or manually compare the first 8 bytes against `T::DISCRIMINATOR`.",
140
+ check(fileContent, filePath) {
141
+ const findings = [];
142
+ const lines = splitLines(fileContent);
143
+ lines.forEach((line, idx) => {
144
+ if (!/try_from_slice\s*\(/.test(line))
145
+ return;
146
+ const windowStart = Math.max(0, idx - 5);
147
+ const window = lines.slice(windowStart, idx + 1).join(`
148
+ `);
149
+ const usesTypedAccountWrapper = /Account<\s*'info/.test(window);
150
+ const checksDiscriminator = /discriminator/i.test(window);
151
+ if (!usesTypedAccountWrapper && !checksDiscriminator) {
152
+ findings.push(makeFinding(typeCosplay, filePath, idx + 1, line, "Verify the account's 8-byte Anchor discriminator matches the expected type before trusting deserialized fields, or switch to Anchor's `Account<'info, T>` type."));
153
+ }
154
+ });
155
+ return findings;
156
+ }
157
+ };
158
+ var insecureInit = {
159
+ id: "SOL-007",
160
+ name: "Insecure Account Initialization",
161
+ severity: "MEDIUM",
162
+ description: "An `#[account(init, ...)]` constraint is missing `payer` and/or `space`. Anchor requires both to safely allocate and rent-exempt a new account; omitting them causes a compile error in modern Anchor, but in hand-rolled or macro-generated code this pattern indicates the constraint block was copy-pasted incompletely and should be reviewed.",
163
+ check(fileContent, filePath) {
164
+ const findings = [];
165
+ const blockRegex = /#\[account\(([\s\S]*?)\)\]/g;
166
+ let match;
167
+ while ((match = blockRegex.exec(fileContent)) !== null) {
168
+ const block = match[1];
169
+ if (!/\binit\b/.test(block))
170
+ continue;
171
+ const hasPayer = /\bpayer\s*=/.test(block);
172
+ const hasSpace = /\bspace\s*=/.test(block);
173
+ if (!hasPayer || !hasSpace) {
174
+ const upToMatch = fileContent.slice(0, match.index);
175
+ const line = upToMatch.split(/\r\n|\r|\n/).length;
176
+ const missing = [!hasPayer && "payer", !hasSpace && "space"].filter(Boolean).join(" and ");
177
+ 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.`));
178
+ }
179
+ }
180
+ return findings;
181
+ }
182
+ };
183
+ var missingOwnerCheck = {
184
+ id: "SOL-008",
185
+ name: "Missing Owner Check",
186
+ severity: "LOW",
187
+ 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.",
188
+ check(fileContent, filePath) {
189
+ const findings = [];
190
+ const lines = splitLines(fileContent);
191
+ const rawFieldPattern = /pub\s+(\w+)\s*:\s*AccountInfo<\s*'info\s*>/;
192
+ const rawFields = new Set;
193
+ lines.forEach((line) => {
194
+ const m = line.match(rawFieldPattern);
195
+ if (m)
196
+ rawFields.add(m[1]);
197
+ });
198
+ if (rawFields.size === 0)
199
+ return findings;
200
+ lines.forEach((line, idx) => {
201
+ for (const field of rawFields) {
202
+ const usagePattern = new RegExp(`ctx\\.accounts\\.${field}\\b`);
203
+ if (!usagePattern.test(line))
204
+ continue;
205
+ const windowStart = Math.max(0, idx - 5);
206
+ const windowEnd = Math.min(lines.length, idx + 6);
207
+ const window = lines.slice(windowStart, windowEnd).join(`
208
+ `);
209
+ const hasOwnerCheck = new RegExp(`${field}\\.owner`).test(window) || /\.owner\s*==/.test(window);
210
+ if (!hasOwnerCheck) {
211
+ 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.`));
212
+ }
213
+ }
214
+ });
215
+ return findings;
216
+ }
217
+ };
218
+ var rules = [
219
+ missingSignerCheck,
220
+ uncheckedDeserialization,
221
+ arbitraryCpi,
222
+ missingBumpCanonicalization,
223
+ integerOverflowRisk,
224
+ typeCosplay,
225
+ insecureInit,
226
+ missingOwnerCheck
227
+ ];
228
+
229
+ // src/scanner.ts
230
+ import { readFileSync, readdirSync, statSync } from "node:fs";
231
+ import { join } from "node:path";
232
+ function scanFile(filePath, rules2) {
233
+ const content = readFileSync(filePath, "utf8");
234
+ const findings = [];
235
+ for (const rule of rules2) {
236
+ try {
237
+ const ruleFindings = rule.check(content, filePath);
238
+ findings.push(...ruleFindings);
239
+ } catch (err) {
240
+ console.error(`[solaudit] rule ${rule.id} threw on ${filePath}: ${err.message}`);
241
+ }
242
+ }
243
+ return findings.sort((a, b) => a.line - b.line);
244
+ }
245
+ function walk(dirPath, files = []) {
246
+ const entries = readdirSync(dirPath);
247
+ for (const entry of entries) {
248
+ if (entry === "node_modules" || entry === "target" || entry === ".git")
249
+ continue;
250
+ const fullPath = join(dirPath, entry);
251
+ const stat = statSync(fullPath);
252
+ if (stat.isDirectory()) {
253
+ walk(fullPath, files);
254
+ } else if (stat.isFile() && fullPath.endsWith(".rs")) {
255
+ files.push(fullPath);
256
+ }
257
+ }
258
+ return files;
259
+ }
260
+ function emptySummary() {
261
+ return { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
262
+ }
263
+ function tallySummary(findings) {
264
+ const summary = emptySummary();
265
+ for (const finding of findings) {
266
+ switch (finding.severity) {
267
+ case "CRITICAL":
268
+ summary.critical++;
269
+ break;
270
+ case "HIGH":
271
+ summary.high++;
272
+ break;
273
+ case "MEDIUM":
274
+ summary.medium++;
275
+ break;
276
+ case "LOW":
277
+ summary.low++;
278
+ break;
279
+ case "INFO":
280
+ summary.info++;
281
+ break;
282
+ }
283
+ }
284
+ return summary;
285
+ }
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
+ }
293
+ const severityOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4 };
294
+ allFindings.sort((a, b) => {
295
+ const sev = severityOrder[a.severity] - severityOrder[b.severity];
296
+ if (sev !== 0)
297
+ return sev;
298
+ if (a.filePath !== b.filePath)
299
+ return a.filePath.localeCompare(b.filePath);
300
+ return a.line - b.line;
301
+ });
302
+ return {
303
+ scannedFiles: rsFiles.length,
304
+ findings: allFindings,
305
+ summary: tallySummary(allFindings)
306
+ };
307
+ }
308
+
309
+ // src/output/report.ts
310
+ var RESET = "\x1B[0m";
311
+ var BOLD = "\x1B[1m";
312
+ var DIM = "\x1B[2m";
313
+ var RED = "\x1B[31m";
314
+ var ORANGE = "\x1B[38;5;208m";
315
+ var YELLOW = "\x1B[33m";
316
+ var BLUE = "\x1B[34m";
317
+ var GRAY = "\x1B[90m";
318
+ var CYAN = "\x1B[36m";
319
+ var GREEN = "\x1B[32m";
320
+ function colorFor(severity) {
321
+ switch (severity) {
322
+ case "CRITICAL":
323
+ return RED;
324
+ case "HIGH":
325
+ return ORANGE;
326
+ case "MEDIUM":
327
+ return YELLOW;
328
+ case "LOW":
329
+ return BLUE;
330
+ default:
331
+ return GRAY;
332
+ }
333
+ }
334
+ function badge(severity) {
335
+ const color = colorFor(severity);
336
+ return `${color}${BOLD} ${severity.padEnd(8)} ${RESET}`;
337
+ }
338
+ var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
339
+ function printReport(result) {
340
+ const timestamp = new Date().toISOString();
341
+ console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.1.0 — static analysis for Anchor/Solana programs${RESET}`);
342
+ console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
343
+ console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
344
+ if (result.findings.length === 0) {
345
+ console.log(`${GREEN}${BOLD}✔ No issues found.${RESET}`);
346
+ } else {
347
+ const grouped = new Map;
348
+ for (const finding of result.findings) {
349
+ const bucket = grouped.get(finding.severity) ?? [];
350
+ bucket.push(finding);
351
+ grouped.set(finding.severity, bucket);
352
+ }
353
+ for (const severity of SEVERITY_ORDER) {
354
+ const findings = grouped.get(severity);
355
+ if (!findings || findings.length === 0)
356
+ continue;
357
+ console.log("");
358
+ console.log(`${colorFor(severity)}${BOLD}${severity} (${findings.length})${RESET}`);
359
+ for (const finding of findings) {
360
+ console.log(` ${badge(finding.severity)} ${BOLD}${finding.ruleId}${RESET} ${finding.ruleName}`);
361
+ console.log(` ${DIM}${finding.filePath}:${finding.line}${RESET}`);
362
+ console.log(` ${GRAY}│${RESET} ${finding.snippet}`);
363
+ console.log(` ${GRAY}└─${RESET} ${finding.description}`);
364
+ }
365
+ }
366
+ }
367
+ console.log("");
368
+ console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
369
+ console.log(`${BOLD}Summary${RESET}`);
370
+ console.log(` ${RED}${BOLD}CRITICAL${RESET} ${result.summary.critical} ` + `${ORANGE}${BOLD}HIGH${RESET} ${result.summary.high} ` + `${YELLOW}${BOLD}MEDIUM${RESET} ${result.summary.medium} ` + `${BLUE}${BOLD}LOW${RESET} ${result.summary.low} ` + `${GRAY}${BOLD}INFO${RESET} ${result.summary.info}`);
371
+ console.log(` ${DIM}Total findings: ${result.findings.length} across ${result.scannedFiles} file(s)${RESET}`);
372
+ console.log("");
373
+ }
374
+
375
+ // src/output/sarif.ts
376
+ var SARIF_VERSION = "2.1.0";
377
+ var TOOL_VERSION = "0.1.0";
378
+ function severityToSarifLevel(severity) {
379
+ switch (severity) {
380
+ case "CRITICAL":
381
+ case "HIGH":
382
+ return "error";
383
+ case "MEDIUM":
384
+ return "warning";
385
+ default:
386
+ return "note";
387
+ }
388
+ }
389
+ function toFileUri(filePath) {
390
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
391
+ }
392
+ function toSarif(result) {
393
+ const sarifRules = rules.map((rule) => ({
394
+ id: rule.id,
395
+ name: rule.name.replace(/\s+/g, ""),
396
+ shortDescription: { text: rule.name },
397
+ fullDescription: { text: rule.description },
398
+ defaultConfiguration: { level: severityToSarifLevel(rule.severity) },
399
+ properties: { severity: rule.severity }
400
+ }));
401
+ const sarifResults = result.findings.map((finding) => ({
402
+ ruleId: finding.ruleId,
403
+ level: severityToSarifLevel(finding.severity),
404
+ message: { text: `${finding.description} (${finding.snippet})` },
405
+ locations: [
406
+ {
407
+ physicalLocation: {
408
+ artifactLocation: { uri: toFileUri(finding.filePath) },
409
+ region: { startLine: Math.max(1, finding.line) }
410
+ }
411
+ }
412
+ ]
413
+ }));
414
+ return {
415
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
416
+ version: SARIF_VERSION,
417
+ runs: [
418
+ {
419
+ tool: {
420
+ driver: {
421
+ name: "SolAudit",
422
+ informationUri: "https://github.com/blueeyez425-art/solaudit",
423
+ version: TOOL_VERSION,
424
+ rules: sarifRules
425
+ }
426
+ },
427
+ results: sarifResults
428
+ }
429
+ ]
430
+ };
431
+ }
432
+
433
+ // src/cli.ts
434
+ var SEVERITY_RANK = {
435
+ CRITICAL: 0,
436
+ HIGH: 1,
437
+ MEDIUM: 2,
438
+ LOW: 3,
439
+ INFO: 4
440
+ };
441
+ function printHelp() {
442
+ console.log(`SolAudit — static analysis CLI for Anchor/Solana programs
443
+
444
+ Usage:
445
+ solaudit [path] [options]
446
+
447
+ Arguments:
448
+ path File or directory to scan (default: current directory)
449
+
450
+ Options:
451
+ --json Print raw scan results as JSON
452
+ --sarif Print scan results as SARIF 2.1.0 JSON (for GitHub code scanning)
453
+ --min-severity <level> Only report findings at or above this severity
454
+ (CRITICAL | HIGH | MEDIUM | LOW | INFO)
455
+ --help Show this help message
456
+
457
+ Examples:
458
+ solaudit ./program
459
+ bun run solaudit ./programs/vault --min-severity HIGH
460
+ solaudit . --sarif > results.sarif
461
+
462
+ Exit codes:
463
+ 0 No CRITICAL or HIGH severity findings
464
+ 1 One or more CRITICAL or HIGH severity findings were found
465
+ `);
466
+ }
467
+ async function main() {
468
+ const args = process.argv.slice(2);
469
+ if (args.includes("--help") || args.includes("-h")) {
470
+ printHelp();
471
+ process.exit(0);
472
+ }
473
+ const wantsJson = args.includes("--json");
474
+ const wantsSarif = args.includes("--sarif");
475
+ let minSeverity = "INFO";
476
+ const minSeverityIdx = args.indexOf("--min-severity");
477
+ if (minSeverityIdx !== -1) {
478
+ const value = (args[minSeverityIdx + 1] || "").toUpperCase();
479
+ if (value in SEVERITY_RANK) {
480
+ minSeverity = value;
481
+ } else {
482
+ console.error(`[solaudit] invalid --min-severity value: "${args[minSeverityIdx + 1]}"`);
483
+ process.exit(2);
484
+ }
485
+ }
486
+ const positional = args.filter((arg, idx) => {
487
+ if (arg.startsWith("-"))
488
+ return false;
489
+ if (args[idx - 1] === "--min-severity")
490
+ return false;
491
+ return true;
492
+ });
493
+ const targetPath = positional[0] || ".";
494
+ const result = await scanDirectory(targetPath, rules);
495
+ result.findings = result.findings.filter((finding) => SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[minSeverity]);
496
+ if (wantsSarif) {
497
+ console.log(JSON.stringify(toSarif(result), null, 2));
498
+ } else if (wantsJson) {
499
+ console.log(JSON.stringify(result, null, 2));
500
+ } else {
501
+ printReport(result);
502
+ }
503
+ const hasBlockingFindings = result.findings.some((f) => f.severity === "CRITICAL" || f.severity === "HIGH");
504
+ process.exit(hasBlockingFindings ? 1 : 0);
505
+ }
506
+ main().catch((err) => {
507
+ console.error(`[solaudit] fatal error: ${err.message}`);
508
+ process.exit(2);
509
+ });
@@ -0,0 +1,168 @@
1
+ use anchor_lang::prelude::*;
2
+ use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer};
3
+ use solana_program::program::{invoke, invoke_signed};
4
+ use solana_program::pubkey::Pubkey;
5
+
6
+ declare_id!("VauLt111111111111111111111111111111111111");
7
+
8
+ // A simple token vault / staking contract.
9
+ // NOTE: this file is intentionally left with a few rough edges from the
10
+ // original hackathon build -- flagged for cleanup before mainnet launch.
11
+ #[program]
12
+ pub mod simple_vault {
13
+ use super::*;
14
+
15
+ pub fn initialize_vault(ctx: Context<InitializeVault>, _bump: u8) -> Result<()> {
16
+ let vault = &mut ctx.accounts.vault;
17
+ vault.total_staked = 0;
18
+ vault.authority = ctx.accounts.authority.key();
19
+ Ok(())
20
+ }
21
+
22
+ // TODO: add signer check before we ship this to mainnet
23
+ pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
24
+ let vault = &mut ctx.accounts.vault;
25
+
26
+ // Naive balance bump -- fine for now, revisit before audit.
27
+ vault.total_staked = vault.total_staked - amount;
28
+
29
+ let cpi_accounts = Transfer {
30
+ from: ctx.accounts.vault_token_account.to_account_info(),
31
+ to: ctx.accounts.user_token_account.to_account_info(),
32
+ authority: ctx.accounts.vault_authority.to_account_info(),
33
+ };
34
+ let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
35
+ token::transfer(cpi_ctx, amount)?;
36
+
37
+ Ok(())
38
+ }
39
+
40
+ pub fn stake(ctx: Context<Stake>, amount: u64) -> Result<()> {
41
+ let vault = &mut ctx.accounts.vault;
42
+ // Total staked grows with every deposit.
43
+ vault.total_staked = vault.total_staked + amount;
44
+
45
+ token::mint_to(
46
+ CpiContext::new(
47
+ ctx.accounts.token_program.to_account_info(),
48
+ token::MintTo {
49
+ mint: ctx.accounts.reward_mint.to_account_info(),
50
+ to: ctx.accounts.user_reward_account.to_account_info(),
51
+ authority: ctx.accounts.vault_authority.to_account_info(),
52
+ },
53
+ ),
54
+ amount,
55
+ )?;
56
+
57
+ Ok(())
58
+ }
59
+
60
+ // Relay instruction lets callers route a CPI through the vault's authority.
61
+ // Convenient for composability with partner programs.
62
+ pub fn relay_cpi(ctx: Context<RelayCpi>, data: Vec<u8>) -> Result<()> {
63
+ let ix = solana_program::instruction::Instruction {
64
+ program_id: ctx.accounts.target_program.key(),
65
+ accounts: vec![],
66
+ data,
67
+ };
68
+ invoke(&ix, &[ctx.accounts.target_program.to_account_info()])?;
69
+ Ok(())
70
+ }
71
+
72
+ // Derives a vault signer PDA without going through find_program_address --
73
+ // kept for backwards compatibility with the old bump scheme.
74
+ pub fn sign_with_legacy_bump(ctx: Context<LegacySign>, bump: u8) -> Result<()> {
75
+ let seeds = &[b"vault", ctx.accounts.authority.key.as_ref(), &[bump]];
76
+ let signer_pda = Pubkey::create_program_address(seeds, ctx.program_id)
77
+ .map_err(|_| ProgramError::InvalidSeeds)?;
78
+ msg!("legacy signer pda: {}", signer_pda);
79
+ Ok(())
80
+ }
81
+
82
+ // Loads raw vault bytes for a quick migration script -- bypasses the
83
+ // normal Account<T> wrapper since we needed this in a hurry.
84
+ pub fn migrate_vault(ctx: Context<MigrateVault>) -> Result<()> {
85
+ let data = ctx.accounts.legacy_vault.try_borrow_data()?;
86
+ let legacy: LegacyVaultData = LegacyVaultData::try_from_slice(&data[8..])?;
87
+ msg!("migrating vault with {} staked", legacy.total_staked);
88
+ Ok(())
89
+ }
90
+
91
+ // Reads the fee_collector account directly for logging purposes.
92
+ pub fn log_fee_collector(ctx: Context<LogFeeCollector>) -> Result<()> {
93
+ msg!("fee collector: {}", ctx.accounts.fee_collector.key());
94
+ Ok(())
95
+ }
96
+ }
97
+
98
+ #[account]
99
+ pub struct Vault {
100
+ pub authority: Pubkey,
101
+ pub total_staked: u64,
102
+ }
103
+
104
+ #[account]
105
+ pub struct LegacyVaultData {
106
+ pub total_staked: u64,
107
+ }
108
+
109
+ #[derive(Accounts)]
110
+ pub struct InitializeVault<'info> {
111
+ // Missing `space` on this init constraint -- copy-pasted from another
112
+ // instruction and never finished.
113
+ #[account(init, payer = authority)]
114
+ pub vault: Account<'info, Vault>,
115
+ #[account(mut)]
116
+ pub authority: Signer<'info>,
117
+ pub system_program: Program<'info, System>,
118
+ }
119
+
120
+ #[derive(Accounts)]
121
+ pub struct Withdraw<'info> {
122
+ #[account(mut)]
123
+ pub vault: Account<'info, Vault>,
124
+ #[account(mut)]
125
+ pub vault_token_account: Account<'info, TokenAccount>,
126
+ #[account(mut)]
127
+ pub user_token_account: Account<'info, TokenAccount>,
128
+ /// CHECK: PDA authority for the vault, checked via seeds elsewhere
129
+ pub vault_authority: AccountInfo<'info>,
130
+ pub token_program: Program<'info, Token>,
131
+ }
132
+
133
+ #[derive(Accounts)]
134
+ pub struct Stake<'info> {
135
+ #[account(mut)]
136
+ pub vault: Account<'info, Vault>,
137
+ #[account(mut)]
138
+ pub reward_mint: Account<'info, Mint>,
139
+ #[account(mut)]
140
+ pub user_reward_account: Account<'info, TokenAccount>,
141
+ /// CHECK: PDA authority for the vault
142
+ pub vault_authority: AccountInfo<'info>,
143
+ pub token_program: Program<'info, Token>,
144
+ }
145
+
146
+ #[derive(Accounts)]
147
+ pub struct RelayCpi<'info> {
148
+ /// CHECK: caller-supplied program to relay the call to -- no allowlist yet
149
+ pub target_program: AccountInfo<'info>,
150
+ pub authority: Signer<'info>,
151
+ }
152
+
153
+ #[derive(Accounts)]
154
+ pub struct LegacySign<'info> {
155
+ pub authority: Signer<'info>,
156
+ }
157
+
158
+ #[derive(Accounts)]
159
+ pub struct MigrateVault<'info> {
160
+ /// CHECK: raw legacy account, migrated manually below
161
+ pub legacy_vault: AccountInfo<'info>,
162
+ }
163
+
164
+ #[derive(Accounts)]
165
+ pub struct LogFeeCollector<'info> {
166
+ /// CHECK: only used for logging, never mutated
167
+ pub fee_collector: AccountInfo<'info>,
168
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "solaudit",
3
+ "version": "0.1.0",
4
+ "description": "Open-source static analysis CLI for Anchor/Solana programs",
5
+ "type": "module",
6
+ "bin": { "solaudit": "./dist/cli.js" },
7
+ "main": "./dist/index.js",
8
+ "files": ["dist", "examples", "README.md", "LICENSE"],
9
+ "engines": { "node": ">=18" },
10
+ "scripts": {
11
+ "build": "bun build src/cli.ts --outdir dist --target node --sourcemap=none",
12
+ "prepublishOnly": "npm run build",
13
+ "start": "bun src/cli.ts",
14
+ "test": "bun test"
15
+ },
16
+ "keywords": ["solana", "anchor", "security", "static-analysis", "audit"],
17
+ "license": "MIT",
18
+ "author": "Cody Pack",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/blueeyez425-art/solaudit.git"
22
+ },
23
+ "devDependencies": {
24
+ "bun-types": "latest"
25
+ }
26
+ }