brainblast 0.7.1 → 0.7.3
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/{chunk-IY52XKWL.js → chunk-5H5JQXXU.js} +56 -1
- package/dist/{chunk-B2M3TZSA.js → chunk-5VYTURTO.js} +125 -1
- package/dist/cli.js +440 -15
- package/dist/index.d.ts +45 -1
- package/dist/index.js +16 -2
- package/dist/{mcp-AM7MTCSZ.js → mcp-EOTWSQK7.js} +1 -1
- package/dist/rules/cpi-target-program-unverified.yaml +41 -0
- package/package.json +1 -1
|
@@ -996,6 +996,60 @@ function anchorBodyCallPattern(c, p) {
|
|
|
996
996
|
};
|
|
997
997
|
}
|
|
998
998
|
|
|
999
|
+
// src/checkers/anchorCpiUnverifiedProgram.ts
|
|
1000
|
+
var DEFAULT_PROGRAM_NAME = "(^|_)program$|^program(_id)?$";
|
|
1001
|
+
var DEFAULT_CPI = "invoke_signed\\s*\\(|invoke\\s*\\(|CpiContext::|\\.cpi\\(\\)|solana_program::program::invoke";
|
|
1002
|
+
var DEFAULT_VERIFIED_TYPE = "Program\\s*<";
|
|
1003
|
+
var DEFAULT_ADDRESS_CONSTRAINT = "address\\s*=";
|
|
1004
|
+
var RAW_TYPES = ["AccountInfo", "UncheckedAccount"];
|
|
1005
|
+
function isRawAccountType(typeName) {
|
|
1006
|
+
return RAW_TYPES.some((t) => typeName.includes(t));
|
|
1007
|
+
}
|
|
1008
|
+
function isProgramNamed(name, re) {
|
|
1009
|
+
return re.test(name);
|
|
1010
|
+
}
|
|
1011
|
+
function anchorCpiUnverifiedProgram(c, p) {
|
|
1012
|
+
const programNameRe = new RegExp(p?.programNameRegex ?? DEFAULT_PROGRAM_NAME, "i");
|
|
1013
|
+
const cpiRe = new RegExp(p?.cpiRegex ?? DEFAULT_CPI);
|
|
1014
|
+
const verifiedTypeRe = new RegExp(p?.verifiedTypeRegex ?? DEFAULT_VERIFIED_TYPE);
|
|
1015
|
+
const addressRe = new RegExp(p?.addressConstraintRegex ?? DEFAULT_ADDRESS_CONSTRAINT);
|
|
1016
|
+
const body = c.fnBodyText ?? "";
|
|
1017
|
+
const performsCpi = cpiRe.test(body);
|
|
1018
|
+
if (!performsCpi) {
|
|
1019
|
+
return {
|
|
1020
|
+
result: "cant_tell",
|
|
1021
|
+
detail: p?.absentDetail ?? `Handler '${c.fnName}' performs no cross-program invocation; CPI program-ID verification does not apply.`
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const programFields = c.accountFields.filter((f) => isProgramNamed(f.name, programNameRe));
|
|
1025
|
+
if (programFields.length === 0) {
|
|
1026
|
+
return {
|
|
1027
|
+
result: "cant_tell",
|
|
1028
|
+
detail: p?.absentDetail ?? `Handler '${c.fnName}' performs a CPI but no program-named account field was found on '${c.accountStructName}' to verify.`
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const isVerified = (f) => {
|
|
1032
|
+
if (verifiedTypeRe.test(f.typeName)) return true;
|
|
1033
|
+
if (!isRawAccountType(f.typeName)) return true;
|
|
1034
|
+
if (addressRe.test(f.attrText)) return true;
|
|
1035
|
+
const keyCheck = new RegExp(`${f.name}\\.key\\s*\\(\\s*\\)`);
|
|
1036
|
+
if (keyCheck.test(body)) return true;
|
|
1037
|
+
return false;
|
|
1038
|
+
};
|
|
1039
|
+
const unverified = programFields.filter((f) => !isVerified(f));
|
|
1040
|
+
if (unverified.length > 0) {
|
|
1041
|
+
const names = unverified.map((f) => `${f.name}: ${f.typeName}`).join(", ");
|
|
1042
|
+
return {
|
|
1043
|
+
result: "fail",
|
|
1044
|
+
detail: p?.failDetail ?? `Handler '${c.fnName}' performs a cross-program invocation but the target program account(s) [${names}] are raw AccountInfo/UncheckedAccount with no address verification. An attacker can substitute a malicious program \u2014 this is the Wormhole ($325M, Feb 2022) class of bug. Type the account as Program<'info, T> or add #[account(address = <expected_program_id>)].`
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
result: "pass",
|
|
1049
|
+
detail: p?.passDetail ?? `Handler '${c.fnName}' performs a CPI and every program account (${programFields.map((f) => f.name).join(", ")}) is identity-verified (Program<'info, _>, address= constraint, or in-body key check).`
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
999
1053
|
// src/checkers/index.ts
|
|
1000
1054
|
var registry = {
|
|
1001
1055
|
"positional-arg-identity": positionalArgIdentity,
|
|
@@ -1013,7 +1067,8 @@ var registry = {
|
|
|
1013
1067
|
"anchor-account-matches-idl": anchorIdlAccount,
|
|
1014
1068
|
"anchor-account-missing-constraint": anchorAccountMissingConstraint,
|
|
1015
1069
|
"anchor-forbidden-account-type": anchorForbiddenAccountType,
|
|
1016
|
-
"anchor-body-call-pattern": anchorBodyCallPattern
|
|
1070
|
+
"anchor-body-call-pattern": anchorBodyCallPattern,
|
|
1071
|
+
"anchor-cpi-unverified-program": anchorCpiUnverifiedProgram
|
|
1017
1072
|
};
|
|
1018
1073
|
function runChecker(kind, c, params) {
|
|
1019
1074
|
const fn = registry[kind];
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
loadPack,
|
|
8
8
|
resolveRules,
|
|
9
9
|
walk
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-5H5JQXXU.js";
|
|
11
11
|
|
|
12
12
|
// src/costAnalysis.ts
|
|
13
13
|
import { Project, SyntaxKind } from "ts-morph";
|
|
@@ -252,6 +252,123 @@ function renderCostReportMd(r) {
|
|
|
252
252
|
return lines.join("\n");
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// src/exploitPatterns.ts
|
|
256
|
+
var EXPLOIT_PATTERNS = [
|
|
257
|
+
{
|
|
258
|
+
id: "wormhole",
|
|
259
|
+
title: "Wormhole bridge exploit",
|
|
260
|
+
date: "2022-02-02",
|
|
261
|
+
lossUsd: 325e6,
|
|
262
|
+
chain: "Solana",
|
|
263
|
+
postmortemUrl: "https://rekt.news/wormhole-rekt/",
|
|
264
|
+
rootCause: "A cross-program invocation / signature-verification load trusted an account by position, not by identity. A deprecated load did not verify the supplied sysvar's address, so an attacker substituted their own account and forged a 'verified' signature set.",
|
|
265
|
+
ruleId: "cpi-target-program-unverified",
|
|
266
|
+
detects: "An Anchor handler that performs a CPI (invoke / invoke_signed / CpiContext) against a program-named account typed as raw AccountInfo/UncheckedAccount with no address= constraint and no in-body key check.",
|
|
267
|
+
fix: "Type the program account as Program<'info, T> (Anchor verifies the program id on deserialization) or add #[account(address = <expected_program_id>)]."
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "cashio",
|
|
271
|
+
title: "Cashio infinite-mint exploit",
|
|
272
|
+
date: "2022-03-23",
|
|
273
|
+
lossUsd: 48e6,
|
|
274
|
+
chain: "Solana",
|
|
275
|
+
postmortemUrl: "https://rekt.news/cashio-rekt/",
|
|
276
|
+
rootCause: "A missing account-validation chain: the program accepted attacker-supplied accounts without verifying their owner/identity, letting a fake collateral account mint unbacked CASH.",
|
|
277
|
+
ruleId: "anchor-unchecked-account-type",
|
|
278
|
+
detects: "UncheckedAccount<'info> fields in instruction handlers \u2014 accounts Anchor performs zero runtime validation on (owner, signer, data layout all unchecked).",
|
|
279
|
+
fix: "Replace UncheckedAccount with Account<'info, T>, Signer<'info>, or SystemAccount<'info> so Anchor enforces ownership and type before the handler runs."
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: "crema",
|
|
283
|
+
title: "Crema Finance exploit",
|
|
284
|
+
date: "2022-07-03",
|
|
285
|
+
lossUsd: 88e5,
|
|
286
|
+
chain: "Solana",
|
|
287
|
+
postmortemUrl: "https://rekt.news/crema-finance-rekt/",
|
|
288
|
+
rootCause: "A forged 'tick' account was passed into the program and accepted without identity validation, letting the attacker report fake fee data and drain liquidity.",
|
|
289
|
+
ruleId: "anchor-unchecked-account-type",
|
|
290
|
+
detects: "The same unchecked-account class: a security-critical account taken as UncheckedAccount with no ownership/type enforcement.",
|
|
291
|
+
fix: "Deserialize the account through a typed Account<'info, T> (or add an explicit owner/address constraint) so a forged account is rejected."
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: "fake-mint-impersonation",
|
|
295
|
+
title: "SPL mint impersonation (counterfeit tokens)",
|
|
296
|
+
chain: "Solana",
|
|
297
|
+
postmortemUrl: "https://solana.com/developers/guides/token-extensions/metadata-pointer",
|
|
298
|
+
rootCause: "A counterfeit SPL mint reuses a legitimate token's name/symbol/metadata; integrations that key off symbol or metadata instead of the canonical mint address route value to the wrong token.",
|
|
299
|
+
ruleId: "solana-token-impersonation",
|
|
300
|
+
detects: "Code that resolves or trusts a token by symbol/name rather than pinning the canonical mint address, against the bundled canonical-mint registry.",
|
|
301
|
+
fix: "Pin and compare the canonical mint address (Pubkey) for every supported token; never trust symbol/name/metadata as identity."
|
|
302
|
+
}
|
|
303
|
+
];
|
|
304
|
+
function getExploitPattern(idOrRule) {
|
|
305
|
+
return EXPLOIT_PATTERNS.find((e) => e.id === idOrRule || e.ruleId === idOrRule);
|
|
306
|
+
}
|
|
307
|
+
function formatUsd(n) {
|
|
308
|
+
if (n == null) return "\u2014";
|
|
309
|
+
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
|
310
|
+
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
|
|
311
|
+
if (n >= 1e3) return `$${(n / 1e3).toFixed(0)}K`;
|
|
312
|
+
return `$${n}`;
|
|
313
|
+
}
|
|
314
|
+
function totalLossUsd(patterns = EXPLOIT_PATTERNS) {
|
|
315
|
+
return patterns.reduce((s, e) => s + (e.lossUsd ?? 0), 0);
|
|
316
|
+
}
|
|
317
|
+
function renderExploitsMd(patterns = EXPLOIT_PATTERNS) {
|
|
318
|
+
const L = ["## Exploit Pattern Database\n"];
|
|
319
|
+
L.push(
|
|
320
|
+
"Real on-chain incidents, each mapped to the bundled brainblast rule that statically detects its root-cause pattern. The code that lost these funds is the exact code these rules fail on.\n"
|
|
321
|
+
);
|
|
322
|
+
L.push("| Incident | Date | Loss | Detecting rule |");
|
|
323
|
+
L.push("|----------|------|------|----------------|");
|
|
324
|
+
for (const e of patterns) {
|
|
325
|
+
L.push(
|
|
326
|
+
`| [${e.title}](${e.postmortemUrl}) | ${e.date ?? "ongoing"} | ${formatUsd(e.lossUsd)} | \`${e.ruleId}\` |`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const total = totalLossUsd(patterns);
|
|
330
|
+
L.push("");
|
|
331
|
+
L.push(`**Catalogued losses: ${formatUsd(total)} across ${patterns.length} patterns.**
|
|
332
|
+
`);
|
|
333
|
+
for (const e of patterns) {
|
|
334
|
+
L.push(`### ${e.title}${e.lossUsd ? ` \u2014 ${formatUsd(e.lossUsd)}` : ""}
|
|
335
|
+
`);
|
|
336
|
+
L.push(`- **Chain / date:** ${e.chain} \xB7 ${e.date ?? "ongoing class"}`);
|
|
337
|
+
L.push(`- **Post-mortem:** ${e.postmortemUrl}`);
|
|
338
|
+
L.push(`- **Root cause:** ${e.rootCause}`);
|
|
339
|
+
L.push(`- **Detected by:** \`${e.ruleId}\` \u2014 ${e.detects}`);
|
|
340
|
+
L.push(`- **Fix:** ${e.fix}`);
|
|
341
|
+
L.push("");
|
|
342
|
+
}
|
|
343
|
+
return L.join("\n");
|
|
344
|
+
}
|
|
345
|
+
function renderExploitsText(patterns = EXPLOIT_PATTERNS) {
|
|
346
|
+
const L = [];
|
|
347
|
+
L.push("\u2500\u2500 Exploit Pattern Database \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
348
|
+
L.push(" real incidents \u2192 the bundled rule that detects each root cause");
|
|
349
|
+
L.push("");
|
|
350
|
+
for (const e of patterns) {
|
|
351
|
+
L.push(` ${e.title} (${e.date ?? "ongoing"}, ${formatUsd(e.lossUsd)}, ${e.chain})`);
|
|
352
|
+
L.push(` rule: ${e.ruleId}`);
|
|
353
|
+
L.push(` cause: ${e.rootCause}`);
|
|
354
|
+
L.push("");
|
|
355
|
+
}
|
|
356
|
+
L.push(` catalogued losses: ${formatUsd(totalLossUsd(patterns))} across ${patterns.length} patterns`);
|
|
357
|
+
return L.join("\n");
|
|
358
|
+
}
|
|
359
|
+
function renderExploitDetailText(e) {
|
|
360
|
+
const L = [];
|
|
361
|
+
L.push(`\u2500\u2500 ${e.title} \u2500\u2500`);
|
|
362
|
+
L.push(` chain/date: ${e.chain} \xB7 ${e.date ?? "ongoing class"}`);
|
|
363
|
+
L.push(` loss: ${formatUsd(e.lossUsd)}`);
|
|
364
|
+
L.push(` postmortem: ${e.postmortemUrl}`);
|
|
365
|
+
L.push(` root cause: ${e.rootCause}`);
|
|
366
|
+
L.push(` detected by: ${e.ruleId}`);
|
|
367
|
+
L.push(` ${e.detects}`);
|
|
368
|
+
L.push(` fix: ${e.fix}`);
|
|
369
|
+
return L.join("\n");
|
|
370
|
+
}
|
|
371
|
+
|
|
255
372
|
// src/watch.ts
|
|
256
373
|
import { watch as fsWatch } from "fs";
|
|
257
374
|
function runIncrementalScan(targetDir, rules, emit) {
|
|
@@ -492,6 +609,13 @@ export {
|
|
|
492
609
|
lamportsToSol,
|
|
493
610
|
analyzeCosts,
|
|
494
611
|
renderCostReportMd,
|
|
612
|
+
EXPLOIT_PATTERNS,
|
|
613
|
+
getExploitPattern,
|
|
614
|
+
formatUsd,
|
|
615
|
+
totalLossUsd,
|
|
616
|
+
renderExploitsMd,
|
|
617
|
+
renderExploitsText,
|
|
618
|
+
renderExploitDetailText,
|
|
495
619
|
runIncrementalScan,
|
|
496
620
|
startWatch,
|
|
497
621
|
parseDiff,
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
EXPLOIT_PATTERNS,
|
|
3
4
|
analyzeCosts,
|
|
4
5
|
applyDiffToFile,
|
|
6
|
+
getExploitPattern,
|
|
5
7
|
initPack,
|
|
6
8
|
isTelemetryEnabled,
|
|
9
|
+
lamportsToSol,
|
|
7
10
|
parseDiff,
|
|
8
11
|
recordGraduationEvents,
|
|
9
12
|
renderCostReportMd,
|
|
13
|
+
renderExploitDetailText,
|
|
14
|
+
renderExploitsMd,
|
|
15
|
+
renderExploitsText,
|
|
16
|
+
rentExemptMinimum,
|
|
10
17
|
startWatch,
|
|
11
18
|
submitTelemetry,
|
|
12
19
|
telemetryFilePath,
|
|
13
20
|
validatePack
|
|
14
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-5VYTURTO.js";
|
|
15
22
|
import {
|
|
16
23
|
renderTrustGraphMd
|
|
17
24
|
} from "./chunk-2UZGWXIX.js";
|
|
@@ -25,7 +32,7 @@ import {
|
|
|
25
32
|
audit,
|
|
26
33
|
getChangedRanges,
|
|
27
34
|
resolveRules
|
|
28
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-5H5JQXXU.js";
|
|
29
36
|
import "./chunk-2XJORJPQ.js";
|
|
30
37
|
import "./chunk-O5Z4ZJHC.js";
|
|
31
38
|
import "./chunk-XSVQSK53.js";
|
|
@@ -36,7 +43,7 @@ import "./chunk-3RG5ZIWI.js";
|
|
|
36
43
|
|
|
37
44
|
// src/cli.ts
|
|
38
45
|
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
39
|
-
import { join as
|
|
46
|
+
import { join as join3 } from "path";
|
|
40
47
|
|
|
41
48
|
// src/memory.ts
|
|
42
49
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
@@ -109,6 +116,351 @@ function updateMemory(memory, checks2, now = /* @__PURE__ */ new Date()) {
|
|
|
109
116
|
return { memory: { schemaVersion: "1.0", lastRun, fixHistory }, precedents };
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
// src/deployPlan.ts
|
|
120
|
+
import { readFileSync as readFileSync2, readdirSync, statSync, existsSync as existsSync2 } from "fs";
|
|
121
|
+
import { join as join2 } from "path";
|
|
122
|
+
import { createRequire } from "module";
|
|
123
|
+
var PROGRAM_ACCOUNT_SIZE = 36;
|
|
124
|
+
var PROGRAMDATA_METADATA = 45;
|
|
125
|
+
var BUFFER_METADATA = 37;
|
|
126
|
+
var DEFAULT_MAX_LEN_MULTIPLIER = 2;
|
|
127
|
+
var WRITE_CHUNK_BYTES = 1012;
|
|
128
|
+
var BASE_TX_FEE_LAMPORTS = 5e3;
|
|
129
|
+
var _require = createRequire(import.meta.url);
|
|
130
|
+
var _parser = null;
|
|
131
|
+
function getParser() {
|
|
132
|
+
if (_parser) return _parser;
|
|
133
|
+
const Parser = _require("tree-sitter");
|
|
134
|
+
const Rust = _require("tree-sitter-rust");
|
|
135
|
+
_parser = new Parser();
|
|
136
|
+
_parser.setLanguage(Rust);
|
|
137
|
+
return _parser;
|
|
138
|
+
}
|
|
139
|
+
function walkRust(dir, out = []) {
|
|
140
|
+
for (const entry of readdirSync(dir)) {
|
|
141
|
+
if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
|
|
142
|
+
const p = join2(dir, entry);
|
|
143
|
+
const st = statSync(p);
|
|
144
|
+
if (st.isDirectory()) walkRust(p, out);
|
|
145
|
+
else if (p.endsWith(".rs")) out.push(p);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function named(node) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
152
|
+
const c = node.child(i);
|
|
153
|
+
if (c.isNamed) out.push(c);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function itemsWithAttrs(containerNode) {
|
|
158
|
+
const result = [];
|
|
159
|
+
let pending = [];
|
|
160
|
+
for (const kid of named(containerNode)) {
|
|
161
|
+
if (kid.type === "attribute_item") pending.push(kid.text);
|
|
162
|
+
else {
|
|
163
|
+
result.push({ attrs: pending, node: kid });
|
|
164
|
+
pending = [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
function evalSpaceExpr(expr) {
|
|
170
|
+
const tokens = expr.split("+").map((t) => t.trim());
|
|
171
|
+
let value = 0;
|
|
172
|
+
let literal = true;
|
|
173
|
+
for (const t of tokens) {
|
|
174
|
+
if (/^\d+$/.test(t)) value += parseInt(t, 10);
|
|
175
|
+
else if (t.length > 0) literal = false;
|
|
176
|
+
}
|
|
177
|
+
return { value, literal };
|
|
178
|
+
}
|
|
179
|
+
function attrValue(attrText, key) {
|
|
180
|
+
const re = new RegExp(`\\b${key}\\s*=\\s*`);
|
|
181
|
+
const m = re.exec(attrText);
|
|
182
|
+
if (!m) return null;
|
|
183
|
+
let i = m.index + m[0].length;
|
|
184
|
+
let depth = 0;
|
|
185
|
+
let out = "";
|
|
186
|
+
for (; i < attrText.length; i++) {
|
|
187
|
+
const ch = attrText[i];
|
|
188
|
+
if (ch === "[" || ch === "(") depth++;
|
|
189
|
+
else if (ch === "]" || ch === ")") {
|
|
190
|
+
if (depth === 0) break;
|
|
191
|
+
depth--;
|
|
192
|
+
} else if (ch === "," && depth === 0) break;
|
|
193
|
+
out += ch;
|
|
194
|
+
}
|
|
195
|
+
return out.trim() || null;
|
|
196
|
+
}
|
|
197
|
+
function parseInitAccounts(targetDir2) {
|
|
198
|
+
const parser = getParser();
|
|
199
|
+
const accounts = [];
|
|
200
|
+
for (const file of walkRust(targetDir2)) {
|
|
201
|
+
let src;
|
|
202
|
+
try {
|
|
203
|
+
src = readFileSync2(file, "utf8");
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!src.includes("#[derive(Accounts)]") && !src.includes("Accounts)]")) continue;
|
|
208
|
+
const tree = parser.parse(src);
|
|
209
|
+
const root = tree.rootNode;
|
|
210
|
+
const topPairs = itemsWithAttrs(root);
|
|
211
|
+
for (const { attrs, node } of topPairs) {
|
|
212
|
+
if (node.type !== "struct_item") continue;
|
|
213
|
+
const isAccounts = attrs.some((a) => a.includes("Accounts"));
|
|
214
|
+
if (!isAccounts) continue;
|
|
215
|
+
const nameNode = node.childForFieldName("name");
|
|
216
|
+
const structName = nameNode?.text ?? "<anonymous>";
|
|
217
|
+
const body = node.childForFieldName("body");
|
|
218
|
+
if (!body) continue;
|
|
219
|
+
for (const { attrs: fAttrs, node: fNode } of itemsWithAttrs(body)) {
|
|
220
|
+
if (fNode.type !== "field_declaration") continue;
|
|
221
|
+
const attrText = fAttrs.join("\n");
|
|
222
|
+
const hasInit = /\binit\b/.test(attrText) || /\binit_if_needed\b/.test(attrText);
|
|
223
|
+
if (!hasInit) continue;
|
|
224
|
+
const fieldName = fNode.childForFieldName("name")?.text ?? "?";
|
|
225
|
+
const typeName = fNode.childForFieldName("type")?.text ?? "?";
|
|
226
|
+
const spaceRaw = attrValue(attrText, "space");
|
|
227
|
+
let space = null;
|
|
228
|
+
let spaceExpr;
|
|
229
|
+
if (spaceRaw) {
|
|
230
|
+
const { value, literal } = evalSpaceExpr(spaceRaw);
|
|
231
|
+
if (literal) space = value;
|
|
232
|
+
else {
|
|
233
|
+
space = null;
|
|
234
|
+
spaceExpr = spaceRaw;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
accounts.push({
|
|
238
|
+
name: fieldName,
|
|
239
|
+
struct: structName,
|
|
240
|
+
file,
|
|
241
|
+
line: fNode.startPosition.row + 1,
|
|
242
|
+
typeName,
|
|
243
|
+
space,
|
|
244
|
+
spaceExpr,
|
|
245
|
+
rentLamports: space === null ? null : rentExemptMinimum(space),
|
|
246
|
+
seeds: attrValue(attrText, "seeds"),
|
|
247
|
+
payer: attrValue(attrText, "payer"),
|
|
248
|
+
conditional: /\binit_if_needed\b/.test(attrText)
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return accounts;
|
|
254
|
+
}
|
|
255
|
+
function findProgramBinary(targetDir2) {
|
|
256
|
+
const candidates = [
|
|
257
|
+
join2(targetDir2, "target", "deploy"),
|
|
258
|
+
join2(targetDir2, "target", "sbf-solana-solana", "release"),
|
|
259
|
+
join2(targetDir2, "target", "bpfel-unknown-unknown", "release")
|
|
260
|
+
];
|
|
261
|
+
let best = null;
|
|
262
|
+
for (const dir of candidates) {
|
|
263
|
+
if (!existsSync2(dir)) continue;
|
|
264
|
+
for (const entry of readdirSync(dir)) {
|
|
265
|
+
if (!entry.endsWith(".so")) continue;
|
|
266
|
+
const p = join2(dir, entry);
|
|
267
|
+
const bytes = statSync(p).size;
|
|
268
|
+
if (!best || bytes > best.bytes) best = { path: p, bytes };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return best;
|
|
272
|
+
}
|
|
273
|
+
function buildDeployPlan(targetDir2, opts = {}) {
|
|
274
|
+
const maxLenMultiplier = opts.maxLenMultiplier ?? DEFAULT_MAX_LEN_MULTIPLIER;
|
|
275
|
+
const priorityMicroLamports = opts.priorityMicroLamports ?? 0;
|
|
276
|
+
const binary = opts.programLen != null ? null : findProgramBinary(targetDir2);
|
|
277
|
+
const programLen = opts.programLen ?? binary?.bytes ?? null;
|
|
278
|
+
const initAccounts = parseInitAccounts(targetDir2);
|
|
279
|
+
const unresolvedInit = initAccounts.filter((a) => a.rentLamports === null);
|
|
280
|
+
const initRentLamports = initAccounts.reduce((s, a) => s + (a.rentLamports ?? 0), 0);
|
|
281
|
+
const programAccountRent = programLen == null ? 0 : rentExemptMinimum(PROGRAM_ACCOUNT_SIZE);
|
|
282
|
+
const programDataRent = programLen == null ? 0 : rentExemptMinimum(PROGRAMDATA_METADATA + maxLenMultiplier * programLen);
|
|
283
|
+
const bufferRent = programLen == null ? 0 : rentExemptMinimum(BUFFER_METADATA + programLen);
|
|
284
|
+
const writeTxCount = programLen == null ? 0 : Math.ceil(programLen / WRITE_CHUNK_BYTES);
|
|
285
|
+
const initStructs = [...new Set(initAccounts.map((a) => a.struct))];
|
|
286
|
+
const baseTxCount = (programLen == null ? 0 : 2 + writeTxCount) + initStructs.length;
|
|
287
|
+
const txFeeLamports = baseTxCount * BASE_TX_FEE_LAMPORTS;
|
|
288
|
+
const steps = [];
|
|
289
|
+
let idx = 1;
|
|
290
|
+
if (programLen != null) {
|
|
291
|
+
steps.push({
|
|
292
|
+
index: idx++,
|
|
293
|
+
kind: "create-buffer",
|
|
294
|
+
label: "Create buffer account",
|
|
295
|
+
rentLamports: 0,
|
|
296
|
+
transientLamports: bufferRent,
|
|
297
|
+
feeLamports: BASE_TX_FEE_LAMPORTS,
|
|
298
|
+
detail: `Allocate a ${BUFFER_METADATA + programLen}-byte buffer (held by the upgradeable loader) and fund it with ${bufferRent.toLocaleString()} lamports of rent. Refunded to you when the buffer is drained at deploy time.`
|
|
299
|
+
});
|
|
300
|
+
steps.push({
|
|
301
|
+
index: idx++,
|
|
302
|
+
kind: "write",
|
|
303
|
+
label: `Write program bytes (${writeTxCount} transaction${writeTxCount === 1 ? "" : "s"})`,
|
|
304
|
+
rentLamports: 0,
|
|
305
|
+
transientLamports: 0,
|
|
306
|
+
feeLamports: writeTxCount * BASE_TX_FEE_LAMPORTS,
|
|
307
|
+
detail: `Stream the ${programLen.toLocaleString()}-byte program into the buffer in ~${WRITE_CHUNK_BYTES}-byte chunks. ${writeTxCount} write transaction${writeTxCount === 1 ? "" : "s"} at ${BASE_TX_FEE_LAMPORTS} lamports each.`
|
|
308
|
+
});
|
|
309
|
+
steps.push({
|
|
310
|
+
index: idx++,
|
|
311
|
+
kind: "deploy",
|
|
312
|
+
label: "Deploy program from buffer",
|
|
313
|
+
rentLamports: programAccountRent + programDataRent,
|
|
314
|
+
transientLamports: -bufferRent,
|
|
315
|
+
feeLamports: BASE_TX_FEE_LAMPORTS,
|
|
316
|
+
detail: `Create the program account (${PROGRAM_ACCOUNT_SIZE} B, rent ${programAccountRent.toLocaleString()}) and the programdata account (${(PROGRAMDATA_METADATA + maxLenMultiplier * programLen).toLocaleString()} B at ${maxLenMultiplier}\xD7 upgrade headroom, rent ${programDataRent.toLocaleString()}). The buffer's lamports roll into the programdata account.`
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
for (const struct of initStructs) {
|
|
320
|
+
const accts = initAccounts.filter((a) => a.struct === struct);
|
|
321
|
+
const rent = accts.reduce((s, a) => s + (a.rentLamports ?? 0), 0);
|
|
322
|
+
const names = accts.map((a) => a.name).join(", ");
|
|
323
|
+
const anyUnresolved = accts.some((a) => a.rentLamports === null);
|
|
324
|
+
steps.push({
|
|
325
|
+
index: idx++,
|
|
326
|
+
kind: "initialize",
|
|
327
|
+
label: `Initialize: ${struct}`,
|
|
328
|
+
rentLamports: rent,
|
|
329
|
+
transientLamports: 0,
|
|
330
|
+
feeLamports: BASE_TX_FEE_LAMPORTS,
|
|
331
|
+
detail: `Invoke the handler using \`Context<${struct}>\` to create ${accts.length} account(s): ${names}. Payer funds ${rent.toLocaleString()} lamports of rent` + (anyUnresolved ? " (plus unresolved-space accounts \u2014 see notes)." : ".")
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const lockedLamports = programAccountRent + programDataRent + initRentLamports;
|
|
335
|
+
const walletRequiredLamports = lockedLamports + bufferRent + txFeeLamports;
|
|
336
|
+
return {
|
|
337
|
+
binary,
|
|
338
|
+
programLen,
|
|
339
|
+
maxLenMultiplier,
|
|
340
|
+
priorityMicroLamports,
|
|
341
|
+
programAccountRent,
|
|
342
|
+
programDataRent,
|
|
343
|
+
bufferRent,
|
|
344
|
+
writeTxCount,
|
|
345
|
+
txFeeLamports,
|
|
346
|
+
initAccounts,
|
|
347
|
+
initRentLamports,
|
|
348
|
+
unresolvedInit,
|
|
349
|
+
steps,
|
|
350
|
+
lockedLamports,
|
|
351
|
+
walletRequiredLamports,
|
|
352
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function sol(lamports) {
|
|
356
|
+
return `${lamportsToSol(lamports)} SOL`;
|
|
357
|
+
}
|
|
358
|
+
function renderDeployPlanMd(p) {
|
|
359
|
+
const L = ["## Deployment Plan\n"];
|
|
360
|
+
if (p.programLen == null) {
|
|
361
|
+
L.push(
|
|
362
|
+
"\u26A0\uFE0F **No compiled `.so` found** under `target/deploy/`. Run `anchor build` (or `cargo build-sbf`) first for exact deploy cost. The transaction sequence below is structural; rent figures for the program binary are omitted.\n"
|
|
363
|
+
);
|
|
364
|
+
} else {
|
|
365
|
+
const srcNote = p.binary ? `\`${p.binary.path.split("/").slice(-1)[0]}\` (${p.programLen.toLocaleString()} bytes)` : `${p.programLen.toLocaleString()} bytes (provided)`;
|
|
366
|
+
L.push(`**Program binary:** ${srcNote}
|
|
367
|
+
`);
|
|
368
|
+
L.push("### How much SOL do I need?\n");
|
|
369
|
+
L.push("| Item | Size | Rent (lamports) | SOL | Recoverable? |");
|
|
370
|
+
L.push("|------|------|-----------------|-----|--------------|");
|
|
371
|
+
L.push(
|
|
372
|
+
`| Program account | ${PROGRAM_ACCOUNT_SIZE} B | ${p.programAccountRent.toLocaleString()} | ${lamportsToSol(p.programAccountRent)} | \u274C until program closed |`
|
|
373
|
+
);
|
|
374
|
+
L.push(
|
|
375
|
+
`| Program data (${p.maxLenMultiplier}\xD7 headroom) | ${(PROGRAMDATA_METADATA + p.maxLenMultiplier * p.programLen).toLocaleString()} B | ${p.programDataRent.toLocaleString()} | ${lamportsToSol(p.programDataRent)} | \u274C until program closed |`
|
|
376
|
+
);
|
|
377
|
+
L.push(
|
|
378
|
+
`| Buffer (transient) | ${(BUFFER_METADATA + p.programLen).toLocaleString()} B | ${p.bufferRent.toLocaleString()} | ${lamportsToSol(p.bufferRent)} | \u2705 refunded at deploy |`
|
|
379
|
+
);
|
|
380
|
+
if (p.initAccounts.length > 0) {
|
|
381
|
+
L.push(
|
|
382
|
+
`| Init accounts (${p.initAccounts.length}) | \u2014 | ${p.initRentLamports.toLocaleString()} | ${lamportsToSol(p.initRentLamports)} | depends on close logic |`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
L.push(
|
|
386
|
+
`| Transaction fees (~${p.steps.reduce((s, x) => s + (x.feeLamports > 0 ? 1 : 0), 0)} steps) | \u2014 | ${p.txFeeLamports.toLocaleString()} | ${lamportsToSol(p.txFeeLamports)} | \u274C spent |`
|
|
387
|
+
);
|
|
388
|
+
L.push("");
|
|
389
|
+
L.push(
|
|
390
|
+
`**\u2192 Fund the deploying wallet with at least ${sol(p.walletRequiredLamports)}** (${p.walletRequiredLamports.toLocaleString()} lamports).`
|
|
391
|
+
);
|
|
392
|
+
L.push(
|
|
393
|
+
`Steady-state locked after deploy: **${sol(p.lockedLamports)}** (program + programdata + init rent). The buffer rent and fees are not part of the steady-state lockup.
|
|
394
|
+
`
|
|
395
|
+
);
|
|
396
|
+
if (p.priorityMicroLamports > 0) {
|
|
397
|
+
L.push(
|
|
398
|
+
`_Priority fee of ${p.priorityMicroLamports} \xB5lamports/CU requested \u2014 add it on top of the base fees above for congested-network safety._
|
|
399
|
+
`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
L.push("### Exact transaction sequence\n");
|
|
404
|
+
for (const s of p.steps) {
|
|
405
|
+
const tags = [];
|
|
406
|
+
if (s.rentLamports > 0) tags.push(`locks ${sol(s.rentLamports)}`);
|
|
407
|
+
if (s.transientLamports > 0) tags.push(`transient ${sol(s.transientLamports)}`);
|
|
408
|
+
if (s.transientLamports < 0) tags.push(`refunds ${sol(-s.transientLamports)}`);
|
|
409
|
+
if (s.feeLamports > 0) tags.push(`fee ${sol(s.feeLamports)}`);
|
|
410
|
+
const tagStr = tags.length ? ` _(${tags.join(", ")})_` : "";
|
|
411
|
+
L.push(`${s.index}. **${s.label}**${tagStr}`);
|
|
412
|
+
L.push(` ${s.detail}`);
|
|
413
|
+
}
|
|
414
|
+
L.push("");
|
|
415
|
+
if (p.initAccounts.length > 0) {
|
|
416
|
+
L.push("### Init accounts (rent at setup)\n");
|
|
417
|
+
L.push("| Account | Struct | Type | Space | Rent | PDA seeds | Payer |");
|
|
418
|
+
L.push("|---------|--------|------|-------|------|-----------|-------|");
|
|
419
|
+
for (const a of p.initAccounts) {
|
|
420
|
+
const file = a.file.split("/").slice(-2).join("/");
|
|
421
|
+
const space = a.space != null ? `${a.space} B` : `\u26A0\uFE0F \`${a.spaceExpr ?? "?"}\``;
|
|
422
|
+
const rent = a.rentLamports != null ? lamportsToSol(a.rentLamports) + " SOL" : "\u2014";
|
|
423
|
+
const seeds = a.seeds ? `\`${a.seeds.replace(/\|/g, "\\|")}\`` : "(keypair)";
|
|
424
|
+
L.push(
|
|
425
|
+
`| \`${a.name}\`${a.conditional ? " (cond.)" : ""} (${file}:${a.line}) | ${a.struct} | \`${a.typeName.replace(/\|/g, "\\|")}\` | ${space} | ${rent} | ${seeds} | ${a.payer ?? "\u2014"} |`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
L.push("");
|
|
429
|
+
if (p.unresolvedInit.length > 0) {
|
|
430
|
+
L.push(
|
|
431
|
+
`> \u26A0\uFE0F ${p.unresolvedInit.length} account(s) declare \`space\` via a non-literal expression (e.g. \`8 + State::INIT_SPACE\`). Their rent is excluded from the totals above \u2014 resolve the constant to get an exact figure.
|
|
432
|
+
`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return L.join("\n");
|
|
437
|
+
}
|
|
438
|
+
function renderDeployPlanText(p) {
|
|
439
|
+
const L = [];
|
|
440
|
+
L.push("\u2500\u2500 Deployment Plan \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
441
|
+
if (p.programLen == null) {
|
|
442
|
+
L.push(" no compiled .so found \u2014 run `anchor build` for exact cost.");
|
|
443
|
+
L.push(" (showing structural transaction sequence only)");
|
|
444
|
+
} else {
|
|
445
|
+
L.push(` program binary: ${p.programLen.toLocaleString()} bytes`);
|
|
446
|
+
L.push(` program account: ${p.programAccountRent.toLocaleString()} lamports (${lamportsToSol(p.programAccountRent)} SOL)`);
|
|
447
|
+
L.push(` program data (${p.maxLenMultiplier}x): ${p.programDataRent.toLocaleString()} lamports (${lamportsToSol(p.programDataRent)} SOL)`);
|
|
448
|
+
L.push(` buffer (transient): ${p.bufferRent.toLocaleString()} lamports (${lamportsToSol(p.bufferRent)} SOL, refunded)`);
|
|
449
|
+
if (p.initAccounts.length > 0)
|
|
450
|
+
L.push(` init accounts: ${p.initRentLamports.toLocaleString()} lamports (${lamportsToSol(p.initRentLamports)} SOL)`);
|
|
451
|
+
L.push(` tx fees (est): ${p.txFeeLamports.toLocaleString()} lamports (${lamportsToSol(p.txFeeLamports)} SOL)`);
|
|
452
|
+
L.push(` \u2500\u2500\u2500 fund wallet with \u2265 ${lamportsToSol(p.walletRequiredLamports)} SOL (steady-state locked: ${lamportsToSol(p.lockedLamports)} SOL)`);
|
|
453
|
+
}
|
|
454
|
+
L.push(" sequence:");
|
|
455
|
+
for (const s of p.steps) {
|
|
456
|
+
const locks = s.rentLamports > 0 ? ` +${lamportsToSol(s.rentLamports)} SOL` : "";
|
|
457
|
+
L.push(` ${s.index}. ${s.label}${locks}`);
|
|
458
|
+
}
|
|
459
|
+
if (p.unresolvedInit.length > 0)
|
|
460
|
+
L.push(` note: ${p.unresolvedInit.length} init account(s) have non-literal space \u2014 excluded from totals.`);
|
|
461
|
+
return L.join("\n");
|
|
462
|
+
}
|
|
463
|
+
|
|
112
464
|
// src/cli.ts
|
|
113
465
|
import { execFileSync } from "child_process";
|
|
114
466
|
var args = process.argv.slice(2);
|
|
@@ -128,7 +480,7 @@ if (args[0] === "drift") {
|
|
|
128
480
|
process.exit(0);
|
|
129
481
|
}
|
|
130
482
|
if (args[0] === "mcp") {
|
|
131
|
-
const { startMcpServer } = await import("./mcp-
|
|
483
|
+
const { startMcpServer } = await import("./mcp-EOTWSQK7.js");
|
|
132
484
|
await startMcpServer();
|
|
133
485
|
process.exit(0);
|
|
134
486
|
}
|
|
@@ -203,6 +555,14 @@ if (args[0] === "batch") {
|
|
|
203
555
|
await runBatch(args.slice(1));
|
|
204
556
|
process.exit(0);
|
|
205
557
|
}
|
|
558
|
+
if (args[0] === "deploy-plan") {
|
|
559
|
+
runDeployPlan(args.slice(1));
|
|
560
|
+
process.exit(0);
|
|
561
|
+
}
|
|
562
|
+
if (args[0] === "exploits") {
|
|
563
|
+
runExploits(args.slice(1));
|
|
564
|
+
process.exit(0);
|
|
565
|
+
}
|
|
206
566
|
if (args[0] === "fix") {
|
|
207
567
|
await runFix(args.slice(1));
|
|
208
568
|
process.exit(0);
|
|
@@ -253,11 +613,11 @@ if (!changedRanges) {
|
|
|
253
613
|
}
|
|
254
614
|
var costReport = analyzeCosts(targetDir);
|
|
255
615
|
report.costAnalysis = costReport;
|
|
256
|
-
var outDir =
|
|
616
|
+
var outDir = join3(targetDir, ".agent-research");
|
|
257
617
|
mkdirSync2(outDir, { recursive: true });
|
|
258
|
-
var reportPath =
|
|
618
|
+
var reportPath = join3(outDir, "report.json");
|
|
259
619
|
writeFileSync2(reportPath, JSON.stringify(report, null, 2));
|
|
260
|
-
var costMdPath =
|
|
620
|
+
var costMdPath = join3(outDir, "cost-analysis.md");
|
|
261
621
|
writeFileSync2(costMdPath, renderCostReportMd(costReport));
|
|
262
622
|
console.log(`brainblast: scanned ${targetDir} with ${rules.length} rule(s)`);
|
|
263
623
|
if (checks.length === 0) console.log(" (no catastrophic components detected)");
|
|
@@ -308,6 +668,71 @@ if (ci) {
|
|
|
308
668
|
const gateFail = fails > 0 || strict && cantTell > 0;
|
|
309
669
|
process.exit(gateFail ? 1 : 0);
|
|
310
670
|
}
|
|
671
|
+
function runDeployPlan(argv) {
|
|
672
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
673
|
+
console.log("usage: brainblast deploy-plan [targetDir] [--json] [--max-len-mult N] [--program-len BYTES] [--priority-fee MICROLAMPORTS]");
|
|
674
|
+
console.log(" Estimate SOL needed to deploy an Anchor program and print the exact ordered");
|
|
675
|
+
console.log(" transaction sequence (create buffer \u2192 write \u2192 deploy \u2192 initialize PDAs).");
|
|
676
|
+
console.log(" Reads the compiled .so under target/deploy/; pass --program-len to model a");
|
|
677
|
+
console.log(" build you haven't compiled yet.");
|
|
678
|
+
process.exit(0);
|
|
679
|
+
}
|
|
680
|
+
const num = (name) => {
|
|
681
|
+
const idx = argv.indexOf(`--${name}`);
|
|
682
|
+
if (idx < 0) return void 0;
|
|
683
|
+
const v = parseInt(argv[idx + 1], 10);
|
|
684
|
+
return Number.isFinite(v) ? v : void 0;
|
|
685
|
+
};
|
|
686
|
+
const targetDir2 = argv.find((a, i) => !a.startsWith("--") && !/^\d+$/.test(a) && argv[i - 1] !== "--max-len-mult" && argv[i - 1] !== "--program-len" && argv[i - 1] !== "--priority-fee") ?? process.cwd();
|
|
687
|
+
const plan = buildDeployPlan(targetDir2, {
|
|
688
|
+
maxLenMultiplier: num("max-len-mult"),
|
|
689
|
+
programLen: num("program-len"),
|
|
690
|
+
priorityMicroLamports: num("priority-fee")
|
|
691
|
+
});
|
|
692
|
+
if (argv.includes("--json")) {
|
|
693
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
console.log(renderDeployPlanText(plan));
|
|
697
|
+
const outDir2 = join3(targetDir2, ".agent-research");
|
|
698
|
+
mkdirSync2(outDir2, { recursive: true });
|
|
699
|
+
const mdPath = join3(outDir2, "deploy-plan.md");
|
|
700
|
+
writeFileSync2(mdPath, renderDeployPlanMd(plan));
|
|
701
|
+
console.log(` deploy plan: ${mdPath}`);
|
|
702
|
+
}
|
|
703
|
+
function runExploits(argv) {
|
|
704
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
705
|
+
console.log("usage: brainblast exploits [id] [--json]");
|
|
706
|
+
console.log(" The Exploit Pattern Database: real on-chain incidents mapped to the bundled");
|
|
707
|
+
console.log(" rule that statically detects each root-cause pattern. Pass an incident id or");
|
|
708
|
+
console.log(" rule id to see one in detail. Known ids:");
|
|
709
|
+
console.log(` ${EXPLOIT_PATTERNS.map((e) => e.id).join(", ")}`);
|
|
710
|
+
process.exit(0);
|
|
711
|
+
}
|
|
712
|
+
const json = argv.includes("--json");
|
|
713
|
+
const id = argv.find((a) => !a.startsWith("--"));
|
|
714
|
+
if (id) {
|
|
715
|
+
const e = getExploitPattern(id);
|
|
716
|
+
if (!e) {
|
|
717
|
+
console.error(`error: no exploit pattern '${id}'. Known: ${EXPLOIT_PATTERNS.map((x) => x.id).join(", ")}`);
|
|
718
|
+
process.exit(2);
|
|
719
|
+
}
|
|
720
|
+
if (json) console.log(JSON.stringify(e, null, 2));
|
|
721
|
+
else console.log(renderExploitDetailText(e));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (json) {
|
|
725
|
+
console.log(JSON.stringify(EXPLOIT_PATTERNS, null, 2));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
console.log(renderExploitsText());
|
|
729
|
+
const outDir2 = join3(process.cwd(), ".agent-research");
|
|
730
|
+
mkdirSync2(outDir2, { recursive: true });
|
|
731
|
+
const mdPath = join3(outDir2, "exploit-patterns.md");
|
|
732
|
+
writeFileSync2(mdPath, renderExploitsMd());
|
|
733
|
+
console.log(`
|
|
734
|
+
database: ${mdPath}`);
|
|
735
|
+
}
|
|
311
736
|
function runPack(argv) {
|
|
312
737
|
const sub = argv[0];
|
|
313
738
|
if (sub === "init") {
|
|
@@ -329,8 +754,8 @@ function runPack(argv) {
|
|
|
329
754
|
description: flag("description")
|
|
330
755
|
});
|
|
331
756
|
console.log(`brainblast pack init: wrote ${manifestFile}`);
|
|
332
|
-
console.log(` rules: ${
|
|
333
|
-
console.log(` fixtures: ${
|
|
757
|
+
console.log(` rules: ${join3(dir, "rules")}/`);
|
|
758
|
+
console.log(` fixtures: ${join3(dir, "fixtures")}/`);
|
|
334
759
|
return;
|
|
335
760
|
}
|
|
336
761
|
if (sub === "validate") {
|
|
@@ -707,8 +1132,8 @@ async function runFirewall(argv) {
|
|
|
707
1132
|
}
|
|
708
1133
|
}
|
|
709
1134
|
async function runIdlRules(argv) {
|
|
710
|
-
const { readFileSync:
|
|
711
|
-
const { join:
|
|
1135
|
+
const { readFileSync: readFileSync3, writeFileSync: writeFileSync3, mkdirSync: mkdirSync3 } = await import("fs");
|
|
1136
|
+
const { join: join4 } = await import("path");
|
|
712
1137
|
const { parseIdl, generateRulesFromIdl, renderRulesYaml } = await import("./idlRules-3KZML4NL.js");
|
|
713
1138
|
const idlPath = argv.find((a) => !a.startsWith("--"));
|
|
714
1139
|
if (!idlPath) {
|
|
@@ -721,7 +1146,7 @@ async function runIdlRules(argv) {
|
|
|
721
1146
|
const jsonOut = argv.includes("--json");
|
|
722
1147
|
let idl;
|
|
723
1148
|
try {
|
|
724
|
-
idl = parseIdl(JSON.parse(
|
|
1149
|
+
idl = parseIdl(JSON.parse(readFileSync3(idlPath, "utf8")));
|
|
725
1150
|
} catch (e) {
|
|
726
1151
|
console.error(`brainblast idl-rules: ${e?.message ?? String(e)}`);
|
|
727
1152
|
process.exit(2);
|
|
@@ -738,7 +1163,7 @@ async function runIdlRules(argv) {
|
|
|
738
1163
|
const yaml = renderRulesYaml(rules2);
|
|
739
1164
|
if (outDir2) {
|
|
740
1165
|
mkdirSync3(outDir2, { recursive: true });
|
|
741
|
-
const file =
|
|
1166
|
+
const file = join4(outDir2, `${rules2[0].id}.yaml`);
|
|
742
1167
|
writeFileSync3(file, yaml);
|
|
743
1168
|
console.log(`Generated ${rules2.length} rule(s) \u2192 ${file}`);
|
|
744
1169
|
console.log(` Run against your program: npx brainblast <program-dir> --packs <pack-with-this-rule>`);
|
|
@@ -812,7 +1237,7 @@ async function runPumpCheck(argv) {
|
|
|
812
1237
|
if (report2.verdict === "NO-GO") process.exit(1);
|
|
813
1238
|
}
|
|
814
1239
|
async function runBatch(argv) {
|
|
815
|
-
const { readFileSync:
|
|
1240
|
+
const { readFileSync: readFileSync3 } = await import("fs");
|
|
816
1241
|
const { batchScan, parseMintList, renderBatchText } = await import("./batchScan-JR2G5JCF.js");
|
|
817
1242
|
const file = argv.find((a) => !a.startsWith("--"));
|
|
818
1243
|
if (!file) {
|
|
@@ -822,7 +1247,7 @@ async function runBatch(argv) {
|
|
|
822
1247
|
}
|
|
823
1248
|
let mints;
|
|
824
1249
|
try {
|
|
825
|
-
mints = parseMintList(
|
|
1250
|
+
mints = parseMintList(readFileSync3(file, "utf8"));
|
|
826
1251
|
} catch (e) {
|
|
827
1252
|
console.error(`brainblast batch: ${e?.message ?? String(e)}`);
|
|
828
1253
|
process.exit(2);
|
package/dist/index.d.ts
CHANGED
|
@@ -156,6 +156,21 @@ interface Rule {
|
|
|
156
156
|
kind: string;
|
|
157
157
|
params?: Record<string, any>;
|
|
158
158
|
};
|
|
159
|
+
/**
|
|
160
|
+
* Exploit Pattern Database provenance (v0.7.3): links this rule to the real
|
|
161
|
+
* on-chain incident whose root cause it detects. Optional; present on rules
|
|
162
|
+
* derived from a public post-mortem. The authoritative, user-facing catalog
|
|
163
|
+
* lives in src/exploitPatterns.ts — this inline block is rule-local
|
|
164
|
+
* provenance and is cross-checked against the catalog in tests.
|
|
165
|
+
*/
|
|
166
|
+
exploit?: {
|
|
167
|
+
incident: string;
|
|
168
|
+
date: string;
|
|
169
|
+
lossUsd?: number;
|
|
170
|
+
chain?: string;
|
|
171
|
+
postmortemUrl: string;
|
|
172
|
+
pattern?: string;
|
|
173
|
+
};
|
|
159
174
|
/**
|
|
160
175
|
* Provenance for rules loaded from a third-party rule pack (see
|
|
161
176
|
* src/packs.ts). Absent for bundled rules. Stamped by the pack loader from
|
|
@@ -861,4 +876,33 @@ declare function batchScan(mints: string[], opts?: BatchScanOpts): Promise<Batch
|
|
|
861
876
|
declare function parseMintList(content: string): string[];
|
|
862
877
|
declare function renderBatchText(result: BatchResult): string;
|
|
863
878
|
|
|
864
|
-
|
|
879
|
+
interface ExploitPattern {
|
|
880
|
+
/** Stable slug, e.g. "wormhole". */
|
|
881
|
+
id: string;
|
|
882
|
+
/** Human title of the incident. */
|
|
883
|
+
title: string;
|
|
884
|
+
/** ISO date of the exploit (YYYY-MM-DD), or undefined for a pattern class. */
|
|
885
|
+
date?: string;
|
|
886
|
+
/** Reported loss in USD, when a single figure is meaningful. */
|
|
887
|
+
lossUsd?: number;
|
|
888
|
+
chain: string;
|
|
889
|
+
/** Public post-mortem / incident analysis URL. */
|
|
890
|
+
postmortemUrl: string;
|
|
891
|
+
/** The generalized root cause, in one sentence. */
|
|
892
|
+
rootCause: string;
|
|
893
|
+
/** Bundled rule id that detects this pattern (must exist in rules/). */
|
|
894
|
+
ruleId: string;
|
|
895
|
+
/** What that rule looks for, in plain English. */
|
|
896
|
+
detects: string;
|
|
897
|
+
/** The safe pattern that closes the hole. */
|
|
898
|
+
fix: string;
|
|
899
|
+
}
|
|
900
|
+
declare const EXPLOIT_PATTERNS: ExploitPattern[];
|
|
901
|
+
declare function getExploitPattern(idOrRule: string): ExploitPattern | undefined;
|
|
902
|
+
declare function formatUsd(n: number | undefined): string;
|
|
903
|
+
declare function totalLossUsd(patterns?: ExploitPattern[]): number;
|
|
904
|
+
declare function renderExploitsMd(patterns?: ExploitPattern[]): string;
|
|
905
|
+
declare function renderExploitsText(patterns?: ExploitPattern[]): string;
|
|
906
|
+
declare function renderExploitDetailText(e: ExploitPattern): string;
|
|
907
|
+
|
|
908
|
+
export { type AccountFlow, type AnchorIdl, type AuditRef, type BatchResult, type BatchRow, type BatchScanOpts, type BuildOpts, CANONICAL_BY_MINT, CANONICAL_MINTS, type Candidate, type CanonicalMint, type ChainEvent, type ChainWatchOpts, type ChainWatchState, type ChangedRanges, type CheckOutcome, type CheckResult, type CheckResultKind, type Checker, type ConfigCandidate, type ConfigChecker, type CostReport, DEFAULT_REGISTRY_URL, DEFAULT_TTL_HOURS, type DecodedInstruction, type DecodedTx, type DiffResult, type DriftAdvisory, type DriftBaseline, type DriftPackage, type DriftResult, EXPLOIT_PATTERNS, type ExploitPattern, type FirewallFinding, type FirewallOpts, type FirewallProgram, type FirewallReport, type FirewallSeverity, type FirewallVerdict, type Grade, type GraduationEvent, type IdentityStatus, type IdlAccount, type IdlConstraintParams, type IdlInstruction, KNOWN_PROGRAMS, type MintInfo, type OnChainProgram, type OsvAdvisory, PACK_MANIFEST_FILE, type PackInitOptions, type PackManifest, type PackRuleValidation, type PackValidateResult, type ParityNote, type ParsedDiff, type PreflightCheck, type PreflightOpts, type PreflightReport, type PreflightStatus, type PreflightVerdict, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type RicoOutcome, type RicoResult, type RicoTokenSecurity, type Rule, type RustAccountField, type RustCandidate, type RustChecker, type ScoreFactor, type Severity, type TelemetrySubmitResult, type TokenIdentity, type TrustGraph, type TrustScore, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, type VerifyOpts, type WatchEvent, type WatchOptions, analyzeCosts, analyzeInstructions, analyzeToken, applyDiffToFile, audit, auditWithRule, base58Decode, base58Encode, batchScan, buildConstraintParams, buildTrustGraph, rules as bundledRules, cacheSize, canonicalMintForSymbol, checkDrift, checkerKinds, decodeTransaction, defaultCachePath, deployerFlagsFrom, diffVersions, fileChanged, findCandidates, findConfigCandidates, formatUsd, generateRulesFromIdl, generateTestForResult, getCacheEntry, getCacheEntryMeta, getChangedRanges, getExploitPattern, getRepoHash, getUserHash, getWorkingTreeChanges, gradeAtLeast, gradeForScore, idlProgramName, initPack, initialChainWatchState, inspectTransaction, isCanonicalMint, isEntryExpired, isTelemetryEnabled, isValidSolanaAddress, lamportsToSol, loadDirectory, loadPack, loadPacksFromDir, loadProgramCache, loadRules, parseCpiPrograms, parseDiff, parseIdl, parseMintAccount, parseMintList, pollChainOnce, pumpPreflight, putCacheEntry, queryOsv, rangeChanged, recordGraduationEvents, renderBatchText, renderCostReportMd, renderDiffMd, renderDiffText, renderDriftText, renderExploitDetailText, renderExploitsMd, renderExploitsText, renderFirewallText, renderPreflightText, renderRicoText, renderRulesYaml, renderScoreText, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, riskScore, runChecker, runIncrementalScan, saveProgramCache, scoreFromProgram, scoreProgram, seedPackages, startChainWatch, startWatch, submitTelemetry, telemetryFilePath, testKinds, toSnakeCase, totalLossUsd, validatePack, validatePackManifest, verifyTokenIdentity };
|
package/dist/index.js
CHANGED
|
@@ -38,8 +38,11 @@ import {
|
|
|
38
38
|
} from "./chunk-VI2JBH2T.js";
|
|
39
39
|
import {
|
|
40
40
|
DEFAULT_REGISTRY_URL,
|
|
41
|
+
EXPLOIT_PATTERNS,
|
|
41
42
|
analyzeCosts,
|
|
42
43
|
applyDiffToFile,
|
|
44
|
+
formatUsd,
|
|
45
|
+
getExploitPattern,
|
|
43
46
|
getRepoHash,
|
|
44
47
|
getUserHash,
|
|
45
48
|
initPack,
|
|
@@ -48,13 +51,17 @@ import {
|
|
|
48
51
|
parseDiff,
|
|
49
52
|
recordGraduationEvents,
|
|
50
53
|
renderCostReportMd,
|
|
54
|
+
renderExploitDetailText,
|
|
55
|
+
renderExploitsMd,
|
|
56
|
+
renderExploitsText,
|
|
51
57
|
rentExemptMinimum,
|
|
52
58
|
runIncrementalScan,
|
|
53
59
|
startWatch,
|
|
54
60
|
submitTelemetry,
|
|
55
61
|
telemetryFilePath,
|
|
62
|
+
totalLossUsd,
|
|
56
63
|
validatePack
|
|
57
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-5VYTURTO.js";
|
|
58
65
|
import {
|
|
59
66
|
renderTrustGraphMd
|
|
60
67
|
} from "./chunk-2UZGWXIX.js";
|
|
@@ -91,7 +98,7 @@ import {
|
|
|
91
98
|
runChecker,
|
|
92
99
|
testKinds,
|
|
93
100
|
validatePackManifest
|
|
94
|
-
} from "./chunk-
|
|
101
|
+
} from "./chunk-5H5JQXXU.js";
|
|
95
102
|
import {
|
|
96
103
|
CANONICAL_BY_MINT,
|
|
97
104
|
CANONICAL_MINTS,
|
|
@@ -144,6 +151,7 @@ export {
|
|
|
144
151
|
CANONICAL_MINTS,
|
|
145
152
|
DEFAULT_REGISTRY_URL,
|
|
146
153
|
DEFAULT_TTL_HOURS,
|
|
154
|
+
EXPLOIT_PATTERNS,
|
|
147
155
|
KNOWN_PROGRAMS,
|
|
148
156
|
PACK_MANIFEST_FILE,
|
|
149
157
|
analyzeCosts,
|
|
@@ -169,11 +177,13 @@ export {
|
|
|
169
177
|
fileChanged,
|
|
170
178
|
findCandidates,
|
|
171
179
|
findConfigCandidates,
|
|
180
|
+
formatUsd,
|
|
172
181
|
generateRulesFromIdl,
|
|
173
182
|
generateTestForResult,
|
|
174
183
|
getCacheEntry,
|
|
175
184
|
getCacheEntryMeta,
|
|
176
185
|
getChangedRanges,
|
|
186
|
+
getExploitPattern,
|
|
177
187
|
getRepoHash,
|
|
178
188
|
getUserHash,
|
|
179
189
|
getWorkingTreeChanges,
|
|
@@ -209,6 +219,9 @@ export {
|
|
|
209
219
|
renderDiffMd,
|
|
210
220
|
renderDiffText,
|
|
211
221
|
renderDriftText,
|
|
222
|
+
renderExploitDetailText,
|
|
223
|
+
renderExploitsMd,
|
|
224
|
+
renderExploitsText,
|
|
212
225
|
renderFirewallText,
|
|
213
226
|
renderPreflightText,
|
|
214
227
|
renderRicoText,
|
|
@@ -231,6 +244,7 @@ export {
|
|
|
231
244
|
telemetryFilePath,
|
|
232
245
|
testKinds,
|
|
233
246
|
toSnakeCase,
|
|
247
|
+
totalLossUsd,
|
|
234
248
|
validatePack,
|
|
235
249
|
validatePackManifest,
|
|
236
250
|
verifyTokenIdentity
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
id: cpi-target-program-unverified
|
|
2
|
+
severity: critical
|
|
3
|
+
title: Anchor CPI invokes a target program account whose identity is never verified (Wormhole pattern)
|
|
4
|
+
component:
|
|
5
|
+
name: Anchor Framework
|
|
6
|
+
type: Blockchain
|
|
7
|
+
version: ">=0.26.0"
|
|
8
|
+
sourceUrl: https://www.anchor-lang.com/docs/the-program-module
|
|
9
|
+
exploit:
|
|
10
|
+
incident: Wormhole bridge exploit
|
|
11
|
+
date: "2022-02-02"
|
|
12
|
+
lossUsd: 325000000
|
|
13
|
+
chain: Solana
|
|
14
|
+
postmortemUrl: https://rekt.news/wormhole-rekt/
|
|
15
|
+
pattern: >-
|
|
16
|
+
A cross-program invocation (or signature-verification load) was performed
|
|
17
|
+
against an account trusted by position rather than by identity. A deprecated
|
|
18
|
+
load did not verify the supplied account's address, letting an attacker
|
|
19
|
+
substitute a malicious account and forge verification. Generalized: a CPI
|
|
20
|
+
target program was used without verifying its program ID.
|
|
21
|
+
detect:
|
|
22
|
+
lang: rust
|
|
23
|
+
modules:
|
|
24
|
+
- "@coral-xyz/anchor"
|
|
25
|
+
- "@project-serum/anchor"
|
|
26
|
+
nameRegex: ".*"
|
|
27
|
+
triggerCalls: []
|
|
28
|
+
check:
|
|
29
|
+
kind: anchor-cpi-unverified-program
|
|
30
|
+
params:
|
|
31
|
+
programNameRegex: "(^|_)program$|^program(_id)?$"
|
|
32
|
+
cpiRegex: "invoke_signed\\s*\\(|invoke\\s*\\(|CpiContext::|\\.cpi\\(\\)"
|
|
33
|
+
test:
|
|
34
|
+
kind: anchor-program-test
|
|
35
|
+
params:
|
|
36
|
+
scenario: cpi-substituted-program
|
|
37
|
+
description: >-
|
|
38
|
+
Invokes the handler with a malicious program substituted for the expected
|
|
39
|
+
target. With a raw AccountInfo program account the CPI dispatches to the
|
|
40
|
+
attacker's program; typing it Program<'info, T> (or adding address=)
|
|
41
|
+
makes Anchor reject the substitution before the handler runs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainblast",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic auditor for catastrophic AI-integration bugs: scan a repo, find the silent money/auth traps, and generate the behavioral test that proves they're fixed.",
|
|
6
6
|
"keywords": [
|