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/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 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.",
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
- lines.forEach((line, idx) => {
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 (!hasSignerEvidence) {
41
- 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."));
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 prevLine = idx > 0 ? lines[idx - 1] : "";
60
- const hasConstraint = /#\[account\(/.test(prevLine) || /#\[account\(/.test(line);
61
- if (!hasConstraint) {
62
- 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."));
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 windowStart = Math.max(0, idx - 3);
81
- const windowEnd = Math.min(lines.length, idx + 4);
82
- const window = lines.slice(windowStart, windowEnd).join(`
83
- `);
84
- const usesAccountsAsProgram = /ctx\.accounts\.\w+\.(to_account_info\(\)|key\(\))/.test(window) && !/Program<\s*'info/.test(window);
85
- if (usesAccountsAsProgram) {
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` 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.",
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 (/create_program_address\s*\(/.test(line) && !/find_program_address/.test(line)) {
102
- 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."));
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 fpaMatch = line.match(/let\s+(\([^)]*\)|\w+)\s*=\s*[\w:]*find_program_address\s*\(/);
106
- if (fpaMatch) {
107
- const binding = fpaMatch[1];
108
- const bindsBump = /\(\s*\w+\s*,\s*\w+\s*\)/.test(binding);
109
- if (!bindsBump) {
110
- 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."));
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 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.",
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
- const arithmeticPattern = /\b(\w*(amount|balance|supply|total|shares|stake)\w*)\s*(\+|\-|\*)\s*\w/i;
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 (arithmeticPattern.test(line)) {
133
- 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."));
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
- if (!hasPayer || !hasSpace) {
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 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.",
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 = /\.lamports\.borrow_mut\(\)\s*=\s*0\s*;/;
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
- function walk(dirPath, files = []) {
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.0 — static analysis for Anchor/Solana programs${RESET}`);
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));