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 +21 -0
- package/README.md +176 -0
- package/dist/cli.js +509 -0
- package/examples/vulnerable.rs +168 -0
- package/package.json +26 -0
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)
|
|
4
|
+
[](https://www.npmjs.com/package/solaudit)
|
|
5
|
+
[](./.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
|
+
}
|