solaudit 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -12
- package/dist/cli.js +485 -41
- package/examples/solscape/ANSWER_KEY.md +35 -0
- package/examples/solscape/DESIGN.md +54 -0
- package/examples/solscape/README.md +20 -0
- package/examples/solscape/SCORECARD.md +102 -0
- package/examples/solscape/fixture_v1/lib_v1.rs +403 -0
- package/examples/solscape/programs/solscape/src/lib.rs +410 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,222 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// ../packages/core/src/anchor.ts
|
|
4
|
+
function stripComments(text) {
|
|
5
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, "").split(/\r\n|\r|\n/).map((line) => line.replace(/\/\/.*$/, "")).join(`
|
|
6
|
+
`);
|
|
7
|
+
}
|
|
8
|
+
function stripStringLiterals(line) {
|
|
9
|
+
return line.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
10
|
+
}
|
|
11
|
+
function matchBraces(content, openIdx) {
|
|
12
|
+
let depth = 0;
|
|
13
|
+
let i = openIdx;
|
|
14
|
+
let inLineComment = false;
|
|
15
|
+
let inBlockComment = false;
|
|
16
|
+
let inString = false;
|
|
17
|
+
let inChar = false;
|
|
18
|
+
while (i < content.length) {
|
|
19
|
+
const c = content[i];
|
|
20
|
+
const next = i + 1 < content.length ? content[i + 1] : "";
|
|
21
|
+
if (inLineComment) {
|
|
22
|
+
if (c === `
|
|
23
|
+
`)
|
|
24
|
+
inLineComment = false;
|
|
25
|
+
} else if (inBlockComment) {
|
|
26
|
+
if (c === "*" && next === "/") {
|
|
27
|
+
inBlockComment = false;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
} else if (inString) {
|
|
31
|
+
if (c === "\\")
|
|
32
|
+
i++;
|
|
33
|
+
else if (c === '"')
|
|
34
|
+
inString = false;
|
|
35
|
+
} else if (inChar) {
|
|
36
|
+
if (c === "\\")
|
|
37
|
+
i++;
|
|
38
|
+
else if (c === "'")
|
|
39
|
+
inChar = false;
|
|
40
|
+
} else if (c === "/" && next === "/") {
|
|
41
|
+
inLineComment = true;
|
|
42
|
+
i++;
|
|
43
|
+
} else if (c === "/" && next === "*") {
|
|
44
|
+
inBlockComment = true;
|
|
45
|
+
i++;
|
|
46
|
+
} else if (c === '"') {
|
|
47
|
+
inString = true;
|
|
48
|
+
} else if (c === "'" && /[a-zA-Z0-9_]/.test(next) && content[i + 2] === "'") {
|
|
49
|
+
inChar = true;
|
|
50
|
+
} else if (c === "{") {
|
|
51
|
+
depth++;
|
|
52
|
+
} else if (c === "}") {
|
|
53
|
+
depth--;
|
|
54
|
+
if (depth === 0)
|
|
55
|
+
return i + 1;
|
|
56
|
+
}
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
return content.length;
|
|
60
|
+
}
|
|
61
|
+
var RAW_WRAPPERS = new Set(["AccountInfo", "UncheckedAccount"]);
|
|
62
|
+
function parseFields(structBody, structBodyOffset, content) {
|
|
63
|
+
const fields = [];
|
|
64
|
+
const fieldRe = /((?:\s*(?:#\[[\s\S]*?\]|\/\/[^\n]*))*)\s*pub\s+(\w+)\s*:\s*((?:Box\s*<\s*)*)([\w:]+)\s*<\s*'info\s*(?:,\s*([\w:]+)\s*)?>[\s>]*/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = fieldRe.exec(structBody)) !== null) {
|
|
67
|
+
const attrs = stripComments(m[1] || "");
|
|
68
|
+
const name = m[2];
|
|
69
|
+
const wrapperType = m[4];
|
|
70
|
+
const genericArg = m[5] || null;
|
|
71
|
+
const offset = structBodyOffset + m.index;
|
|
72
|
+
const line = content.slice(0, offset).split(/\r\n|\r|\n/).length;
|
|
73
|
+
fields.push({
|
|
74
|
+
name,
|
|
75
|
+
wrapperType,
|
|
76
|
+
genericArg,
|
|
77
|
+
attrs,
|
|
78
|
+
isRaw: RAW_WRAPPERS.has(wrapperType),
|
|
79
|
+
isSignerTyped: wrapperType === "Signer" || /#\s*\[\s*account\(\s*signer\s*\)\s*\]/.test(attrs),
|
|
80
|
+
isTokenAccount: genericArg === "TokenAccount",
|
|
81
|
+
isMint: genericArg === "Mint",
|
|
82
|
+
validated: /\b(owner|constraint|has_one|address|seeds)\s*=/.test(attrs),
|
|
83
|
+
bindsTokenMint: /\b(?:token|associated_token)::mint\b/.test(attrs),
|
|
84
|
+
hasInit: /\binit(_if_needed)?\b/.test(attrs),
|
|
85
|
+
line
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return fields;
|
|
89
|
+
}
|
|
90
|
+
function parseStructs(content) {
|
|
91
|
+
const structs = [];
|
|
92
|
+
const deriveRe = /#\s*\[\s*derive\(\s*Accounts(?:\s*,\s*[\w:]+)*\s*\)\s*\]/g;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = deriveRe.exec(content)) !== null) {
|
|
95
|
+
const afterIdx = m.index + m[0].length;
|
|
96
|
+
const structRe = /pub\s+struct\s+(\w+)[^{]*\{/g;
|
|
97
|
+
structRe.lastIndex = afterIdx;
|
|
98
|
+
const sm = structRe.exec(content);
|
|
99
|
+
if (!sm || sm.index - afterIdx > 400)
|
|
100
|
+
continue;
|
|
101
|
+
const openBrace = sm.index + sm[0].length - 1;
|
|
102
|
+
const end = matchBraces(content, openBrace);
|
|
103
|
+
const body = content.slice(openBrace + 1, end - 1);
|
|
104
|
+
structs.push({
|
|
105
|
+
name: sm[1],
|
|
106
|
+
start: m.index,
|
|
107
|
+
end,
|
|
108
|
+
fields: parseFields(body, openBrace + 1, content)
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return structs;
|
|
112
|
+
}
|
|
113
|
+
function parseRegions(content) {
|
|
114
|
+
const regions = [];
|
|
115
|
+
const implRe = /\bimpl(?:\s*<[^>]*>)?\s+(\w+)(?:\s*<[^>]*>)?(?:\s+for\s+\w+(?:\s*<[^>]*>)?)?\s*\{/g;
|
|
116
|
+
let im;
|
|
117
|
+
while ((im = implRe.exec(content)) !== null) {
|
|
118
|
+
const forMatch = im[0].match(/\bfor\s+(\w+)/);
|
|
119
|
+
const implType = forMatch ? forMatch[1] : im[1];
|
|
120
|
+
const openBrace = im.index + im[0].length - 1;
|
|
121
|
+
const end = matchBraces(content, openBrace);
|
|
122
|
+
regions.push({ start: im.index, end, contextType: null, implType });
|
|
123
|
+
}
|
|
124
|
+
const fnRe = /\bfn\s+\w+\s*(?:<[^>]*>)?\s*\(\s*(?:mut\s+)?ctx\s*:\s*Context\s*<\s*(?:(?:'\w+|'_)\s*,\s*)*(\w+)(?:\s*<[^>]*>)?\s*[,>]/g;
|
|
125
|
+
let fm;
|
|
126
|
+
while ((fm = fnRe.exec(content)) !== null) {
|
|
127
|
+
let i = fm.index + fm[0].length;
|
|
128
|
+
let parenDepth = 0;
|
|
129
|
+
parenDepth = 1;
|
|
130
|
+
while (i < content.length && parenDepth > 0) {
|
|
131
|
+
if (content[i] === "(")
|
|
132
|
+
parenDepth++;
|
|
133
|
+
else if (content[i] === ")")
|
|
134
|
+
parenDepth--;
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
while (i < content.length && content[i] !== "{") {
|
|
138
|
+
if (content[i] === ";")
|
|
139
|
+
break;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
if (i >= content.length || content[i] !== "{")
|
|
143
|
+
continue;
|
|
144
|
+
const end = matchBraces(content, i);
|
|
145
|
+
regions.push({ start: fm.index, end, contextType: fm[1], implType: null });
|
|
146
|
+
}
|
|
147
|
+
return regions;
|
|
148
|
+
}
|
|
149
|
+
function parseTestRanges(content) {
|
|
150
|
+
const ranges = [];
|
|
151
|
+
const re = /#\s*\[\s*cfg\(\s*test\s*\)\s*\]/g;
|
|
152
|
+
let m;
|
|
153
|
+
while ((m = re.exec(content)) !== null) {
|
|
154
|
+
const braceIdx = content.indexOf("{", m.index + m[0].length);
|
|
155
|
+
if (braceIdx === -1)
|
|
156
|
+
continue;
|
|
157
|
+
ranges.push([m.index, matchBraces(content, braceIdx)]);
|
|
158
|
+
}
|
|
159
|
+
return ranges;
|
|
160
|
+
}
|
|
161
|
+
function buildModel(content) {
|
|
162
|
+
return {
|
|
163
|
+
structs: parseStructs(content),
|
|
164
|
+
regions: parseRegions(content),
|
|
165
|
+
testRanges: parseTestRanges(content)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
var cached = null;
|
|
169
|
+
function getConstraintModel(content) {
|
|
170
|
+
if (cached && cached.content === content)
|
|
171
|
+
return cached.model;
|
|
172
|
+
const model = buildModel(content);
|
|
173
|
+
cached = { content, model };
|
|
174
|
+
return model;
|
|
175
|
+
}
|
|
176
|
+
function inRanges(offset, ranges) {
|
|
177
|
+
return ranges.some(([s, e]) => offset >= s && offset < e);
|
|
178
|
+
}
|
|
179
|
+
function innermostRegionAt(model, offset) {
|
|
180
|
+
let best = null;
|
|
181
|
+
for (const r of model.regions) {
|
|
182
|
+
if (offset >= r.start && offset < r.end) {
|
|
183
|
+
if (!best || r.end - r.start < best.end - best.start)
|
|
184
|
+
best = r;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return best;
|
|
188
|
+
}
|
|
189
|
+
function structAt(model, offset) {
|
|
190
|
+
const region = innermostRegionAt(model, offset);
|
|
191
|
+
if (!region)
|
|
192
|
+
return null;
|
|
193
|
+
const name = region.contextType ?? region.implType;
|
|
194
|
+
if (!name)
|
|
195
|
+
return null;
|
|
196
|
+
return model.structs.find((s) => s.name === name) ?? null;
|
|
197
|
+
}
|
|
198
|
+
function findField(model, name) {
|
|
199
|
+
for (const s of model.structs) {
|
|
200
|
+
const f = s.fields.find((f) => f.name === name);
|
|
201
|
+
if (f)
|
|
202
|
+
return f;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function lineOffset(lines, idx) {
|
|
207
|
+
let off = 0;
|
|
208
|
+
for (let i = 0;i < idx && i < lines.length; i++)
|
|
209
|
+
off += lines[i].length + 1;
|
|
210
|
+
return off;
|
|
211
|
+
}
|
|
212
|
+
function isTestLikePath(filePath) {
|
|
213
|
+
const p = filePath.replace(/\\/g, "/");
|
|
214
|
+
if (/(^|\/)(tests?|test[-_]utils?|testing|fuzz|fixtures?|benches|benchmarks|examples?)(\/|$)/i.test(p))
|
|
215
|
+
return true;
|
|
216
|
+
const base = (p.split("/").pop() || "").toLowerCase();
|
|
217
|
+
return /^(tests|e2e)\.rs$/.test(base) || /^test_.*\.rs$/.test(base) || /(_tests?|_e2e)\.rs$/.test(base);
|
|
218
|
+
}
|
|
219
|
+
|
|
3
220
|
// ../packages/core/src/rules/index.ts
|
|
4
221
|
function makeFinding(rule, filePath, line, snippet, description) {
|
|
5
222
|
return {
|
|
@@ -23,23 +240,63 @@ var missingSignerCheck = {
|
|
|
23
240
|
id: "SOL-001",
|
|
24
241
|
name: "Missing Signer Check",
|
|
25
242
|
severity: "CRITICAL",
|
|
26
|
-
description: "A transfer or mint_to call was found
|
|
243
|
+
description: "A transfer or mint_to call was found with no signer evidence: no is_signer check nearby, no Signer<'info> field on the governing Accounts struct, and no PDA signing (invoke_signed / new_with_signer). Add a `Signer<'info>` account type or an explicit `require!(ctx.accounts.authority.is_signer, ...)` check before authorizing fund movement.",
|
|
27
244
|
check(fileContent, filePath) {
|
|
28
245
|
const findings = [];
|
|
29
246
|
const lines = splitLines(fileContent);
|
|
30
|
-
const callPattern = /\b(transfer|mint_to)\s*\(/;
|
|
31
|
-
|
|
247
|
+
const callPattern = /\b(?:transfer|mint_to|transfer_checked|mint_to_checked)\s*\(/;
|
|
248
|
+
const declPattern = /\bfn\s+(?:transfer|mint_to|transfer_checked|mint_to_checked)\s*\(/;
|
|
249
|
+
const model = getConstraintModel(fileContent);
|
|
250
|
+
lines.forEach((rawLine, idx) => {
|
|
251
|
+
if (declPattern.test(rawLine))
|
|
252
|
+
return;
|
|
253
|
+
const line = stripStringLiterals(rawLine);
|
|
32
254
|
if (!callPattern.test(line))
|
|
33
255
|
return;
|
|
34
256
|
if (!line.includes("ctx.accounts.") && !fileContent.includes("ctx.accounts."))
|
|
35
257
|
return;
|
|
258
|
+
const offset = lineOffset(lines, idx);
|
|
259
|
+
if (inRanges(offset, model.testRanges))
|
|
260
|
+
return;
|
|
36
261
|
const windowStart = Math.max(0, idx - 10);
|
|
37
262
|
const window = lines.slice(windowStart, idx + 1).join(`
|
|
38
263
|
`);
|
|
39
264
|
const hasSignerEvidence = /is_signer/.test(window) || /Signer<\s*'info\s*>/.test(window) || /#\[account\([^)]*signer[^)]*\)\]/i.test(window);
|
|
40
|
-
if (
|
|
41
|
-
|
|
265
|
+
if (hasSignerEvidence)
|
|
266
|
+
return;
|
|
267
|
+
const struct = structAt(model, offset);
|
|
268
|
+
const extWindow = lines.slice(windowStart, Math.min(lines.length, idx + 16)).join(`
|
|
269
|
+
`);
|
|
270
|
+
if (/\binvoke_signed\b/.test(extWindow)) {
|
|
271
|
+
if (struct && !struct.fields.some((f) => f.isSignerTyped)) {
|
|
272
|
+
const callBlock = lines.slice(idx, Math.min(lines.length, idx + 8)).join(`
|
|
273
|
+
`);
|
|
274
|
+
if (/system_instruction::transfer|::transfer(?:_checked)?\s*\(/.test(callBlock)) {
|
|
275
|
+
const refs = [...callBlock.matchAll(/ctx\.accounts\.(\w+)/g)].map((m) => m[1]);
|
|
276
|
+
const toField = refs.length >= 2 ? findField(model, refs[1]) : null;
|
|
277
|
+
if (toField && toField.isRaw && !toField.validated) {
|
|
278
|
+
findings.push(makeFinding(missingSignerCheck, filePath, idx + 1, rawLine, `This instruction PDA-signs a transfer whose destination (\`${refs[1]}\`) is a caller-supplied unchecked account, and its Accounts struct has no Signer — anyone can call it and drain the vault. Require an authority Signer and bind the recipient to validated state.`));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
42
283
|
}
|
|
284
|
+
if (/\bnew_with_signer\b/.test(extWindow))
|
|
285
|
+
return;
|
|
286
|
+
if (/system_instruction::transfer/.test(line) && /\binvoke(?:_signed)?\s*\(/.test(extWindow)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!line.includes("::")) {
|
|
290
|
+
const beforeMatch = line.slice(0, line.search(callPattern));
|
|
291
|
+
if (/\binvoke(?:_signed)?\s*\([^)]*$/.test(beforeMatch))
|
|
292
|
+
return;
|
|
293
|
+
const prevLines = lines.slice(Math.max(0, idx - 3), idx);
|
|
294
|
+
if (prevLines.some((l) => /\binvoke(?:_signed)?\s*\([^)]*$/.test(l)))
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (struct && struct.fields.some((f) => f.isSignerTyped))
|
|
298
|
+
return;
|
|
299
|
+
findings.push(makeFinding(missingSignerCheck, filePath, idx + 1, rawLine, "Verify the authority/owner account is a Signer or explicitly check `.is_signer` before transferring or minting tokens."));
|
|
43
300
|
});
|
|
44
301
|
return findings;
|
|
45
302
|
}
|
|
@@ -53,14 +310,25 @@ var uncheckedDeserialization = {
|
|
|
53
310
|
const findings = [];
|
|
54
311
|
const lines = splitLines(fileContent);
|
|
55
312
|
const pattern = /(Account|AccountLoader)::try_from\s*\(/;
|
|
313
|
+
const model = getConstraintModel(fileContent);
|
|
56
314
|
lines.forEach((line, idx) => {
|
|
57
315
|
if (!pattern.test(line))
|
|
58
316
|
return;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
317
|
+
const offset = lineOffset(lines, idx);
|
|
318
|
+
if (inRanges(offset, model.testRanges))
|
|
319
|
+
return;
|
|
320
|
+
const windowStart = Math.max(0, idx - 3);
|
|
321
|
+
const window = lines.slice(windowStart, idx + 1).join(`
|
|
322
|
+
`);
|
|
323
|
+
if (/#\[account\(/.test(window))
|
|
324
|
+
return;
|
|
325
|
+
const argMatch = line.match(/::try_from\s*\(\s*&?\s*(?:ctx\.accounts\.|self\.)?(\w+)/);
|
|
326
|
+
if (argMatch) {
|
|
327
|
+
const field = findField(model, argMatch[1]);
|
|
328
|
+
if (field && field.validated)
|
|
329
|
+
return;
|
|
63
330
|
}
|
|
331
|
+
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."));
|
|
64
332
|
});
|
|
65
333
|
return findings;
|
|
66
334
|
}
|
|
@@ -73,17 +341,35 @@ var arbitraryCpi = {
|
|
|
73
341
|
check(fileContent, filePath) {
|
|
74
342
|
const findings = [];
|
|
75
343
|
const lines = splitLines(fileContent);
|
|
76
|
-
const cpiPattern = /\b(invoke|invoke_signed)\s*\(/;
|
|
344
|
+
const cpiPattern = /\b(?:invoke|invoke_signed)\s*\(/;
|
|
345
|
+
const model = getConstraintModel(fileContent);
|
|
77
346
|
lines.forEach((line, idx) => {
|
|
78
347
|
if (!cpiPattern.test(line))
|
|
79
348
|
return;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
349
|
+
const offset = lineOffset(lines, idx);
|
|
350
|
+
if (inRanges(offset, model.testRanges))
|
|
351
|
+
return;
|
|
352
|
+
const windowStart = Math.max(0, idx - 12);
|
|
353
|
+
const windowEnd = Math.min(lines.length, idx + 12);
|
|
354
|
+
const window = stripLineComments(lines.slice(windowStart, windowEnd).join(`
|
|
355
|
+
`));
|
|
356
|
+
const suspectNames = new Set;
|
|
357
|
+
for (const m of window.matchAll(/program_id\s*:\s*[*&]?\s*(?:ctx\.accounts\.)?(\w+)/g)) {
|
|
358
|
+
suspectNames.add(m[1]);
|
|
359
|
+
}
|
|
360
|
+
for (const m of window.matchAll(/ctx\.accounts\.(\w*(?:^|_)program\w*)/g)) {
|
|
361
|
+
suspectNames.add(m[1]);
|
|
362
|
+
}
|
|
363
|
+
for (const name of suspectNames) {
|
|
364
|
+
const field = findField(model, name);
|
|
365
|
+
if (field && field.wrapperType === "Program")
|
|
366
|
+
continue;
|
|
367
|
+
if (field && !field.isRaw)
|
|
368
|
+
continue;
|
|
369
|
+
if (!field && /Program<\s*'info/.test(window))
|
|
370
|
+
continue;
|
|
86
371
|
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."));
|
|
372
|
+
break;
|
|
87
373
|
}
|
|
88
374
|
});
|
|
89
375
|
return findings;
|
|
@@ -93,24 +379,63 @@ var missingBumpCanonicalization = {
|
|
|
93
379
|
id: "SOL-004",
|
|
94
380
|
name: "Missing Bump Seed Canonicalization",
|
|
95
381
|
severity: "HIGH",
|
|
96
|
-
description: "PDA bump seeds must be derived via `find_program_address`
|
|
382
|
+
description: "PDA bump seeds must be derived via `find_program_address` (which returns the canonical bump by construction) and stored/validated on-chain. Calling `create_program_address` directly with a caller-supplied bump allows bump-seed forgery attacks.",
|
|
97
383
|
check(fileContent, filePath) {
|
|
98
384
|
const findings = [];
|
|
99
385
|
const lines = splitLines(fileContent);
|
|
386
|
+
const model = getConstraintModel(fileContent);
|
|
100
387
|
lines.forEach((line, idx) => {
|
|
101
|
-
if (
|
|
102
|
-
|
|
388
|
+
if (!/create_program_address\s*\(/.test(line))
|
|
389
|
+
return;
|
|
390
|
+
const offset = lineOffset(lines, idx);
|
|
391
|
+
if (inRanges(offset, model.testRanges))
|
|
103
392
|
return;
|
|
393
|
+
const windowStart = Math.max(0, idx - 10);
|
|
394
|
+
const windowEnd = Math.min(lines.length, idx + 3);
|
|
395
|
+
const window = stripLineComments(lines.slice(windowStart, windowEnd).join(`
|
|
396
|
+
`));
|
|
397
|
+
if (/find_program_address/.test(window))
|
|
398
|
+
return;
|
|
399
|
+
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."));
|
|
400
|
+
});
|
|
401
|
+
if (model.structs.length > 0) {
|
|
402
|
+
const offsets = [];
|
|
403
|
+
let acc = 0;
|
|
404
|
+
for (const l of lines) {
|
|
405
|
+
offsets.push(acc);
|
|
406
|
+
acc += l.length + 1;
|
|
104
407
|
}
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
408
|
+
for (const s of model.structs) {
|
|
409
|
+
const regions = model.regions.filter((r) => r.contextType === s.name);
|
|
410
|
+
const regionText = regions.length > 0 ? regions.map((r) => fileContent.slice(r.start, r.end)).join(`
|
|
411
|
+
`) : fileContent;
|
|
412
|
+
const movesValue = /system_instruction::transfer|token::transfer|try_borrow_mut_lamports|lamports\.borrow_mut/.test(stripLineComments(regionText));
|
|
413
|
+
if (!movesValue)
|
|
414
|
+
continue;
|
|
415
|
+
const inScope = (lineIdx) => {
|
|
416
|
+
if (regions.length === 0)
|
|
417
|
+
return true;
|
|
418
|
+
const off = offsets[lineIdx];
|
|
419
|
+
return regions.some((r) => off >= r.start && off < r.end);
|
|
420
|
+
};
|
|
421
|
+
for (const f of s.fields) {
|
|
422
|
+
if (!f.isRaw || f.validated)
|
|
423
|
+
continue;
|
|
424
|
+
if (!/\bmut\b/.test(f.attrs))
|
|
425
|
+
continue;
|
|
426
|
+
if (!/vault|treasury|pool|escrow|fund|bank|reward/i.test(f.name))
|
|
427
|
+
continue;
|
|
428
|
+
for (let i = 0;i < lines.length; i++) {
|
|
429
|
+
if (!inScope(i))
|
|
430
|
+
continue;
|
|
431
|
+
if (!lines[i].includes(`ctx.accounts.${f.name}`))
|
|
432
|
+
continue;
|
|
433
|
+
findings.push(makeFinding(missingBumpCanonicalization, filePath, i + 1, lines[i], `\`${f.name}\` is a mutable unchecked account used in value movement with no \`seeds\` binding. Verify it against its derived PDA (\`seeds = [...], bump\`) or pin it with \`address =\` — otherwise the caller can substitute an attacker-controlled account and redirect the funds.`));
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
111
436
|
}
|
|
112
437
|
}
|
|
113
|
-
}
|
|
438
|
+
}
|
|
114
439
|
return findings;
|
|
115
440
|
}
|
|
116
441
|
};
|
|
@@ -118,19 +443,32 @@ var integerOverflowRisk = {
|
|
|
118
443
|
id: "SOL-005",
|
|
119
444
|
name: "Integer Overflow Risk",
|
|
120
445
|
severity: "MEDIUM",
|
|
121
|
-
description: "Raw arithmetic (`+`, `-`, `*`) was
|
|
446
|
+
description: "Raw arithmetic (`+`, `-`, `*`) was written into account state (a field assignment or compound assignment) on amount-like values without 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.",
|
|
122
447
|
check(fileContent, filePath) {
|
|
123
448
|
const findings = [];
|
|
124
449
|
const lines = splitLines(fileContent);
|
|
125
|
-
|
|
450
|
+
if (isTestLikePath(filePath))
|
|
451
|
+
return findings;
|
|
452
|
+
const model = getConstraintModel(fileContent);
|
|
453
|
+
const arithmeticPattern = /\b(\w*(amount|balance|supply|total|shares|stake|reserve|collateral|liquidity|principal)\w*)\s*(\+|\-|\*)\s*\w/i;
|
|
126
454
|
const alreadySafe = /checked_(add|sub|mul|div)|saturating_(add|sub|mul)|wrapping_(add|sub|mul)/;
|
|
455
|
+
const stateWritePattern = /^\s*(?:[\w.\[\]]+\.)[\w.\[\]]+\s*(?:=|\+=|-=|\*=)\s*[^=>]|\b\w+\s*(?:\+=|-=|\*=)\s*[^=]/;
|
|
127
456
|
lines.forEach((line, idx) => {
|
|
128
457
|
if (alreadySafe.test(line))
|
|
129
458
|
return;
|
|
130
459
|
if (line.trim().startsWith("//"))
|
|
131
460
|
return;
|
|
132
|
-
if (
|
|
133
|
-
|
|
461
|
+
if (/\d\.\d/.test(line))
|
|
462
|
+
return;
|
|
463
|
+
const offset = lineOffset(lines, idx);
|
|
464
|
+
if (inRanges(offset, model.testRanges))
|
|
465
|
+
return;
|
|
466
|
+
const code = stripStringLiterals(line);
|
|
467
|
+
if (arithmeticPattern.test(code) && stateWritePattern.test(code)) {
|
|
468
|
+
findings.push(makeFinding(integerOverflowRisk, filePath, idx + 1, line, "Replace raw arithmetic written to state with `.checked_add()/.checked_sub()/.checked_mul()` (propagating errors via `ok_or(ErrorCode::MathOverflow)?`) or `saturating_*` equivalents."));
|
|
469
|
+
}
|
|
470
|
+
if (/\.pow\s*\([^)]*[a-zA-Z_]/.test(code) && !/checked_pow|saturating|wrapping/.test(code)) {
|
|
471
|
+
findings.push(makeFinding(integerOverflowRisk, filePath, idx + 1, line, "Exponentiation with a non-constant exponent overflows silently and quickly (2^64 wraps a u64 to 0). Use `checked_pow` with error propagation, or bound the exponent explicitly."));
|
|
134
472
|
}
|
|
135
473
|
});
|
|
136
474
|
return findings;
|
|
@@ -174,10 +512,11 @@ var insecureInit = {
|
|
|
174
512
|
continue;
|
|
175
513
|
const hasPayer = /\bpayer\s*=/.test(block);
|
|
176
514
|
const hasSpace = /\bspace\s*=/.test(block);
|
|
177
|
-
|
|
515
|
+
const isTokenInit = /\b(associated_token|token)::/.test(block);
|
|
516
|
+
if (!hasPayer || !hasSpace && !isTokenInit) {
|
|
178
517
|
const upToMatch = fileContent.slice(0, match.index);
|
|
179
518
|
const line = upToMatch.split(/\r\n|\r|\n/).length;
|
|
180
|
-
const missing = [!hasPayer && "payer", !hasSpace && "space"].filter(Boolean).join(" and ");
|
|
519
|
+
const missing = [!hasPayer && "payer", !hasSpace && !isTokenInit && "space"].filter(Boolean).join(" and ");
|
|
181
520
|
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.`));
|
|
182
521
|
}
|
|
183
522
|
}
|
|
@@ -188,10 +527,64 @@ var missingOwnerCheck = {
|
|
|
188
527
|
id: "SOL-008",
|
|
189
528
|
name: "Missing Owner Check",
|
|
190
529
|
severity: "LOW",
|
|
191
|
-
description: "A raw `AccountInfo` field
|
|
530
|
+
description: "A raw `AccountInfo`/`UncheckedAccount` field carries no `owner`/`constraint`/`has_one` binding in its Accounts struct and no `.owner` comparison anywhere in the file. Raw accounts bypass Anchor's automatic ownership validation, so the program must manually verify `account.owner == expected_program_id` before trusting its data.",
|
|
192
531
|
check(fileContent, filePath) {
|
|
193
532
|
const findings = [];
|
|
194
533
|
const lines = splitLines(fileContent);
|
|
534
|
+
const model = getConstraintModel(fileContent);
|
|
535
|
+
if (model.structs.length > 0) {
|
|
536
|
+
const codeOnly = stripLineComments(fileContent);
|
|
537
|
+
const offsets = [];
|
|
538
|
+
{
|
|
539
|
+
let off = 0;
|
|
540
|
+
for (const l of lines) {
|
|
541
|
+
offsets.push(off);
|
|
542
|
+
off += l.length + 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const touchPattern = /try_borrow_data|try_borrow_mut_data|try_borrow_mut_lamports|\.load\s*\(|data\.borrow|try_from|\.lamports\s*\(\s*\)|accessor::/;
|
|
546
|
+
for (const s of model.structs) {
|
|
547
|
+
const regions = model.regions.filter((r) => r.contextType === s.name);
|
|
548
|
+
const inScope = (lineIdx) => {
|
|
549
|
+
if (regions.length === 0)
|
|
550
|
+
return true;
|
|
551
|
+
const off = offsets[lineIdx];
|
|
552
|
+
return regions.some((r) => off >= r.start && off < r.end);
|
|
553
|
+
};
|
|
554
|
+
for (const f of s.fields) {
|
|
555
|
+
if (!f.isRaw || f.validated)
|
|
556
|
+
continue;
|
|
557
|
+
const ownerCheck = new RegExp(`\\b${f.name}\\.owner\\s*==|require!\\([^)]*\\b${f.name}\\.owner\\b|require_keys_eq!\\([^)]*\\b${f.name}\\b`);
|
|
558
|
+
if (ownerCheck.test(codeOnly))
|
|
559
|
+
continue;
|
|
560
|
+
const aliases = new Set;
|
|
561
|
+
const aliasRe = new RegExp(`let\\s+(\\w+)\\s*=\\s*&?\\s*ctx\\.accounts\\.${f.name}\\b`, "g");
|
|
562
|
+
let am;
|
|
563
|
+
while ((am = aliasRe.exec(codeOnly)) !== null)
|
|
564
|
+
aliases.add(am[1]);
|
|
565
|
+
let reportLine = -1;
|
|
566
|
+
let snippet = "";
|
|
567
|
+
for (let i = 0;i < lines.length; i++) {
|
|
568
|
+
if (!inScope(i))
|
|
569
|
+
continue;
|
|
570
|
+
const l = lines[i];
|
|
571
|
+
const direct = l.includes(`ctx.accounts.${f.name}`);
|
|
572
|
+
const aliasTouch = [...aliases].some((a) => new RegExp(`\\b${a}\\s*\\.`).test(l) && touchPattern.test(l));
|
|
573
|
+
const directTouch = direct && touchPattern.test(l);
|
|
574
|
+
const keyAssign = direct && new RegExp(`=\\s*&?\\s*ctx\\.accounts\\.${f.name}\\.key\\s*\\(`).test(l) && !/==|require_keys_eq/.test(l);
|
|
575
|
+
if (aliasTouch || directTouch || keyAssign) {
|
|
576
|
+
reportLine = i + 1;
|
|
577
|
+
snippet = l;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (reportLine === -1)
|
|
582
|
+
continue;
|
|
583
|
+
findings.push(makeFinding(missingOwnerCheck, filePath, reportLine, snippet, `\`${f.name}\` is a raw AccountInfo with no constraint binding. Add an explicit \`require!(ctx.accounts.${f.name}.owner == &expected_program_id, ErrorCode::InvalidOwner)\` check (or an \`#[account(owner = ...)]\` constraint) before trusting its data.`));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return findings;
|
|
587
|
+
}
|
|
195
588
|
const rawFieldPattern = /pub\s+(\w+)\s*:\s*AccountInfo<\s*'info\s*>/;
|
|
196
589
|
const rawFields = new Set;
|
|
197
590
|
lines.forEach((line) => {
|
|
@@ -201,8 +594,11 @@ var missingOwnerCheck = {
|
|
|
201
594
|
});
|
|
202
595
|
if (rawFields.size === 0)
|
|
203
596
|
return findings;
|
|
597
|
+
const flagged = new Set;
|
|
204
598
|
lines.forEach((line, idx) => {
|
|
205
599
|
for (const field of rawFields) {
|
|
600
|
+
if (flagged.has(field))
|
|
601
|
+
continue;
|
|
206
602
|
const usagePattern = new RegExp(`ctx\\.accounts\\.${field}\\b`);
|
|
207
603
|
if (!usagePattern.test(line))
|
|
208
604
|
continue;
|
|
@@ -212,6 +608,7 @@ var missingOwnerCheck = {
|
|
|
212
608
|
`);
|
|
213
609
|
const hasOwnerCheck = new RegExp(`${field}\\.owner`).test(window) || /\.owner\s*==/.test(window);
|
|
214
610
|
if (!hasOwnerCheck) {
|
|
611
|
+
flagged.add(field);
|
|
215
612
|
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.`));
|
|
216
613
|
}
|
|
217
614
|
}
|
|
@@ -227,7 +624,7 @@ var insecureAccountClosure = {
|
|
|
227
624
|
check(fileContent, filePath) {
|
|
228
625
|
const findings = [];
|
|
229
626
|
const lines = splitLines(fileContent);
|
|
230
|
-
const zeroLamportsPattern =
|
|
627
|
+
const zeroLamportsPattern = /(?:\.lamports\.borrow_mut\(\)|\.try_borrow_mut_lamports\(\)\??)\s*=\s*0\s*;/;
|
|
231
628
|
lines.forEach((line, idx) => {
|
|
232
629
|
if (!zeroLamportsPattern.test(line))
|
|
233
630
|
return;
|
|
@@ -344,6 +741,30 @@ var missingRentExemptCheck = {
|
|
|
344
741
|
return findings;
|
|
345
742
|
}
|
|
346
743
|
};
|
|
744
|
+
var unboundTokenMint = {
|
|
745
|
+
id: "SOL-013",
|
|
746
|
+
name: "Token Account Not Bound to Mint",
|
|
747
|
+
severity: "MEDIUM",
|
|
748
|
+
description: "A token-account field and a mint field coexist in the same Accounts struct, but the token account is never bound to the mint (`token::mint = ...`, `associated_token::mint = ...`, or a constraint/address pin). The instruction may accept a token account of an unintended mint — the vulnerability class behind the Cashio infinite-mint exploit. Bind the token account to the mint in the `#[account(...)]` constraint.",
|
|
749
|
+
check(fileContent, filePath) {
|
|
750
|
+
const findings = [];
|
|
751
|
+
const lines = splitLines(fileContent);
|
|
752
|
+
const model = getConstraintModel(fileContent);
|
|
753
|
+
for (const s of model.structs) {
|
|
754
|
+
const mintFields = s.fields.filter((f) => f.isMint);
|
|
755
|
+
if (mintFields.length === 0)
|
|
756
|
+
continue;
|
|
757
|
+
for (const f of s.fields) {
|
|
758
|
+
if (!f.isTokenAccount)
|
|
759
|
+
continue;
|
|
760
|
+
if (f.bindsTokenMint || f.hasInit || f.validated)
|
|
761
|
+
continue;
|
|
762
|
+
findings.push(makeFinding(unboundTokenMint, filePath, f.line, lines[f.line - 1] || f.name, `\`${f.name}\` is a TokenAccount but is never bound to a mint, although this instruction also takes a mint account (${mintFields.map((m) => `\`${m.name}\``).join(", ")}). Add \`token::mint = ${mintFields[0].name}\` (and usually \`token::authority\`) to the field's #[account(...)] constraint so a token account of an unintended mint is rejected.`));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return findings;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
347
768
|
var rules = [
|
|
348
769
|
missingSignerCheck,
|
|
349
770
|
uncheckedDeserialization,
|
|
@@ -357,7 +778,7 @@ var rules = [
|
|
|
357
778
|
initIfNeededReinit,
|
|
358
779
|
missingRentExemptCheck
|
|
359
780
|
];
|
|
360
|
-
var experimentalRules = [duplicateMutableAccounts];
|
|
781
|
+
var experimentalRules = [duplicateMutableAccounts, unboundTokenMint];
|
|
361
782
|
// ../packages/core/src/scanner.ts
|
|
362
783
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
363
784
|
import { join } from "node:path";
|
|
@@ -377,16 +798,36 @@ function scanFile(filePath, rules) {
|
|
|
377
798
|
const content = readFileSync(filePath, "utf8");
|
|
378
799
|
return scanContent(content, filePath, rules);
|
|
379
800
|
}
|
|
380
|
-
|
|
801
|
+
var EXCLUDED_DIR_NAMES = new Set([
|
|
802
|
+
"tests",
|
|
803
|
+
"test",
|
|
804
|
+
"test-utils",
|
|
805
|
+
"test_utils",
|
|
806
|
+
"testing",
|
|
807
|
+
"fuzz",
|
|
808
|
+
"fixtures",
|
|
809
|
+
"fixture",
|
|
810
|
+
"benches",
|
|
811
|
+
"benchmarks",
|
|
812
|
+
"examples",
|
|
813
|
+
"example"
|
|
814
|
+
]);
|
|
815
|
+
function walk(dirPath, files = [], includeTests = false, rootPath) {
|
|
816
|
+
const root = rootPath ?? dirPath;
|
|
381
817
|
const entries = readdirSync(dirPath);
|
|
382
818
|
for (const entry of entries) {
|
|
383
819
|
if (entry === "node_modules" || entry === "target" || entry === ".git")
|
|
384
820
|
continue;
|
|
821
|
+
if (!includeTests && EXCLUDED_DIR_NAMES.has(entry.toLowerCase()))
|
|
822
|
+
continue;
|
|
385
823
|
const fullPath = join(dirPath, entry);
|
|
386
824
|
const stat = statSync(fullPath);
|
|
387
825
|
if (stat.isDirectory()) {
|
|
388
|
-
walk(fullPath, files);
|
|
826
|
+
walk(fullPath, files, includeTests, root);
|
|
389
827
|
} else if (stat.isFile() && fullPath.endsWith(".rs")) {
|
|
828
|
+
const rel = fullPath.slice(root.length).replace(/^[\/\\]/, "");
|
|
829
|
+
if (!includeTests && isTestLikePath(rel))
|
|
830
|
+
continue;
|
|
390
831
|
files.push(fullPath);
|
|
391
832
|
}
|
|
392
833
|
}
|
|
@@ -434,9 +875,9 @@ function sortAndSummarize(rsFileCount, allFindings) {
|
|
|
434
875
|
summary: tallySummary(allFindings)
|
|
435
876
|
};
|
|
436
877
|
}
|
|
437
|
-
async function scanDirectory(targetPath, rules) {
|
|
878
|
+
async function scanDirectory(targetPath, rules, opts = {}) {
|
|
438
879
|
const stat = statSync(targetPath);
|
|
439
|
-
const rsFiles = stat.isDirectory() ? walk(targetPath) : targetPath.endsWith(".rs") ? [targetPath] : [];
|
|
880
|
+
const rsFiles = stat.isDirectory() ? walk(targetPath, [], opts.includeTests ?? false) : targetPath.endsWith(".rs") ? [targetPath] : [];
|
|
440
881
|
const allFindings = [];
|
|
441
882
|
for (const file of rsFiles) {
|
|
442
883
|
allFindings.push(...scanFile(file, rules));
|
|
@@ -475,7 +916,7 @@ function badge(severity) {
|
|
|
475
916
|
var SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"];
|
|
476
917
|
function printReport(result) {
|
|
477
918
|
const timestamp = new Date().toISOString();
|
|
478
|
-
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.
|
|
919
|
+
console.log(`${BOLD}${CYAN}SolAudit${RESET} ${DIM}v0.3.2 — static analysis for Anchor/Solana programs${RESET}`);
|
|
479
920
|
console.log(`${DIM}Scanned ${result.scannedFiles} file(s) at ${timestamp}${RESET}`);
|
|
480
921
|
console.log(`${GRAY}${"─".repeat(70)}${RESET}`);
|
|
481
922
|
if (result.findings.length === 0) {
|
|
@@ -589,6 +1030,8 @@ Options:
|
|
|
589
1030
|
(CRITICAL | HIGH | MEDIUM | LOW | INFO)
|
|
590
1031
|
--experimental Also run experimental rules (e.g. SOL-012 duplicate
|
|
591
1032
|
mutable accounts). Higher signal, more false positives.
|
|
1033
|
+
--include-tests Also scan test/fuzz/fixture/example code (skipped by
|
|
1034
|
+
default; findings there are rarely actionable)
|
|
592
1035
|
--help Show this help message
|
|
593
1036
|
|
|
594
1037
|
Examples:
|
|
@@ -610,6 +1053,7 @@ async function main() {
|
|
|
610
1053
|
const wantsJson = args.includes("--json");
|
|
611
1054
|
const wantsSarif = args.includes("--sarif");
|
|
612
1055
|
const wantsExperimental = args.includes("--experimental");
|
|
1056
|
+
const includeTests = args.includes("--include-tests");
|
|
613
1057
|
let minSeverity = "INFO";
|
|
614
1058
|
const minSeverityIdx = args.indexOf("--min-severity");
|
|
615
1059
|
if (minSeverityIdx !== -1) {
|
|
@@ -630,7 +1074,7 @@ async function main() {
|
|
|
630
1074
|
});
|
|
631
1075
|
const targetPath = positional[0] || ".";
|
|
632
1076
|
const activeRules = wantsExperimental ? [...rules, ...experimentalRules] : rules;
|
|
633
|
-
const result = await scanDirectory(targetPath, activeRules);
|
|
1077
|
+
const result = await scanDirectory(targetPath, activeRules, { includeTests });
|
|
634
1078
|
result.findings = result.findings.filter((finding) => SEVERITY_RANK[finding.severity] <= SEVERITY_RANK[minSeverity]);
|
|
635
1079
|
if (wantsSarif) {
|
|
636
1080
|
console.log(JSON.stringify(toSarif(result), null, 2));
|