brainblast 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -15
- package/dist/chunk-WVHGN2HR.js +1812 -0
- package/dist/cli.js +171 -7
- package/dist/index.d.ts +230 -2
- package/dist/index.js +39 -1
- package/dist/programs/directory.yaml +179 -0
- package/dist/rules/anchor-init-if-needed-guarded.yaml +47 -0
- package/dist/rules/bags-fee-share-creator-included.yaml +6 -1
- package/dist/rules/metaplex-metadata-immutable.yaml +55 -0
- package/dist/rules/privy-jwt-verification.yaml +18 -2
- package/dist/rules/stripe-webhook-raw-body.yaml +5 -0
- package/dist/rules/token-2022-program-id-pinned.yaml +55 -0
- package/package.json +50 -9
- package/dist/chunk-H2Y75CSH.js +0 -494
|
@@ -0,0 +1,1812 @@
|
|
|
1
|
+
// src/finder.ts
|
|
2
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
3
|
+
|
|
4
|
+
// src/walk.ts
|
|
5
|
+
import { readdirSync, statSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
function walk(dir, out = []) {
|
|
8
|
+
for (const entry of readdirSync(dir)) {
|
|
9
|
+
if (entry === "node_modules" || entry === ".git" || entry === ".gen") continue;
|
|
10
|
+
const p = join(dir, entry);
|
|
11
|
+
const st = statSync(p);
|
|
12
|
+
if (st.isDirectory()) walk(p, out);
|
|
13
|
+
else if (p.endsWith(".ts") && !p.endsWith(".test.ts") && !p.endsWith(".d.ts")) out.push(p);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/finder.ts
|
|
19
|
+
function bodyCallsAnyOf(fn, names) {
|
|
20
|
+
if (names.size === 0) return false;
|
|
21
|
+
return fn.getDescendantsOfKind(SyntaxKind.CallExpression).some((c) => {
|
|
22
|
+
const exp = c.getExpression();
|
|
23
|
+
if (exp.getKind() === SyntaxKind.Identifier) return names.has(exp.getText());
|
|
24
|
+
if (exp.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
25
|
+
return names.has(exp.asKind(SyntaxKind.PropertyAccessExpression).getName());
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function findCandidates(targetDir, rule) {
|
|
31
|
+
const files = walk(targetDir);
|
|
32
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true, compilerOptions: { allowJs: false } });
|
|
33
|
+
const modules = new Set(rule.detect.modules);
|
|
34
|
+
const triggers = new Set(rule.detect.triggerCalls);
|
|
35
|
+
const nameRe = new RegExp(rule.detect.nameRegex, "i");
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const sf = project.addSourceFileAtPath(file);
|
|
39
|
+
const importsModule = sf.getImportDeclarations().some((d) => modules.has(d.getModuleSpecifierValue()));
|
|
40
|
+
const consider = (fn, name) => {
|
|
41
|
+
const hasName = !!(name && nameRe.test(name));
|
|
42
|
+
const hasTrigger = bodyCallsAnyOf(fn, triggers);
|
|
43
|
+
if (rule.detect.requiresImport) {
|
|
44
|
+
if (!(importsModule && (hasName || hasTrigger))) return;
|
|
45
|
+
} else {
|
|
46
|
+
if (!(hasName || hasTrigger)) return;
|
|
47
|
+
}
|
|
48
|
+
out.push({
|
|
49
|
+
filePath: file,
|
|
50
|
+
fnName: name || "(anonymous)",
|
|
51
|
+
params: fn.getParameters().map((p) => p.getName()),
|
|
52
|
+
fn
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
for (const fn of sf.getFunctions()) consider(fn, fn.getName() ?? "");
|
|
56
|
+
for (const v of sf.getVariableDeclarations()) {
|
|
57
|
+
const arrow = v.getInitializerIfKind(SyntaxKind.ArrowFunction);
|
|
58
|
+
if (arrow) consider(arrow, v.getName());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/checkers/positionalArgIdentity.ts
|
|
65
|
+
import { SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
66
|
+
var positionalArgIdentity = (c, p) => {
|
|
67
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind2.CallExpression).filter((call) => {
|
|
68
|
+
const exp = call.getExpression();
|
|
69
|
+
return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
|
|
70
|
+
});
|
|
71
|
+
if (calls.length === 0) {
|
|
72
|
+
const sf = c.fn.getSourceFile();
|
|
73
|
+
const existsInFile = sf.getDescendantsOfKind(SyntaxKind2.CallExpression).some((call) => {
|
|
74
|
+
const exp = call.getExpression();
|
|
75
|
+
return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
|
|
76
|
+
});
|
|
77
|
+
if (existsInFile) {
|
|
78
|
+
return {
|
|
79
|
+
result: "cant_tell",
|
|
80
|
+
detail: `${p.call} is called elsewhere in this file; unable to confirm this function's delegation path statically.`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { result: "fail", detail: p.absentDetail };
|
|
84
|
+
}
|
|
85
|
+
const arg = calls[0].getArguments()[p.argIndex];
|
|
86
|
+
const wantParam = c.params[p.paramIndex];
|
|
87
|
+
if (arg && wantParam && arg.getKind() === SyntaxKind2.Identifier && arg.getText() === wantParam) {
|
|
88
|
+
return { result: "pass", detail: String(p.passDetail).replace("{param}", wantParam) };
|
|
89
|
+
}
|
|
90
|
+
if (arg && arg.getKind() === SyntaxKind2.CallExpression) {
|
|
91
|
+
return { result: "fail", detail: p.parsedDetail };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
result: "cant_tell",
|
|
95
|
+
detail: `Could not confirm argument ${p.argIndex} of ${p.call} is the raw input.`
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/checkers/requiredCallWithOptions.ts
|
|
100
|
+
import { SyntaxKind as SyntaxKind3 } from "ts-morph";
|
|
101
|
+
function callName(call) {
|
|
102
|
+
const exp = call.getExpression();
|
|
103
|
+
if (exp.getKind() === SyntaxKind3.Identifier) return exp.getText();
|
|
104
|
+
if (exp.getKind() === SyntaxKind3.PropertyAccessExpression) {
|
|
105
|
+
return exp.asKind(SyntaxKind3.PropertyAccessExpression).getName();
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
function hasAllProps(call, groups) {
|
|
110
|
+
for (const arg of call.getArguments()) {
|
|
111
|
+
const obj = arg.asKind(SyntaxKind3.ObjectLiteralExpression);
|
|
112
|
+
if (!obj) continue;
|
|
113
|
+
const names = obj.getProperties().map((pr) => {
|
|
114
|
+
const pa = pr.asKind(SyntaxKind3.PropertyAssignment) ?? pr.asKind(SyntaxKind3.ShorthandPropertyAssignment);
|
|
115
|
+
return pa?.getName() ?? "";
|
|
116
|
+
});
|
|
117
|
+
if (groups.every((g) => g.some((n) => names.includes(n)))) return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
var requiredCallWithOptions = (c, p) => {
|
|
122
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind3.CallExpression);
|
|
123
|
+
const verify = calls.filter((x) => p.verifyCalls.includes(callName(x)));
|
|
124
|
+
const decode = calls.filter((x) => p.decodeCalls.includes(callName(x)));
|
|
125
|
+
if (verify.length > 0) {
|
|
126
|
+
if (verify.some((v) => hasAllProps(v, p.requiredProps))) {
|
|
127
|
+
return { result: "pass", detail: p.passDetail };
|
|
128
|
+
}
|
|
129
|
+
return { result: "fail", detail: p.missingPropsDetail };
|
|
130
|
+
}
|
|
131
|
+
if (decode.length > 0) return { result: "fail", detail: p.decodeOnlyDetail };
|
|
132
|
+
return { result: "cant_tell", detail: "No verification or decode call found." };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/checkers/feeAllocationShape.ts
|
|
136
|
+
import { SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
137
|
+
function callName2(call) {
|
|
138
|
+
const exp = call.getExpression();
|
|
139
|
+
if (exp.getKind() === SyntaxKind4.Identifier) return exp.getText();
|
|
140
|
+
if (exp.getKind() === SyntaxKind4.PropertyAccessExpression) {
|
|
141
|
+
return exp.asKind(SyntaxKind4.PropertyAccessExpression).getName();
|
|
142
|
+
}
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
function asArrayLiteral(expr, c) {
|
|
146
|
+
if (!expr) return void 0;
|
|
147
|
+
const direct = expr.asKind(SyntaxKind4.ArrayLiteralExpression);
|
|
148
|
+
if (direct) return direct;
|
|
149
|
+
if (expr.getKind() === SyntaxKind4.Identifier) {
|
|
150
|
+
const name = expr.getText();
|
|
151
|
+
for (const decl of c.fn.getDescendantsOfKind(SyntaxKind4.VariableDeclaration)) {
|
|
152
|
+
if (decl.getName() === name) {
|
|
153
|
+
return decl.getInitializerIfKind(SyntaxKind4.ArrayLiteralExpression);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return void 0;
|
|
158
|
+
}
|
|
159
|
+
function feeArray(call, prop, c) {
|
|
160
|
+
for (const arg of call.getArguments()) {
|
|
161
|
+
const obj = arg.asKind(SyntaxKind4.ObjectLiteralExpression);
|
|
162
|
+
if (!obj) continue;
|
|
163
|
+
const member = obj.getProperty(prop);
|
|
164
|
+
if (!member) continue;
|
|
165
|
+
const pa = member.asKind(SyntaxKind4.PropertyAssignment);
|
|
166
|
+
if (pa) return asArrayLiteral(pa.getInitializer(), c) ?? null;
|
|
167
|
+
const shorthand = member.asKind(SyntaxKind4.ShorthandPropertyAssignment);
|
|
168
|
+
if (shorthand) return asArrayLiteral(shorthand.getNameNode(), c) ?? null;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return void 0;
|
|
172
|
+
}
|
|
173
|
+
function propInit(entry, name) {
|
|
174
|
+
return entry.getProperty(name)?.asKind(SyntaxKind4.PropertyAssignment)?.getInitializer();
|
|
175
|
+
}
|
|
176
|
+
var feeAllocationShape = (c, p) => {
|
|
177
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind4.CallExpression).filter((x) => callName2(x) === p.configCall);
|
|
178
|
+
if (calls.length === 0) return { result: "fail", detail: p.absentDetail };
|
|
179
|
+
const arr = feeArray(calls[0], p.arrayProp, c);
|
|
180
|
+
if (arr === void 0 || arr === null) {
|
|
181
|
+
return { result: "cant_tell", detail: p.dynamicDetail };
|
|
182
|
+
}
|
|
183
|
+
const entries = arr.getElements().map((e) => e.asKind(SyntaxKind4.ObjectLiteralExpression));
|
|
184
|
+
if (entries.length === 0 || entries.some((e) => !e)) {
|
|
185
|
+
return { result: "cant_tell", detail: p.dynamicDetail };
|
|
186
|
+
}
|
|
187
|
+
const creatorParam = c.params.find((name) => new RegExp(p.creatorParamRegex, "i").test(name));
|
|
188
|
+
const creatorIncluded = creatorParam ? entries.some((e) => propInit(e, p.walletProp)?.getText() === creatorParam) : false;
|
|
189
|
+
if (!creatorIncluded) return { result: "fail", detail: p.creatorMissingDetail };
|
|
190
|
+
let sum = 0;
|
|
191
|
+
let allNumeric = true;
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
const lit = propInit(e, p.bpsProp)?.asKind(SyntaxKind4.NumericLiteral);
|
|
194
|
+
if (!lit) {
|
|
195
|
+
allNumeric = false;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
sum += Number(lit.getLiteralValue());
|
|
199
|
+
}
|
|
200
|
+
if (!allNumeric) return { result: "cant_tell", detail: p.dynamicDetail };
|
|
201
|
+
if (sum !== p.bpsTotal) {
|
|
202
|
+
return { result: "fail", detail: String(p.bpsSumDetail).replace("{sum}", String(sum)) };
|
|
203
|
+
}
|
|
204
|
+
return { result: "pass", detail: String(p.passDetail).replace("{param}", creatorParam) };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// src/checkers/argEqualsConstantIdentifier.ts
|
|
208
|
+
import { SyntaxKind as SyntaxKind5 } from "ts-morph";
|
|
209
|
+
function callName3(call) {
|
|
210
|
+
const exp = call.getExpression();
|
|
211
|
+
if (exp.getKind() === SyntaxKind5.Identifier) return exp.getText();
|
|
212
|
+
if (exp.getKind() === SyntaxKind5.PropertyAccessExpression) {
|
|
213
|
+
return exp.asKind(SyntaxKind5.PropertyAccessExpression).getName();
|
|
214
|
+
}
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
function fileImports(sf, name) {
|
|
218
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
219
|
+
if (decl.getDefaultImport()?.getText() === name) return true;
|
|
220
|
+
for (const n of decl.getNamedImports()) {
|
|
221
|
+
if (n.getName() === name || n.getAliasNode()?.getText() === name) return true;
|
|
222
|
+
}
|
|
223
|
+
if (decl.getNamespaceImport()?.getText() === name) return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
var argEqualsConstantIdentifier = (c, p) => {
|
|
228
|
+
if (p.requireImport) {
|
|
229
|
+
const sf = c.fn.getSourceFile();
|
|
230
|
+
if (!fileImports(sf, p.requireImport)) {
|
|
231
|
+
return { result: "cant_tell", detail: p.scopeNotMetDetail };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind5.CallExpression).filter((x) => callName3(x) === p.call);
|
|
235
|
+
if (calls.length === 0) {
|
|
236
|
+
return { result: "cant_tell", detail: p.absentCallDetail };
|
|
237
|
+
}
|
|
238
|
+
const arg = calls[0].getArguments()[p.argIndex];
|
|
239
|
+
const forbidden = Array.isArray(p.forbiddenIdentifiers) ? p.forbiddenIdentifiers : [];
|
|
240
|
+
if (!arg) {
|
|
241
|
+
return {
|
|
242
|
+
result: "fail",
|
|
243
|
+
detail: String(p.failMissingDetail).replace("{expected}", p.expectedIdentifier)
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (arg.getKind() === SyntaxKind5.Identifier) {
|
|
247
|
+
const text = arg.getText();
|
|
248
|
+
if (text === p.expectedIdentifier) {
|
|
249
|
+
return { result: "pass", detail: String(p.passDetail).replace("{expected}", p.expectedIdentifier) };
|
|
250
|
+
}
|
|
251
|
+
if (forbidden.includes(text)) {
|
|
252
|
+
return {
|
|
253
|
+
result: "fail",
|
|
254
|
+
detail: String(p.failForbiddenDetail).replace("{got}", text).replace("{expected}", p.expectedIdentifier)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
result: "fail",
|
|
259
|
+
detail: String(p.failOtherDetail).replace("{got}", text).replace("{expected}", p.expectedIdentifier)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (arg.getKind() === SyntaxKind5.Identifier && arg.getText() === "undefined") {
|
|
263
|
+
return {
|
|
264
|
+
result: "fail",
|
|
265
|
+
detail: String(p.failMissingDetail).replace("{expected}", p.expectedIdentifier)
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
result: "cant_tell",
|
|
270
|
+
detail: `Argument ${p.argIndex} of ${p.call} is a ${arg.getKindName()}; could not statically confirm it equals ${p.expectedIdentifier}.`
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/checkers/objectArgPropertyLiteralEquals.ts
|
|
275
|
+
import { SyntaxKind as SyntaxKind6 } from "ts-morph";
|
|
276
|
+
function fileImports2(sf, name) {
|
|
277
|
+
return sf.getImportDeclarations().some((d) => {
|
|
278
|
+
if (d.getDefaultImport()?.getText() === name) return true;
|
|
279
|
+
if (d.getNamespaceImport()?.getText() === name) return true;
|
|
280
|
+
return d.getNamedImports().some((i) => i.getName() === name);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
var objectArgPropertyLiteralEquals = (c, p) => {
|
|
284
|
+
if (p.requireImport) {
|
|
285
|
+
const sf = c.fn.getSourceFile();
|
|
286
|
+
if (!fileImports2(sf, p.requireImport)) {
|
|
287
|
+
return {
|
|
288
|
+
result: "cant_tell",
|
|
289
|
+
detail: p.scopeNotMetDetail ?? `no ${p.requireImport} import`
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind6.CallExpression).filter((ce2) => {
|
|
294
|
+
const expr = ce2.getExpression();
|
|
295
|
+
if (expr.getKind() === SyntaxKind6.Identifier) return expr.getText() === p.call;
|
|
296
|
+
if (expr.getKind() === SyntaxKind6.PropertyAccessExpression) {
|
|
297
|
+
return expr.asKind(SyntaxKind6.PropertyAccessExpression).getName() === p.call;
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
});
|
|
301
|
+
if (calls.length === 0) {
|
|
302
|
+
return {
|
|
303
|
+
result: "cant_tell",
|
|
304
|
+
detail: p.absentCallDetail ?? `no ${p.call} call found`
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const ce = calls[0];
|
|
308
|
+
const args = ce.getArguments();
|
|
309
|
+
if (args.length <= p.argIndex) {
|
|
310
|
+
return {
|
|
311
|
+
result: "fail",
|
|
312
|
+
detail: p.failAbsentDetail ?? `${p.call} arg[${p.argIndex}] missing \u2014 ${p.propName} defaults to ${p.expectedValue === false ? "true" : p.expectedValue}`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const arg = args[p.argIndex];
|
|
316
|
+
const objLit = arg.asKind(SyntaxKind6.ObjectLiteralExpression);
|
|
317
|
+
if (!objLit) {
|
|
318
|
+
return {
|
|
319
|
+
result: "cant_tell",
|
|
320
|
+
detail: p.failArgDetail ?? `${p.call} arg[${p.argIndex}] is not an inline object literal \u2014 cannot statically inspect ${p.propName}`
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const propAssignment = objLit.getProperties().map((prop) => prop.asKind(SyntaxKind6.PropertyAssignment)).find((pa) => pa?.getName() === p.propName);
|
|
324
|
+
if (!propAssignment) {
|
|
325
|
+
return {
|
|
326
|
+
result: "fail",
|
|
327
|
+
detail: p.failAbsentDetail ?? `${p.propName} is absent; the SDK defaults to ${p.expectedValue === false ? "mutable (true)" : String(p.expectedValue)}`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const init = propAssignment.getInitializer();
|
|
331
|
+
if (!init) {
|
|
332
|
+
return {
|
|
333
|
+
result: "cant_tell",
|
|
334
|
+
detail: p.failDynamicDetail ?? `${p.propName} has no initializer`
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const kind = init.getKind();
|
|
338
|
+
if (p.expectedValue === false) {
|
|
339
|
+
if (kind === SyntaxKind6.FalseKeyword) {
|
|
340
|
+
return { result: "pass", detail: p.passDetail ?? `${p.propName} is false` };
|
|
341
|
+
}
|
|
342
|
+
if (kind === SyntaxKind6.TrueKeyword) {
|
|
343
|
+
return {
|
|
344
|
+
result: "fail",
|
|
345
|
+
detail: p.failWrongDetail ?? `${p.propName} is true \u2014 metadata will remain mutable after mint`
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
result: "cant_tell",
|
|
350
|
+
detail: p.failDynamicDetail ?? `${p.propName} is a non-literal expression \u2014 cannot determine immutability statically`
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (p.expectedValue === true) {
|
|
354
|
+
if (kind === SyntaxKind6.TrueKeyword) return { result: "pass", detail: p.passDetail ?? `${p.propName} is true` };
|
|
355
|
+
if (kind === SyntaxKind6.FalseKeyword) {
|
|
356
|
+
return { result: "fail", detail: p.failWrongDetail ?? `${p.propName} is false` };
|
|
357
|
+
}
|
|
358
|
+
return { result: "cant_tell", detail: p.failDynamicDetail ?? `${p.propName} is a non-literal expression` };
|
|
359
|
+
}
|
|
360
|
+
const text = init.getText();
|
|
361
|
+
const expected = JSON.stringify(p.expectedValue);
|
|
362
|
+
if (text === expected || text === String(p.expectedValue)) {
|
|
363
|
+
return { result: "pass", detail: p.passDetail ?? `${p.propName} is ${p.expectedValue}` };
|
|
364
|
+
}
|
|
365
|
+
if (kind === SyntaxKind6.StringLiteral || kind === SyntaxKind6.NumericLiteral) {
|
|
366
|
+
return {
|
|
367
|
+
result: "fail",
|
|
368
|
+
detail: p.failWrongDetail ?? `${p.propName} is ${text} (expected ${p.expectedValue})`
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
result: "cant_tell",
|
|
373
|
+
detail: p.failDynamicDetail ?? `${p.propName} is a non-literal expression`
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/checkers/anchorInitIfNeededGuarded.ts
|
|
378
|
+
var GUARD_PATTERNS = [
|
|
379
|
+
/\brequire!\s*\(/,
|
|
380
|
+
// require!(...)
|
|
381
|
+
/\brequire_eq!\s*\(/,
|
|
382
|
+
// require_eq!(...)
|
|
383
|
+
/\brequire_keys_eq!\s*\(/,
|
|
384
|
+
// require_keys_eq!(...)
|
|
385
|
+
/\.data_is_empty\s*\(\s*\)/,
|
|
386
|
+
// account.data_is_empty()
|
|
387
|
+
/\bis_initialized\b/
|
|
388
|
+
// is_initialized flag check
|
|
389
|
+
];
|
|
390
|
+
function hasReinitGuard(bodyText) {
|
|
391
|
+
return GUARD_PATTERNS.some((re) => re.test(bodyText));
|
|
392
|
+
}
|
|
393
|
+
function anchorInitIfNeededGuarded(c, p) {
|
|
394
|
+
const risky = c.accountFields.filter((f) => f.hasInitIfNeeded);
|
|
395
|
+
if (risky.length === 0) {
|
|
396
|
+
return {
|
|
397
|
+
result: "cant_tell",
|
|
398
|
+
detail: p.absentDetail ?? `Handler '${c.fnName}' has no #[account(init_if_needed)] fields; the reinit-guard rule does not apply.`
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (hasReinitGuard(c.fnBodyText)) {
|
|
402
|
+
return {
|
|
403
|
+
result: "pass",
|
|
404
|
+
detail: p.passDetail ?? `Handler '${c.fnName}' has #[account(init_if_needed)] on [${risky.map((f) => f.name).join(", ")}] and a reinitialization guard was detected.`
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
result: "fail",
|
|
409
|
+
detail: p.failAbsentDetail ?? `Handler '${c.fnName}' has #[account(init_if_needed)] on [${risky.map((f) => f.name).join(", ")}] but no reinitialization guard (require!, data_is_empty, is_initialized). A second invocation will silently overwrite the account state.`
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/checkers/index.ts
|
|
414
|
+
var registry = {
|
|
415
|
+
"positional-arg-identity": positionalArgIdentity,
|
|
416
|
+
"required-call-with-options": requiredCallWithOptions,
|
|
417
|
+
"fee-allocation-shape": feeAllocationShape,
|
|
418
|
+
"arg-equals-constant-identifier": argEqualsConstantIdentifier,
|
|
419
|
+
"object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
|
|
420
|
+
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded
|
|
421
|
+
};
|
|
422
|
+
function runChecker(kind, c, params) {
|
|
423
|
+
const fn = registry[kind];
|
|
424
|
+
if (!fn) return { result: "cant_tell", detail: `Unknown checker kind '${kind}'.` };
|
|
425
|
+
return fn(c, params);
|
|
426
|
+
}
|
|
427
|
+
var checkerKinds = Object.keys(registry);
|
|
428
|
+
|
|
429
|
+
// src/rustFinder.ts
|
|
430
|
+
import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
431
|
+
import { join as join2 } from "path";
|
|
432
|
+
import { createRequire } from "module";
|
|
433
|
+
function walkRust(dir, out = []) {
|
|
434
|
+
for (const entry of readdirSync2(dir)) {
|
|
435
|
+
if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
|
|
436
|
+
const p = join2(dir, entry);
|
|
437
|
+
const st = statSync2(p);
|
|
438
|
+
if (st.isDirectory()) walkRust(p, out);
|
|
439
|
+
else if (p.endsWith(".rs")) out.push(p);
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
var _require = createRequire(import.meta.url);
|
|
444
|
+
var _parser = null;
|
|
445
|
+
function getParser() {
|
|
446
|
+
if (_parser) return _parser;
|
|
447
|
+
const Parser = _require("tree-sitter");
|
|
448
|
+
const Rust = _require("tree-sitter-rust");
|
|
449
|
+
_parser = new Parser();
|
|
450
|
+
_parser.setLanguage(Rust);
|
|
451
|
+
return _parser;
|
|
452
|
+
}
|
|
453
|
+
function children(node) {
|
|
454
|
+
const out = [];
|
|
455
|
+
for (let i = 0; i < node.childCount; i++) out.push(node.child(i));
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
function named(node) {
|
|
459
|
+
return children(node).filter((c) => c.isNamed);
|
|
460
|
+
}
|
|
461
|
+
function accountsStructName(fnNode) {
|
|
462
|
+
const params = fnNode.childForFieldName("parameters");
|
|
463
|
+
if (!params) return null;
|
|
464
|
+
for (const param of named(params)) {
|
|
465
|
+
const typeNode = param.childForFieldName?.("type") ?? null;
|
|
466
|
+
if (!typeNode) continue;
|
|
467
|
+
const text = typeNode.text ?? "";
|
|
468
|
+
const m = text.match(/^Context\s*<\s*([A-Za-z_][A-Za-z0-9_]*)/);
|
|
469
|
+
if (m) return m[1];
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
function itemsWithAttrs(containerNode) {
|
|
474
|
+
const kids = named(containerNode);
|
|
475
|
+
const result = [];
|
|
476
|
+
let pending = [];
|
|
477
|
+
for (const kid of kids) {
|
|
478
|
+
if (kid.type === "attribute_item") {
|
|
479
|
+
pending.push(kid.text);
|
|
480
|
+
} else {
|
|
481
|
+
result.push({ attrs: pending, node: kid });
|
|
482
|
+
pending = [];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
function parseAccountsStruct(structNode) {
|
|
488
|
+
const body = structNode.childForFieldName("body");
|
|
489
|
+
if (!body) return [];
|
|
490
|
+
const pairs = itemsWithAttrs(body);
|
|
491
|
+
const fields = [];
|
|
492
|
+
for (const { attrs, node } of pairs) {
|
|
493
|
+
if (node.type !== "field_declaration") continue;
|
|
494
|
+
const nameNode = node.childForFieldName("name");
|
|
495
|
+
const typeNode = node.childForFieldName("type");
|
|
496
|
+
if (!nameNode || !typeNode) continue;
|
|
497
|
+
const attrText = attrs.join("\n");
|
|
498
|
+
fields.push({
|
|
499
|
+
name: nameNode.text,
|
|
500
|
+
typeName: typeNode.text,
|
|
501
|
+
attrText,
|
|
502
|
+
hasInitIfNeeded: attrText.includes("init_if_needed")
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return fields;
|
|
506
|
+
}
|
|
507
|
+
function findRustCandidates(targetDir, rule) {
|
|
508
|
+
const parser = getParser();
|
|
509
|
+
const nameRe = new RegExp(rule.detect.nameRegex, "i");
|
|
510
|
+
const triggerAttrs = new Set(
|
|
511
|
+
(rule.detect.triggerCalls ?? []).map((s) => s.toLowerCase())
|
|
512
|
+
);
|
|
513
|
+
const out = [];
|
|
514
|
+
for (const file of walkRust(targetDir)) {
|
|
515
|
+
if (!file.endsWith(".rs")) continue;
|
|
516
|
+
const src = readFileSync(file, "utf8");
|
|
517
|
+
const tree = parser.parse(src);
|
|
518
|
+
const structMap = /* @__PURE__ */ new Map();
|
|
519
|
+
const topPairs = itemsWithAttrs(tree.rootNode);
|
|
520
|
+
for (const { attrs, node } of topPairs) {
|
|
521
|
+
if (node.type !== "struct_item") continue;
|
|
522
|
+
const hasAccounts = attrs.some((a) => a.includes("Accounts"));
|
|
523
|
+
if (!hasAccounts) continue;
|
|
524
|
+
const nameNode = node.childForFieldName("name");
|
|
525
|
+
if (!nameNode) continue;
|
|
526
|
+
structMap.set(nameNode.text, parseAccountsStruct(node));
|
|
527
|
+
}
|
|
528
|
+
for (const { attrs, node } of topPairs) {
|
|
529
|
+
if (node.type !== "mod_item") continue;
|
|
530
|
+
const isProgram = attrs.some((a) => a.includes("program"));
|
|
531
|
+
if (!isProgram) continue;
|
|
532
|
+
const body = node.childForFieldName("body");
|
|
533
|
+
if (!body) continue;
|
|
534
|
+
for (const { node: item } of itemsWithAttrs(body)) {
|
|
535
|
+
if (item.type !== "function_item") continue;
|
|
536
|
+
const fnNameNode = item.childForFieldName("name");
|
|
537
|
+
if (!fnNameNode) continue;
|
|
538
|
+
const fnName = fnNameNode.text;
|
|
539
|
+
const structName = accountsStructName(item) ?? "";
|
|
540
|
+
const fields = structMap.get(structName) ?? [];
|
|
541
|
+
const nameMatch = nameRe.test(fnName);
|
|
542
|
+
const attrMatch = triggerAttrs.size > 0 && fields.some((f) => [...triggerAttrs].some((t) => f.attrText.includes(t)));
|
|
543
|
+
if (!nameMatch && !attrMatch) continue;
|
|
544
|
+
const bodyNode = item.childForFieldName("body");
|
|
545
|
+
if (!bodyNode) continue;
|
|
546
|
+
out.push({
|
|
547
|
+
filePath: file,
|
|
548
|
+
fnName,
|
|
549
|
+
accountStructName: structName,
|
|
550
|
+
accountFields: fields,
|
|
551
|
+
fnBodyText: bodyNode.text,
|
|
552
|
+
fnBodyNode: bodyNode
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/fixers/positionalArgIdentity.ts
|
|
561
|
+
import { SyntaxKind as SyntaxKind7 } from "ts-morph";
|
|
562
|
+
|
|
563
|
+
// src/fixers/diffUtil.ts
|
|
564
|
+
function buildDiff(node, replacement) {
|
|
565
|
+
const sf = node.getSourceFile();
|
|
566
|
+
const filePath = sf.getFilePath();
|
|
567
|
+
const fullText = sf.getFullText();
|
|
568
|
+
const start = node.getStart();
|
|
569
|
+
const end = node.getEnd();
|
|
570
|
+
const startPos = sf.getLineAndColumnAtPos(start);
|
|
571
|
+
const endPos = sf.getLineAndColumnAtPos(end);
|
|
572
|
+
const lines = fullText.split("\n");
|
|
573
|
+
const oldMiddle = lines.slice(startPos.line - 1, endPos.line);
|
|
574
|
+
const oldFirst = oldMiddle[0].slice(0, startPos.column - 1);
|
|
575
|
+
const oldLast = oldMiddle[oldMiddle.length - 1].slice(endPos.column - 1);
|
|
576
|
+
const newMiddle = (oldFirst + replacement + oldLast).split("\n");
|
|
577
|
+
const removed = oldMiddle.map((l) => `-${l}`);
|
|
578
|
+
const added = newMiddle.map((l) => `+${l}`);
|
|
579
|
+
const hunkHeader = `@@ -${startPos.line},${oldMiddle.length} +${startPos.line},${newMiddle.length} @@`;
|
|
580
|
+
return [`--- a${filePath}`, `+++ b${filePath}`, hunkHeader, ...removed, ...added].join("\n");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/fixers/positionalArgIdentity.ts
|
|
584
|
+
var fixPositionalArgIdentity = (c, p, outcome) => {
|
|
585
|
+
if (outcome.result !== "fail") return void 0;
|
|
586
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind7.CallExpression).filter((call) => {
|
|
587
|
+
const exp = call.getExpression();
|
|
588
|
+
return exp.getKind() === SyntaxKind7.PropertyAccessExpression && exp.asKind(SyntaxKind7.PropertyAccessExpression).getName() === p.call;
|
|
589
|
+
});
|
|
590
|
+
if (calls.length === 0) {
|
|
591
|
+
const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
|
|
592
|
+
return {
|
|
593
|
+
summary: `Add a ${p.call} call that verifies the raw request body`,
|
|
594
|
+
suggestion: `No '${p.call}' call was found in this handler. Verify the signature against the raw, unparsed request body \u2014 parameter '${wantParam2}' \u2014 before trusting the event, e.g.:
|
|
595
|
+
|
|
596
|
+
const event = stripe.webhooks.constructEvent(${wantParam2}, signature, process.env.STRIPE_WEBHOOK_SECRET!);
|
|
597
|
+
|
|
598
|
+
Do not call JSON.parse() on the body before this verification step.`
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const arg = calls[0].getArguments()[p.argIndex];
|
|
602
|
+
const wantParam = c.params[p.paramIndex];
|
|
603
|
+
if (arg && wantParam && arg.getKind() === SyntaxKind7.CallExpression) {
|
|
604
|
+
return {
|
|
605
|
+
summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
|
|
606
|
+
diff: buildDiff(arg, wantParam)
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
return void 0;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// src/fixers/requiredCallWithOptions.ts
|
|
613
|
+
import { SyntaxKind as SyntaxKind8 } from "ts-morph";
|
|
614
|
+
function callName4(call) {
|
|
615
|
+
const exp = call.getExpression();
|
|
616
|
+
if (exp.getKind() === SyntaxKind8.Identifier) return exp.getText();
|
|
617
|
+
if (exp.getKind() === SyntaxKind8.PropertyAccessExpression) {
|
|
618
|
+
return exp.asKind(SyntaxKind8.PropertyAccessExpression).getName();
|
|
619
|
+
}
|
|
620
|
+
return "";
|
|
621
|
+
}
|
|
622
|
+
function placeholderFor(propName) {
|
|
623
|
+
switch (propName) {
|
|
624
|
+
case "audience":
|
|
625
|
+
case "aud":
|
|
626
|
+
return `audience: process.env.PRIVY_APP_ID`;
|
|
627
|
+
case "issuer":
|
|
628
|
+
case "iss":
|
|
629
|
+
return `issuer: "https://privy.io"`;
|
|
630
|
+
default:
|
|
631
|
+
return `${propName}: undefined /* TODO: brainblast could not infer this value */`;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
var fixRequiredCallWithOptions = (c, p, outcome) => {
|
|
635
|
+
if (outcome.result !== "fail") return void 0;
|
|
636
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression);
|
|
637
|
+
const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
|
|
638
|
+
if (verify.length > 0) {
|
|
639
|
+
const call = verify[0];
|
|
640
|
+
const args = call.getArguments();
|
|
641
|
+
const lastArg = args[args.length - 1];
|
|
642
|
+
const obj = lastArg?.asKind(SyntaxKind8.ObjectLiteralExpression);
|
|
643
|
+
const presentNames = obj ? obj.getProperties().map((pr) => {
|
|
644
|
+
const pa = pr.asKind(SyntaxKind8.PropertyAssignment) ?? pr.asKind(SyntaxKind8.ShorthandPropertyAssignment);
|
|
645
|
+
return pa?.getName() ?? "";
|
|
646
|
+
}) : [];
|
|
647
|
+
const missingGroups = p.requiredProps.filter(
|
|
648
|
+
(g) => !g.some((n) => presentNames.includes(n))
|
|
649
|
+
);
|
|
650
|
+
if (missingGroups.length === 0) return void 0;
|
|
651
|
+
const newProps = missingGroups.map((g) => placeholderFor(g[0])).join(", ");
|
|
652
|
+
const summary = `Add ${missingGroups.map((g) => g[0]).join(" and ")} to the ${callName4(call)} call`;
|
|
653
|
+
if (obj) {
|
|
654
|
+
const inner = obj.getText().slice(1, -1).trim();
|
|
655
|
+
const newText = inner.length > 0 ? `{ ${inner}, ${newProps} }` : `{ ${newProps} }`;
|
|
656
|
+
return { summary, diff: buildDiff(obj, newText) };
|
|
657
|
+
}
|
|
658
|
+
if (lastArg) {
|
|
659
|
+
const newText = `${lastArg.getText()}, { ${newProps} }`;
|
|
660
|
+
return { summary, diff: buildDiff(lastArg, newText) };
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
summary,
|
|
664
|
+
suggestion: `Add an options object ({ ${newProps} }) as an argument to ${callName4(call)}.`
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
summary: "Replace the decode-only call with a verified call",
|
|
669
|
+
suggestion: `This token is decoded without verifying its signature, accepting any forged token. Replace the decode call with a verifying call that asserts audience and issuer, e.g.:
|
|
670
|
+
|
|
671
|
+
const { payload } = await jwtVerify(token, JWKS, { audience: process.env.PRIVY_APP_ID, issuer: "https://privy.io" });
|
|
672
|
+
|
|
673
|
+
JWKS must come from Privy's published JWKS endpoint for your app.`
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// src/fixers/index.ts
|
|
678
|
+
var registry2 = {
|
|
679
|
+
"positional-arg-identity": fixPositionalArgIdentity,
|
|
680
|
+
"required-call-with-options": fixRequiredCallWithOptions
|
|
681
|
+
};
|
|
682
|
+
function runFixer(kind, c, params, outcome) {
|
|
683
|
+
if (outcome.result !== "fail") return void 0;
|
|
684
|
+
const fn = registry2[kind];
|
|
685
|
+
if (!fn) return void 0;
|
|
686
|
+
return fn(c, params, outcome);
|
|
687
|
+
}
|
|
688
|
+
var fixerKinds = Object.keys(registry2);
|
|
689
|
+
|
|
690
|
+
// src/emit.ts
|
|
691
|
+
function buildReport(target, checks, rules2, costReport) {
|
|
692
|
+
const byId = new Map(rules2.map((r) => [r.id, r]));
|
|
693
|
+
const checkTotals = { pass: 0, fail: 0, cant_tell: 0 };
|
|
694
|
+
for (const c of checks) checkTotals[c.result]++;
|
|
695
|
+
const riskTotals = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
696
|
+
for (const c of checks) if (c.result === "fail") riskTotals[c.severity]++;
|
|
697
|
+
const ruleIdsSeen = [...new Set(checks.map((c) => c.ruleId))];
|
|
698
|
+
const components = ruleIdsSeen.map((id) => {
|
|
699
|
+
const rule = byId.get(id);
|
|
700
|
+
const fails = checks.filter((c) => c.ruleId === id && c.result === "fail");
|
|
701
|
+
return {
|
|
702
|
+
name: rule?.component.name ?? id,
|
|
703
|
+
type: rule?.component.type ?? "Other",
|
|
704
|
+
version: rule?.component.version ?? "unversioned",
|
|
705
|
+
sourceUrl: rule?.component.sourceUrl ?? null,
|
|
706
|
+
status: "fresh",
|
|
707
|
+
risks: fails.map((c) => ({ severity: c.severity, title: c.title, detail: c.detail }))
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
const now = /* @__PURE__ */ new Date();
|
|
711
|
+
const totalFails = checkTotals.fail;
|
|
712
|
+
return {
|
|
713
|
+
schemaVersion: "1.0",
|
|
714
|
+
run: {
|
|
715
|
+
id: now.toISOString().replace(/[-:T]/g, "").slice(0, 14),
|
|
716
|
+
date: now.toISOString().slice(0, 10),
|
|
717
|
+
requirements: `Catastrophic-integration audit of ${target}`,
|
|
718
|
+
generator: "@brainblast/core"
|
|
719
|
+
},
|
|
720
|
+
summary: {
|
|
721
|
+
building: "external integrations",
|
|
722
|
+
verdict: totalFails > 0 ? "blocked" : "ready",
|
|
723
|
+
topRisk: totalFails > 0 ? checks.find((c) => c.result === "fail")?.detail ?? null : null,
|
|
724
|
+
mustDecideFirst: null,
|
|
725
|
+
watchOutFor: null
|
|
726
|
+
},
|
|
727
|
+
components,
|
|
728
|
+
riskTotals,
|
|
729
|
+
checks: checks.map((c) => ({
|
|
730
|
+
ruleId: c.ruleId,
|
|
731
|
+
severity: c.severity,
|
|
732
|
+
result: c.result,
|
|
733
|
+
file: c.file,
|
|
734
|
+
line: c.line,
|
|
735
|
+
title: c.title,
|
|
736
|
+
detail: c.detail,
|
|
737
|
+
...c.fix ? { fix: c.fix } : {},
|
|
738
|
+
...c.precedent ? { precedent: c.precedent } : {}
|
|
739
|
+
})),
|
|
740
|
+
checkTotals,
|
|
741
|
+
openQuestions: [],
|
|
742
|
+
costAnalysis: costReport ?? null
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// src/audit.ts
|
|
747
|
+
function auditWithRule(targetDir, rule) {
|
|
748
|
+
if (rule.detect.lang === "rust") {
|
|
749
|
+
return findRustCandidates(targetDir, rule).map((c) => {
|
|
750
|
+
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
751
|
+
return {
|
|
752
|
+
ruleId: rule.id,
|
|
753
|
+
severity: rule.severity,
|
|
754
|
+
title: rule.title,
|
|
755
|
+
file: c.filePath,
|
|
756
|
+
line: 1,
|
|
757
|
+
// tree-sitter line numbers available via fnBodyNode.startPosition.row + 1
|
|
758
|
+
exportName: c.fnName,
|
|
759
|
+
...outcome
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
return findCandidates(targetDir, rule).map((c) => {
|
|
764
|
+
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
765
|
+
const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
|
|
766
|
+
return {
|
|
767
|
+
ruleId: rule.id,
|
|
768
|
+
severity: rule.severity,
|
|
769
|
+
title: rule.title,
|
|
770
|
+
file: c.filePath,
|
|
771
|
+
line: c.fn.getStartLineNumber(),
|
|
772
|
+
exportName: c.fnName,
|
|
773
|
+
...outcome,
|
|
774
|
+
...fix ? { fix } : {}
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function audit(targetDir, rules2) {
|
|
779
|
+
const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
|
|
780
|
+
const report = buildReport(targetDir, checks, rules2);
|
|
781
|
+
return { checks, report };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// src/testTemplates/stripeWebhookSignature.ts
|
|
785
|
+
var stripeWebhookSignature = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the webhook
|
|
786
|
+
// verifies the signature on the RAW body.
|
|
787
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
788
|
+
import Stripe from "stripe";
|
|
789
|
+
import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
|
|
790
|
+
|
|
791
|
+
const SECRET = "whsec_brainblast_test_secret";
|
|
792
|
+
const stripe = new Stripe("sk_test_brainblast");
|
|
793
|
+
const payload = JSON.stringify({ id: "evt_1", type: "payment_intent.succeeded", data: { object: { id: "pi_1" } } });
|
|
794
|
+
|
|
795
|
+
beforeAll(() => {
|
|
796
|
+
process.env.STRIPE_WEBHOOK_SECRET = SECRET;
|
|
797
|
+
process.env.STRIPE_SECRET_KEY = "sk_test_brainblast";
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
describe("Stripe webhook signature verification (brainblast contract)", () => {
|
|
801
|
+
it("accepts a validly-signed event", () => {
|
|
802
|
+
const sig = stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET });
|
|
803
|
+
expect(() => ${handlerExport}(payload, sig)).not.toThrow();
|
|
804
|
+
});
|
|
805
|
+
it("REJECTS an invalid signature (forged event)", () => {
|
|
806
|
+
expect(() => ${handlerExport}(payload, "t=1,v1=deadbeef")).toThrow();
|
|
807
|
+
});
|
|
808
|
+
it("REJECTS a mutated body under a valid-looking signature", () => {
|
|
809
|
+
const sig = stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET });
|
|
810
|
+
const mutated = payload.replace("succeeded", "failed");
|
|
811
|
+
expect(() => ${handlerExport}(mutated, sig)).toThrow();
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
`;
|
|
815
|
+
|
|
816
|
+
// src/testTemplates/privyJwtClaims.ts
|
|
817
|
+
var privyJwtClaims = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the token is
|
|
818
|
+
// verified (signature + aud + iss).
|
|
819
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
820
|
+
import { SignJWT, generateKeyPair, exportSPKI } from "jose";
|
|
821
|
+
import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
|
|
822
|
+
|
|
823
|
+
const APP_ID = "app_brainblast";
|
|
824
|
+
let valid = "", badSignature = "", wrongAudience = "", wrongIssuer = "";
|
|
825
|
+
|
|
826
|
+
beforeAll(async () => {
|
|
827
|
+
const { publicKey, privateKey } = await generateKeyPair("ES256");
|
|
828
|
+
const attacker = await generateKeyPair("ES256");
|
|
829
|
+
process.env.PRIVY_APP_ID = APP_ID;
|
|
830
|
+
process.env.PRIVY_VERIFICATION_KEY = await exportSPKI(publicKey);
|
|
831
|
+
|
|
832
|
+
const mint = (signer: any, iss: string, aud: string) =>
|
|
833
|
+
new SignJWT({})
|
|
834
|
+
.setProtectedHeader({ alg: "ES256" })
|
|
835
|
+
.setIssuer(iss)
|
|
836
|
+
.setAudience(aud)
|
|
837
|
+
.setSubject("did:privy:user_1")
|
|
838
|
+
.setExpirationTime("1h")
|
|
839
|
+
.sign(signer);
|
|
840
|
+
|
|
841
|
+
valid = await mint(privateKey, "privy.io", APP_ID);
|
|
842
|
+
badSignature = await mint(attacker.privateKey, "privy.io", APP_ID);
|
|
843
|
+
wrongAudience = await mint(privateKey, "privy.io", "app_evil");
|
|
844
|
+
wrongIssuer = await mint(privateKey, "evil.com", APP_ID);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const call = (t: string) => Promise.resolve().then(() => ${handlerExport}(t));
|
|
848
|
+
|
|
849
|
+
describe("Privy access-token verification (brainblast contract)", () => {
|
|
850
|
+
it("accepts a valid token", async () => { await expect(call(valid)).resolves.toBeDefined(); });
|
|
851
|
+
it("REJECTS a bad signature (forged token)", async () => { await expect(call(badSignature)).rejects.toThrow(); });
|
|
852
|
+
it("REJECTS a wrong audience (token from another app)", async () => { await expect(call(wrongAudience)).rejects.toThrow(); });
|
|
853
|
+
it("REJECTS a wrong issuer", async () => { await expect(call(wrongIssuer)).rejects.toThrow(); });
|
|
854
|
+
});
|
|
855
|
+
`;
|
|
856
|
+
|
|
857
|
+
// src/testTemplates/bagsFeeShare.ts
|
|
858
|
+
var bagsFeeShare = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the fee-share
|
|
859
|
+
// config includes the creator wallet with a non-zero userBps summing to 10000.
|
|
860
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
861
|
+
|
|
862
|
+
let captured: any;
|
|
863
|
+
vi.mock("@bagsfm/bags-sdk", () => ({
|
|
864
|
+
createBagsFeeShareConfig: (cfg: any) => {
|
|
865
|
+
captured = cfg;
|
|
866
|
+
return { meteoraConfigKey: "mock-config-key" };
|
|
867
|
+
},
|
|
868
|
+
BagsSDK: class {},
|
|
869
|
+
}));
|
|
870
|
+
|
|
871
|
+
import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
|
|
872
|
+
|
|
873
|
+
const CREATOR = "CreatorWa11etPubKey1111111111111111111111111";
|
|
874
|
+
|
|
875
|
+
describe("Bags fee-share creator inclusion (brainblast contract)", () => {
|
|
876
|
+
beforeEach(() => {
|
|
877
|
+
captured = undefined;
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("passes the creator wallet into feeClaimers", () => {
|
|
881
|
+
${handlerExport}(CREATOR);
|
|
882
|
+
const claimers = captured?.feeClaimers ?? [];
|
|
883
|
+
expect(claimers.some((c: any) => c.user === CREATOR)).toBe(true);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("allocates the creator a non-zero userBps", () => {
|
|
887
|
+
${handlerExport}(CREATOR);
|
|
888
|
+
const entry = (captured?.feeClaimers ?? []).find((c: any) => c.user === CREATOR);
|
|
889
|
+
expect(entry?.userBps ?? 0).toBeGreaterThan(0);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("feeClaimers userBps sum to exactly 10000", () => {
|
|
893
|
+
${handlerExport}(CREATOR);
|
|
894
|
+
const sum = (captured?.feeClaimers ?? []).reduce((s: number, c: any) => s + c.userBps, 0);
|
|
895
|
+
expect(sum).toBe(10000);
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
`;
|
|
899
|
+
|
|
900
|
+
// src/testTemplates/tokenProgramConsistency.ts
|
|
901
|
+
var tokenProgramConsistency = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until createMint is
|
|
902
|
+
// called with TOKEN_2022_PROGRAM_ID as the programId argument.
|
|
903
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
904
|
+
import { PublicKey } from "@solana/web3.js";
|
|
905
|
+
|
|
906
|
+
// Keep the well-known token program addresses as strings; we compare via
|
|
907
|
+
// .toString() so we never depend on PublicKey identity across the mock
|
|
908
|
+
// boundary. Defined at module scope (not used inside vi.mock factory, which
|
|
909
|
+
// is hoisted before any const initializations).
|
|
910
|
+
const TOKEN_PROGRAM_ID_STR = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
911
|
+
const TOKEN_2022_PROGRAM_ID_STR = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
912
|
+
|
|
913
|
+
// The capture target. vi.hoisted runs before vi.mock factory hoisting, so the
|
|
914
|
+
// factory below can close over \`captured\` safely.
|
|
915
|
+
const captured = vi.hoisted(() => ({ value: { programId: undefined as any } }));
|
|
916
|
+
|
|
917
|
+
vi.mock("@solana/spl-token", () => {
|
|
918
|
+
// PublicKey instantiation lives INSIDE the factory so it runs after the
|
|
919
|
+
// import from @solana/web3.js has resolved.
|
|
920
|
+
const { PublicKey: PK } = require("@solana/web3.js");
|
|
921
|
+
const TOKEN_PROGRAM_ID = new PK("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
|
|
922
|
+
const TOKEN_2022_PROGRAM_ID = new PK("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb");
|
|
923
|
+
const FAKE_MINT = new PK("11111111111111111111111111111111");
|
|
924
|
+
return {
|
|
925
|
+
TOKEN_PROGRAM_ID,
|
|
926
|
+
TOKEN_2022_PROGRAM_ID,
|
|
927
|
+
createMint: (
|
|
928
|
+
_connection: any,
|
|
929
|
+
_payer: any,
|
|
930
|
+
_mintAuthority: any,
|
|
931
|
+
_freezeAuthority: any,
|
|
932
|
+
_decimals: any,
|
|
933
|
+
_keypair: any,
|
|
934
|
+
_confirmOptions: any,
|
|
935
|
+
programId: any,
|
|
936
|
+
) => {
|
|
937
|
+
captured.value.programId = programId;
|
|
938
|
+
return Promise.resolve(FAKE_MINT);
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
|
|
944
|
+
|
|
945
|
+
describe("Token-2022 program-ID pinning (brainblast contract)", () => {
|
|
946
|
+
beforeEach(() => {
|
|
947
|
+
captured.value.programId = undefined;
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("passes TOKEN_2022_PROGRAM_ID as the createMint programId argument", async () => {
|
|
951
|
+
await Promise.resolve(${handlerExport}({} as any));
|
|
952
|
+
expect(captured.value.programId, "createMint was not called or did not receive a programId").toBeDefined();
|
|
953
|
+
expect(String(captured.value.programId)).toBe(TOKEN_2022_PROGRAM_ID_STR);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("does not default to the legacy token program", async () => {
|
|
957
|
+
await Promise.resolve(${handlerExport}({} as any));
|
|
958
|
+
expect(String(captured.value.programId)).not.toBe(TOKEN_PROGRAM_ID_STR);
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
`;
|
|
962
|
+
|
|
963
|
+
// src/testTemplates/metaplexImmutableMetadata.ts
|
|
964
|
+
var metaplexImmutableMetadata = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the metadata
|
|
965
|
+
// creation call includes isMutable: false.
|
|
966
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
967
|
+
|
|
968
|
+
// Capture target \u2014 vi.hoisted ensures this is initialized before vi.mock factories run.
|
|
969
|
+
const captured = vi.hoisted(() => ({ value: { isMutable: undefined as boolean | undefined } }));
|
|
970
|
+
|
|
971
|
+
vi.mock("@metaplex-foundation/mpl-token-metadata", () => ({
|
|
972
|
+
createV1: (_umi: any, opts: any) => {
|
|
973
|
+
captured.value.isMutable = opts.isMutable;
|
|
974
|
+
return Promise.resolve();
|
|
975
|
+
},
|
|
976
|
+
createNft: (_metaplex: any, opts: any) => {
|
|
977
|
+
captured.value.isMutable = opts.isMutable;
|
|
978
|
+
return Promise.resolve({ nft: {} });
|
|
979
|
+
},
|
|
980
|
+
createAndMint: (_umi: any, opts: any) => {
|
|
981
|
+
captured.value.isMutable = opts.isMutable;
|
|
982
|
+
return Promise.resolve();
|
|
983
|
+
},
|
|
984
|
+
// Stub enums so import-only references don't crash
|
|
985
|
+
TokenStandard: { Fungible: 2, FungibleAsset: 3, NonFungible: 0, NonFungibleEdition: 1 },
|
|
986
|
+
printSupply: (x: any) => x,
|
|
987
|
+
percentAmount: (n: number) => n,
|
|
988
|
+
}));
|
|
989
|
+
|
|
990
|
+
import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
|
|
991
|
+
|
|
992
|
+
describe("Metaplex metadata immutability (brainblast contract)", () => {
|
|
993
|
+
beforeEach(() => {
|
|
994
|
+
captured.value.isMutable = undefined;
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("passes isMutable: false to the metadata creation call", async () => {
|
|
998
|
+
await Promise.resolve(${handlerExport}({} as any));
|
|
999
|
+
expect(
|
|
1000
|
+
captured.value.isMutable,
|
|
1001
|
+
"createV1/createNft/createAndMint was not called or isMutable was not passed",
|
|
1002
|
+
).toBeDefined();
|
|
1003
|
+
expect(captured.value.isMutable).toBe(false);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("does not leave metadata mutable (isMutable !== true)", async () => {
|
|
1007
|
+
await Promise.resolve(${handlerExport}({} as any));
|
|
1008
|
+
expect(captured.value.isMutable).not.toBe(true);
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
`;
|
|
1012
|
+
|
|
1013
|
+
// src/testTemplates/anchorProgramTest.ts
|
|
1014
|
+
var anchorProgramTest = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 paste into your
|
|
1015
|
+
// Anchor program's tests/ directory and run: anchor test
|
|
1016
|
+
//
|
|
1017
|
+
// Handler under test: ${handlerExport} (${handlerImportPath})
|
|
1018
|
+
//
|
|
1019
|
+
// Contract: calling the instruction a second time on the same account must
|
|
1020
|
+
// fail with an AlreadyInitialized error (or equivalent). If it succeeds,
|
|
1021
|
+
// the account state has been silently overwritten.
|
|
1022
|
+
|
|
1023
|
+
use anchor_lang::prelude::*;
|
|
1024
|
+
use anchor_lang::solana_program::instruction::InstructionError;
|
|
1025
|
+
|
|
1026
|
+
// NOTE: Replace MyProgram with your program's generated client and adjust
|
|
1027
|
+
// the instruction call to match your handler's signature.
|
|
1028
|
+
|
|
1029
|
+
#[cfg(test)]
|
|
1030
|
+
mod brainblast_reinit_guard_test {
|
|
1031
|
+
// Import your program's client \u2014 e.g.:
|
|
1032
|
+
// use my_program::*;
|
|
1033
|
+
// use anchor_lang::solana_program::pubkey::Pubkey;
|
|
1034
|
+
|
|
1035
|
+
#[tokio::test]
|
|
1036
|
+
async fn first_call_succeeds() {
|
|
1037
|
+
// Arrange: set up a fresh program test environment
|
|
1038
|
+
// let program_test = ProgramTest::new("${handlerExport}", id(), None);
|
|
1039
|
+
// let (mut banks_client, payer, recent_blockhash) = program_test.start().await;
|
|
1040
|
+
|
|
1041
|
+
// Act: invoke ${handlerExport} for the first time
|
|
1042
|
+
// assert!(result.is_ok(), "First initialization must succeed");
|
|
1043
|
+
todo!("implement: first call to ${handlerExport} should succeed");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
#[tokio::test]
|
|
1047
|
+
async fn second_call_is_rejected() {
|
|
1048
|
+
// Arrange: initialize the account (same as above)
|
|
1049
|
+
// Act: invoke ${handlerExport} a SECOND time on the same account
|
|
1050
|
+
// Assert: must fail \u2014 AlreadyInitialized or similar
|
|
1051
|
+
// let err = result.unwrap_err();
|
|
1052
|
+
// assert!(matches!(err, ...AlreadyInitialized));
|
|
1053
|
+
todo!("implement: second call to ${handlerExport} must be rejected");
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
`;
|
|
1057
|
+
|
|
1058
|
+
// src/testTemplates/index.ts
|
|
1059
|
+
var registry3 = {
|
|
1060
|
+
"stripe-webhook-signature": stripeWebhookSignature,
|
|
1061
|
+
"privy-jwt-claims": privyJwtClaims,
|
|
1062
|
+
"bags-fee-share": bagsFeeShare,
|
|
1063
|
+
"token-program-consistency": tokenProgramConsistency,
|
|
1064
|
+
"metaplex-immutable-metadata": metaplexImmutableMetadata,
|
|
1065
|
+
"anchor-program-test": anchorProgramTest
|
|
1066
|
+
};
|
|
1067
|
+
var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
1068
|
+
function renderTest(kind, opts) {
|
|
1069
|
+
const tpl = registry3[kind];
|
|
1070
|
+
if (!tpl) throw new Error(`Unknown test template kind '${kind}'.`);
|
|
1071
|
+
if (!JS_IDENTIFIER.test(opts.handlerExport)) {
|
|
1072
|
+
throw new Error(`Unsafe handler export name '${opts.handlerExport}' (not a JS identifier).`);
|
|
1073
|
+
}
|
|
1074
|
+
return tpl(opts);
|
|
1075
|
+
}
|
|
1076
|
+
var testKinds = Object.keys(registry3);
|
|
1077
|
+
|
|
1078
|
+
// src/loadRules.ts
|
|
1079
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync2 } from "fs";
|
|
1080
|
+
import { join as join3 } from "path";
|
|
1081
|
+
import { parse } from "yaml";
|
|
1082
|
+
var SEVERITIES = ["critical", "high", "medium", "low"];
|
|
1083
|
+
function validateRule(r, file) {
|
|
1084
|
+
const errs = [];
|
|
1085
|
+
if (!r || typeof r !== "object") {
|
|
1086
|
+
throw new Error(`invalid rule in ${file}: not a mapping`);
|
|
1087
|
+
}
|
|
1088
|
+
if (!r.id || typeof r.id !== "string") errs.push("missing id");
|
|
1089
|
+
if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
|
|
1090
|
+
if (!r.title || typeof r.title !== "string") errs.push("missing title");
|
|
1091
|
+
if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
|
|
1092
|
+
if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
|
|
1093
|
+
errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
|
|
1094
|
+
} else {
|
|
1095
|
+
try {
|
|
1096
|
+
new RegExp(r.detect.nameRegex);
|
|
1097
|
+
} catch {
|
|
1098
|
+
errs.push(`detect.nameRegex is not a valid regex: ${r.detect.nameRegex}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (!r.check || !checkerKinds.includes(r.check.kind)) {
|
|
1102
|
+
errs.push(`check.kind must be one of ${checkerKinds.join("|")} (got '${r.check?.kind}')`);
|
|
1103
|
+
}
|
|
1104
|
+
if (!r.test || !testKinds.includes(r.test.kind)) {
|
|
1105
|
+
errs.push(`test.kind must be one of ${testKinds.join("|")} (got '${r.test?.kind}')`);
|
|
1106
|
+
}
|
|
1107
|
+
if (errs.length) throw new Error(`invalid rule in ${file}: ${errs.join("; ")}`);
|
|
1108
|
+
}
|
|
1109
|
+
function loadRules(dir) {
|
|
1110
|
+
const files = readdirSync3(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
|
|
1111
|
+
const rules2 = [];
|
|
1112
|
+
for (const f of files) {
|
|
1113
|
+
const raw = parse(readFileSync2(join3(dir, f), "utf8"));
|
|
1114
|
+
validateRule(raw, f);
|
|
1115
|
+
rules2.push(raw);
|
|
1116
|
+
}
|
|
1117
|
+
return rules2;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// rules/index.ts
|
|
1121
|
+
import { existsSync } from "fs";
|
|
1122
|
+
import { dirname, join as join4 } from "path";
|
|
1123
|
+
import { fileURLToPath } from "url";
|
|
1124
|
+
function bundledRulesDir() {
|
|
1125
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1126
|
+
if (existsSync(join4(here, "stripe-webhook-raw-body.yaml"))) return here;
|
|
1127
|
+
const sub = join4(here, "rules");
|
|
1128
|
+
if (existsSync(join4(sub, "stripe-webhook-raw-body.yaml"))) return sub;
|
|
1129
|
+
return here;
|
|
1130
|
+
}
|
|
1131
|
+
var rules = loadRules(bundledRulesDir());
|
|
1132
|
+
|
|
1133
|
+
// src/resolveRules.ts
|
|
1134
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1135
|
+
import { join as join5 } from "path";
|
|
1136
|
+
function resolveRules(targetDir) {
|
|
1137
|
+
const all = [...rules];
|
|
1138
|
+
const projDir = join5(targetDir, ".agent-research", "rules");
|
|
1139
|
+
if (existsSync2(projDir)) {
|
|
1140
|
+
const seen = new Set(all.map((r) => r.id));
|
|
1141
|
+
for (const r of loadRules(projDir)) {
|
|
1142
|
+
if (seen.has(r.id)) {
|
|
1143
|
+
console.warn(`brainblast: project rule '${r.id}' shadows a bundled rule; keeping bundled.`);
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
all.push(r);
|
|
1147
|
+
seen.add(r.id);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return all;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/trustGraph/directory.ts
|
|
1154
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
|
|
1155
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1156
|
+
import { join as join6 } from "path";
|
|
1157
|
+
import { parse as parse2 } from "yaml";
|
|
1158
|
+
var cache = null;
|
|
1159
|
+
function bundledPath() {
|
|
1160
|
+
const here = fileURLToPath2(new URL(".", import.meta.url));
|
|
1161
|
+
const candidates = [
|
|
1162
|
+
join6(here, "programs", "directory.yaml"),
|
|
1163
|
+
// dist/programs/directory.yaml
|
|
1164
|
+
join6(here, "..", "..", "programs", "directory.yaml"),
|
|
1165
|
+
// src/../../programs/
|
|
1166
|
+
join6(here, "..", "programs", "directory.yaml")
|
|
1167
|
+
// fallback
|
|
1168
|
+
];
|
|
1169
|
+
for (const c of candidates) {
|
|
1170
|
+
if (existsSync3(c)) return c;
|
|
1171
|
+
}
|
|
1172
|
+
return candidates[0];
|
|
1173
|
+
}
|
|
1174
|
+
function loadDirectory(path = bundledPath()) {
|
|
1175
|
+
if (cache && path === bundledPath()) return cache;
|
|
1176
|
+
const raw = parse2(readFileSync3(path, "utf8"));
|
|
1177
|
+
if (!raw || !Array.isArray(raw.programs)) {
|
|
1178
|
+
throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
|
|
1179
|
+
}
|
|
1180
|
+
const m = /* @__PURE__ */ new Map();
|
|
1181
|
+
for (const p of raw.programs) {
|
|
1182
|
+
if (!p.programId || !p.name) throw new Error(`directory entry missing programId/name: ${JSON.stringify(p)}`);
|
|
1183
|
+
if (m.has(p.programId)) throw new Error(`directory has duplicate programId ${p.programId}`);
|
|
1184
|
+
m.set(p.programId, { ...p, provenance: { ...p.provenance ?? {}, directoryFile: path } });
|
|
1185
|
+
}
|
|
1186
|
+
if (path === bundledPath()) cache = m;
|
|
1187
|
+
return m;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/trustGraph/base58.ts
|
|
1191
|
+
var ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
1192
|
+
var MAP = {};
|
|
1193
|
+
for (let i = 0; i < ALPHA.length; i++) MAP[ALPHA[i]] = i;
|
|
1194
|
+
function base58Encode(bytes) {
|
|
1195
|
+
let zeros = 0;
|
|
1196
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
1197
|
+
const buf = Array.from(bytes);
|
|
1198
|
+
const out = [];
|
|
1199
|
+
let start = zeros;
|
|
1200
|
+
while (start < buf.length) {
|
|
1201
|
+
let rem = 0;
|
|
1202
|
+
for (let i = start; i < buf.length; i++) {
|
|
1203
|
+
const acc = rem * 256 + buf[i];
|
|
1204
|
+
buf[i] = Math.floor(acc / 58);
|
|
1205
|
+
rem = acc % 58;
|
|
1206
|
+
}
|
|
1207
|
+
out.push(rem);
|
|
1208
|
+
if (buf[start] === 0) start++;
|
|
1209
|
+
}
|
|
1210
|
+
let s = "";
|
|
1211
|
+
for (let i = 0; i < zeros; i++) s += "1";
|
|
1212
|
+
for (let i = out.length - 1; i >= 0; i--) s += ALPHA[out[i]];
|
|
1213
|
+
return s;
|
|
1214
|
+
}
|
|
1215
|
+
function base58Decode(s) {
|
|
1216
|
+
let zeros = 0;
|
|
1217
|
+
while (zeros < s.length && s[zeros] === "1") zeros++;
|
|
1218
|
+
const buf = [];
|
|
1219
|
+
for (let i = zeros; i < s.length; i++) {
|
|
1220
|
+
const v = MAP[s[i]];
|
|
1221
|
+
if (v === void 0) throw new Error(`base58: invalid char '${s[i]}' at ${i}`);
|
|
1222
|
+
let carry = v;
|
|
1223
|
+
for (let j = 0; j < buf.length; j++) {
|
|
1224
|
+
const acc = buf[j] * 58 + carry;
|
|
1225
|
+
buf[j] = acc & 255;
|
|
1226
|
+
carry = acc >>> 8;
|
|
1227
|
+
}
|
|
1228
|
+
while (carry > 0) {
|
|
1229
|
+
buf.push(carry & 255);
|
|
1230
|
+
carry >>>= 8;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
const out = new Uint8Array(zeros + buf.length);
|
|
1234
|
+
for (let i = 0; i < buf.length; i++) out[zeros + buf.length - 1 - i] = buf[i];
|
|
1235
|
+
return out;
|
|
1236
|
+
}
|
|
1237
|
+
function isValidSolanaAddress(s) {
|
|
1238
|
+
if (typeof s !== "string" || s.length < 32 || s.length > 44) return false;
|
|
1239
|
+
try {
|
|
1240
|
+
return base58Decode(s).length === 32;
|
|
1241
|
+
} catch {
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/trustGraph/programCache.ts
|
|
1247
|
+
import { readFileSync as readFileSync4, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
|
|
1248
|
+
import { join as join7, dirname as dirname2 } from "path";
|
|
1249
|
+
import { homedir } from "os";
|
|
1250
|
+
var DEFAULT_TTL_HOURS = 168;
|
|
1251
|
+
var SCHEMA_VERSION = "1.0";
|
|
1252
|
+
function defaultCachePath() {
|
|
1253
|
+
const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
|
|
1254
|
+
return envOverride ?? join7(homedir(), ".brainblast", "program-cache.json");
|
|
1255
|
+
}
|
|
1256
|
+
function emptyCache() {
|
|
1257
|
+
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
1258
|
+
}
|
|
1259
|
+
function loadProgramCache(cachePath) {
|
|
1260
|
+
const path = cachePath ?? defaultCachePath();
|
|
1261
|
+
if (!existsSync4(path)) return emptyCache();
|
|
1262
|
+
try {
|
|
1263
|
+
const raw = JSON.parse(readFileSync4(path, "utf8"));
|
|
1264
|
+
if (raw?.schemaVersion !== SCHEMA_VERSION) {
|
|
1265
|
+
return emptyCache();
|
|
1266
|
+
}
|
|
1267
|
+
if (!raw.entries || typeof raw.entries !== "object") return emptyCache();
|
|
1268
|
+
return { schemaVersion: SCHEMA_VERSION, entries: raw.entries };
|
|
1269
|
+
} catch {
|
|
1270
|
+
return emptyCache();
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
function saveProgramCache(cache2, cachePath) {
|
|
1274
|
+
const path = cachePath ?? defaultCachePath();
|
|
1275
|
+
mkdirSync(dirname2(path), { recursive: true });
|
|
1276
|
+
writeFileSync(path, JSON.stringify(cache2, null, 2), "utf8");
|
|
1277
|
+
}
|
|
1278
|
+
function getCacheEntry(cache2, programId, ttlHoursOverride) {
|
|
1279
|
+
const entry = cache2.entries[programId];
|
|
1280
|
+
if (!entry) return null;
|
|
1281
|
+
if (isEntryExpired(entry, ttlHoursOverride)) return null;
|
|
1282
|
+
return entry.program;
|
|
1283
|
+
}
|
|
1284
|
+
function putCacheEntry(cache2, programId, program, sourceRun, ttlHours = DEFAULT_TTL_HOURS) {
|
|
1285
|
+
cache2.entries[programId] = {
|
|
1286
|
+
program,
|
|
1287
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1288
|
+
sourceRun,
|
|
1289
|
+
ttlHours
|
|
1290
|
+
};
|
|
1291
|
+
return cache2;
|
|
1292
|
+
}
|
|
1293
|
+
function getCacheEntryMeta(cache2, programId) {
|
|
1294
|
+
return cache2.entries[programId] ?? null;
|
|
1295
|
+
}
|
|
1296
|
+
function isEntryExpired(entry, ttlHoursOverride) {
|
|
1297
|
+
const ttl = ttlHoursOverride ?? entry.ttlHours ?? DEFAULT_TTL_HOURS;
|
|
1298
|
+
if (ttl <= 0) return true;
|
|
1299
|
+
const cachedMs = Date.parse(entry.cachedAt);
|
|
1300
|
+
if (Number.isNaN(cachedMs)) return true;
|
|
1301
|
+
const ageMs = Date.now() - cachedMs;
|
|
1302
|
+
return ageMs >= ttl * 36e5;
|
|
1303
|
+
}
|
|
1304
|
+
function cacheSize(cache2, ttlHoursOverride) {
|
|
1305
|
+
return Object.values(cache2.entries).filter((e) => !isEntryExpired(e, ttlHoursOverride)).length;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// src/trustGraph/rpc.ts
|
|
1309
|
+
var BPF_UPGRADEABLE_LOADER = "BPFLoaderUpgradeab1e11111111111111111111111";
|
|
1310
|
+
var BPF_LOADER_2 = "BPFLoader2111111111111111111111111111111111";
|
|
1311
|
+
var NATIVE_LOADER = "NativeLoader1111111111111111111111111111111";
|
|
1312
|
+
var DEFAULT_RPC = "https://api.mainnet-beta.solana.com";
|
|
1313
|
+
async function rpc(method, params, opts) {
|
|
1314
|
+
const url = opts.rpcUrl ?? DEFAULT_RPC;
|
|
1315
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1316
|
+
const ac = new AbortController();
|
|
1317
|
+
const t = setTimeout(() => ac.abort(), opts.timeoutMs ?? 1e4);
|
|
1318
|
+
try {
|
|
1319
|
+
const res = await fetchImpl(url, {
|
|
1320
|
+
method: "POST",
|
|
1321
|
+
headers: { "content-type": "application/json" },
|
|
1322
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
1323
|
+
signal: ac.signal
|
|
1324
|
+
});
|
|
1325
|
+
if (!res.ok) throw new Error(`rpc ${method}: HTTP ${res.status}`);
|
|
1326
|
+
const body = await res.json();
|
|
1327
|
+
if (body.error) throw new Error(`rpc ${method}: ${body.error.message}`);
|
|
1328
|
+
if (body.result === void 0) throw new Error(`rpc ${method}: empty result`);
|
|
1329
|
+
return body.result;
|
|
1330
|
+
} finally {
|
|
1331
|
+
clearTimeout(t);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
async function getAccountInfo(address, opts = {}) {
|
|
1335
|
+
if (!isValidSolanaAddress(address)) throw new Error(`invalid Solana address: ${address}`);
|
|
1336
|
+
const result = await rpc(
|
|
1337
|
+
"getAccountInfo",
|
|
1338
|
+
[address, { encoding: "base64", commitment: "confirmed" }],
|
|
1339
|
+
opts
|
|
1340
|
+
);
|
|
1341
|
+
if (!result || !result.value) return null;
|
|
1342
|
+
const v = result.value;
|
|
1343
|
+
const [b64] = v.data;
|
|
1344
|
+
return {
|
|
1345
|
+
owner: v.owner,
|
|
1346
|
+
data: Buffer.from(b64, "base64"),
|
|
1347
|
+
executable: v.executable,
|
|
1348
|
+
lamports: v.lamports
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
async function probeUpgradeAuthority(programId, opts = {}) {
|
|
1352
|
+
const acct = await getAccountInfo(programId, opts);
|
|
1353
|
+
if (!acct) {
|
|
1354
|
+
return {
|
|
1355
|
+
kind: "unknown",
|
|
1356
|
+
address: null,
|
|
1357
|
+
source: "rpc",
|
|
1358
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
if (acct.owner === BPF_LOADER_2 || acct.owner === NATIVE_LOADER) {
|
|
1362
|
+
return {
|
|
1363
|
+
kind: "renounced",
|
|
1364
|
+
address: null,
|
|
1365
|
+
source: "rpc",
|
|
1366
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
if (acct.owner !== BPF_UPGRADEABLE_LOADER) {
|
|
1370
|
+
throw new Error(
|
|
1371
|
+
`program ${programId} is owned by ${acct.owner}, not a known loader; not a deployed program?`
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
if (acct.data.length < 36) throw new Error(`program account too small: ${acct.data.length}`);
|
|
1375
|
+
const tag = acct.data[0] | acct.data[1] << 8 | acct.data[2] << 16 | acct.data[3] << 24;
|
|
1376
|
+
if (tag !== 2) throw new Error(`expected Program (tag=2) state, got tag=${tag}`);
|
|
1377
|
+
const programDataAddr = base58Encode(acct.data.subarray(4, 36));
|
|
1378
|
+
const pd = await getAccountInfo(programDataAddr, opts);
|
|
1379
|
+
if (!pd) {
|
|
1380
|
+
throw new Error(`program ${programId} ProgramData ${programDataAddr} not found`);
|
|
1381
|
+
}
|
|
1382
|
+
if (pd.data.length < 45) throw new Error(`ProgramData account too small: ${pd.data.length}`);
|
|
1383
|
+
const pdTag = pd.data[0] | pd.data[1] << 8 | pd.data[2] << 16 | pd.data[3] << 24;
|
|
1384
|
+
if (pdTag !== 3) throw new Error(`expected ProgramData (tag=3), got tag=${pdTag}`);
|
|
1385
|
+
const optionTag = pd.data[12];
|
|
1386
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1387
|
+
if (optionTag === 0) {
|
|
1388
|
+
return { kind: "renounced", address: null, source: "rpc", checkedAt };
|
|
1389
|
+
}
|
|
1390
|
+
if (optionTag !== 1) throw new Error(`unexpected Option tag in ProgramData: ${optionTag}`);
|
|
1391
|
+
const authority = base58Encode(pd.data.subarray(13, 45));
|
|
1392
|
+
return { kind: "unknown", address: authority, source: "rpc", checkedAt };
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// src/trustGraph/build.ts
|
|
1396
|
+
async function buildTrustGraph(programIds, opts = {}) {
|
|
1397
|
+
const dir = loadDirectory(opts.directoryPath);
|
|
1398
|
+
const programs = [];
|
|
1399
|
+
const unresolved = [];
|
|
1400
|
+
const cacheEnabled = opts.cachePath !== null;
|
|
1401
|
+
const cachePathArg = opts.cachePath === null ? void 0 : opts.cachePath;
|
|
1402
|
+
const cache2 = cacheEnabled ? loadProgramCache(cachePathArg) : null;
|
|
1403
|
+
const newFromRpc = [];
|
|
1404
|
+
const runId = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
1405
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1406
|
+
const ordered = programIds.filter((id) => seen.has(id) ? false : (seen.add(id), true));
|
|
1407
|
+
for (const id of ordered) {
|
|
1408
|
+
const directoryHit = dir.get(id);
|
|
1409
|
+
if (directoryHit) {
|
|
1410
|
+
programs.push(directoryHit);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
if (cache2) {
|
|
1414
|
+
const cached = getCacheEntry(cache2, id);
|
|
1415
|
+
if (cached) {
|
|
1416
|
+
const meta = getCacheEntryMeta(cache2, id);
|
|
1417
|
+
programs.push({
|
|
1418
|
+
...cached,
|
|
1419
|
+
provenance: {
|
|
1420
|
+
...cached.provenance ?? {},
|
|
1421
|
+
notes: [
|
|
1422
|
+
cached.provenance?.notes,
|
|
1423
|
+
`cache-hit: cachedAt=${meta.cachedAt} sourceRun=${meta.sourceRun}`
|
|
1424
|
+
].filter(Boolean).join("; ")
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (opts.probeRpc === false) {
|
|
1431
|
+
unresolved.push({
|
|
1432
|
+
programId: id,
|
|
1433
|
+
reason: "not_in_directory_or_cache_and_rpc_disabled"
|
|
1434
|
+
});
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
let authority;
|
|
1438
|
+
try {
|
|
1439
|
+
authority = await probeUpgradeAuthority(id, opts);
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
unresolved.push({ programId: id, reason: `rpc_error: ${e?.message ?? String(e)}` });
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
const probed = {
|
|
1445
|
+
programId: id,
|
|
1446
|
+
name: `Unknown program (${id.slice(0, 8)}\u2026)`,
|
|
1447
|
+
kind: "app",
|
|
1448
|
+
upgradeAuthority: authority,
|
|
1449
|
+
verifiedBuild: { state: "unknown" },
|
|
1450
|
+
audits: [],
|
|
1451
|
+
parity: { mainnet: "unknown", devnet: "unknown" },
|
|
1452
|
+
provenance: { rpcUrl: opts.rpcUrl, notes: "live-probed; not in curated directory" }
|
|
1453
|
+
};
|
|
1454
|
+
programs.push(probed);
|
|
1455
|
+
newFromRpc.push(id);
|
|
1456
|
+
if (cache2) {
|
|
1457
|
+
putCacheEntry(cache2, id, probed, runId);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (cache2 && newFromRpc.length > 0) {
|
|
1461
|
+
saveProgramCache(cache2, cachePathArg);
|
|
1462
|
+
}
|
|
1463
|
+
return { programs, unresolved, generatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/trustGraph/render.ts
|
|
1467
|
+
function renderAuthority(p) {
|
|
1468
|
+
const a = p.upgradeAuthority;
|
|
1469
|
+
switch (a.kind) {
|
|
1470
|
+
case "renounced":
|
|
1471
|
+
return "\u{1F512} **Renounced** \u2014 program is frozen; no key can upgrade it.";
|
|
1472
|
+
case "single-key":
|
|
1473
|
+
return `\u26A0\uFE0F **Single key** \`${a.address}\` \u2014 one private key can replace this program at any time.`;
|
|
1474
|
+
case "multisig":
|
|
1475
|
+
return `\u{1F510} **Multisig** \`${a.address}\` \u2014 a threshold of signers can upgrade.`;
|
|
1476
|
+
case "dao":
|
|
1477
|
+
return `\u{1F3DB} **DAO** \`${a.address}\` \u2014 governance program controls upgrades.`;
|
|
1478
|
+
case "unknown":
|
|
1479
|
+
return a.address ? `\u2753 **Unclassified authority** \`${a.address}\` \u2014 needs research to confirm single-key vs multisig/DAO.` : "\u2753 **Unknown** \u2014 could not determine upgrade authority.";
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function renderVerified(p) {
|
|
1483
|
+
const v = p.verifiedBuild;
|
|
1484
|
+
switch (v.state) {
|
|
1485
|
+
case "verified":
|
|
1486
|
+
return `\u2705 Verified build${v.commit ? ` @ \`${v.commit.slice(0, 12)}\`` : ""} \u2014 [registry](${v.registryUrl})`;
|
|
1487
|
+
case "unverified":
|
|
1488
|
+
return "\u274C Unverified \u2014 on-chain bytecode does not match any source we trust.";
|
|
1489
|
+
case "unknown":
|
|
1490
|
+
return "\u2753 Verified-build status not checked.";
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
function renderAudits(p) {
|
|
1494
|
+
if (!p.audits.length) return "_No audits on file._";
|
|
1495
|
+
return p.audits.map((a) => `- ${a.firm} (${a.date}) \u2014 [report](${a.reportUrl})${a.auditedCommit ? ` @ \`${a.auditedCommit.slice(0, 12)}\`` : ""}`).join("\n");
|
|
1496
|
+
}
|
|
1497
|
+
function renderParity(p) {
|
|
1498
|
+
const { mainnet, devnet, testnet, notes } = p.parity;
|
|
1499
|
+
const cells = [`mainnet=\`${mainnet}\``, `devnet=\`${devnet}\``];
|
|
1500
|
+
if (testnet) cells.push(`testnet=\`${testnet}\``);
|
|
1501
|
+
return cells.join(" \xB7 ") + (notes ? `
|
|
1502
|
+
_${notes}_` : "");
|
|
1503
|
+
}
|
|
1504
|
+
function renderProgram(p) {
|
|
1505
|
+
return [
|
|
1506
|
+
`### ${p.name}`,
|
|
1507
|
+
"",
|
|
1508
|
+
`\`${p.programId}\`${p.kind ? ` \xB7 kind: \`${p.kind}\`` : ""}`,
|
|
1509
|
+
"",
|
|
1510
|
+
`- **Upgrade authority:** ${renderAuthority(p)}`,
|
|
1511
|
+
`- **Verified build:** ${renderVerified(p)}`,
|
|
1512
|
+
`- **Parity:** ${renderParity(p)}`,
|
|
1513
|
+
`- **Audits:**
|
|
1514
|
+
${renderAudits(p).split("\n").map((l) => " " + l).join("\n")}`,
|
|
1515
|
+
p.invokes && p.invokes.length ? `- **Invokes (CPI):** ${p.invokes.map((id) => `\`${id}\``).join(", ")}` : ""
|
|
1516
|
+
].filter(Boolean).join("\n");
|
|
1517
|
+
}
|
|
1518
|
+
function renderTrustGraphMd(g) {
|
|
1519
|
+
const head = [
|
|
1520
|
+
"# Trust Graph",
|
|
1521
|
+
"",
|
|
1522
|
+
`_Generated ${g.generatedAt}._`,
|
|
1523
|
+
"",
|
|
1524
|
+
"Every program your code transitively invokes, with the authority that controls it, the build-verification status, and the audits we found.",
|
|
1525
|
+
""
|
|
1526
|
+
].join("\n");
|
|
1527
|
+
const body = g.programs.map(renderProgram).join("\n\n---\n\n");
|
|
1528
|
+
const tail = g.unresolved.length ? [
|
|
1529
|
+
"",
|
|
1530
|
+
"---",
|
|
1531
|
+
"",
|
|
1532
|
+
"## Unresolved",
|
|
1533
|
+
"",
|
|
1534
|
+
...g.unresolved.map((u) => `- \`${u.programId}\` \u2014 ${u.reason}`)
|
|
1535
|
+
].join("\n") : "";
|
|
1536
|
+
return head + body + tail + "\n";
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// src/costAnalysis.ts
|
|
1540
|
+
import { Project as Project2, SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
1541
|
+
var LAMPORTS_PER_BYTE_YEAR = 3480;
|
|
1542
|
+
var EXEMPTION_THRESHOLD = 2;
|
|
1543
|
+
var OVERHEAD_BYTES = 128;
|
|
1544
|
+
var LAMPORTS_PER_SOL = 1e9;
|
|
1545
|
+
function rentExemptMinimum(dataLen) {
|
|
1546
|
+
return (dataLen + OVERHEAD_BYTES) * LAMPORTS_PER_BYTE_YEAR * EXEMPTION_THRESHOLD;
|
|
1547
|
+
}
|
|
1548
|
+
function lamportsToSol(lamports) {
|
|
1549
|
+
return (lamports / LAMPORTS_PER_SOL).toFixed(9).replace(/\.?0+$/, "");
|
|
1550
|
+
}
|
|
1551
|
+
var KNOWN_FLOWS = [
|
|
1552
|
+
{
|
|
1553
|
+
call: "createMint",
|
|
1554
|
+
module: "@solana/spl-token",
|
|
1555
|
+
accountType: "SPL Token Mint",
|
|
1556
|
+
dataLen: 82,
|
|
1557
|
+
recoverability: "conditionally-recoverable",
|
|
1558
|
+
recoverabilityNote: "Recoverable via `closeAccount` on the mint \u2014 requires mint supply = 0 and mint authority disabled. Most production mints never meet these conditions."
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
call: "createAssociatedTokenAccount",
|
|
1562
|
+
module: "@solana/spl-token",
|
|
1563
|
+
accountType: "Associated Token Account (ATA)",
|
|
1564
|
+
dataLen: 165,
|
|
1565
|
+
recoverability: "recoverable",
|
|
1566
|
+
recoverabilityNote: "Recovered by calling `closeAccount`; lamports return to the destination wallet. Requires zero token balance."
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
call: "createAssociatedTokenAccountIdempotent",
|
|
1570
|
+
module: "@solana/spl-token",
|
|
1571
|
+
accountType: "Associated Token Account (ATA, idempotent)",
|
|
1572
|
+
dataLen: 165,
|
|
1573
|
+
recoverability: "recoverable",
|
|
1574
|
+
recoverabilityNote: "Recovered by calling `closeAccount`; lamports return to the destination wallet. Requires zero token balance."
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
call: "createAccount",
|
|
1578
|
+
module: "@solana/spl-token",
|
|
1579
|
+
accountType: "SPL Token Account (explicit)",
|
|
1580
|
+
dataLen: 165,
|
|
1581
|
+
recoverability: "recoverable",
|
|
1582
|
+
recoverabilityNote: "Recovered by calling `closeAccount`; lamports return to the destination wallet. Requires zero token balance."
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
call: "createV1",
|
|
1586
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
1587
|
+
accountType: "Metaplex Token Metadata",
|
|
1588
|
+
// Base metadata: 1(key) + 32(update_auth) + 32(mint) + 4+name + 4+symbol + 4+uri
|
|
1589
|
+
// + 2(seller_fee) + 1(creators opt) + 1(primary_sale) + 1(is_mutable) ≈ 679 bytes typical
|
|
1590
|
+
dataLen: 679,
|
|
1591
|
+
recoverability: "non-recoverable",
|
|
1592
|
+
recoverabilityNote: "Metaplex metadata accounts cannot be closed. The lamport lockup is permanent for the lifetime of the token."
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
call: "createNft",
|
|
1596
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
1597
|
+
accountType: "Metaplex NFT Metadata",
|
|
1598
|
+
dataLen: 679,
|
|
1599
|
+
recoverability: "non-recoverable",
|
|
1600
|
+
recoverabilityNote: "Metaplex metadata accounts cannot be closed. The lamport lockup is permanent."
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
call: "createAndMint",
|
|
1604
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
1605
|
+
accountType: "Metaplex Token Metadata + Mint",
|
|
1606
|
+
dataLen: 679 + 82,
|
|
1607
|
+
// metadata + mint
|
|
1608
|
+
recoverability: "non-recoverable",
|
|
1609
|
+
recoverabilityNote: "Metadata accounts cannot be closed. Mint rent is conditionally recoverable (requires 0 supply + disabled authority)."
|
|
1610
|
+
},
|
|
1611
|
+
{
|
|
1612
|
+
call: "createFungible",
|
|
1613
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
1614
|
+
accountType: "Metaplex Fungible Token Metadata",
|
|
1615
|
+
dataLen: 679,
|
|
1616
|
+
recoverability: "non-recoverable",
|
|
1617
|
+
recoverabilityNote: "Metaplex metadata accounts cannot be closed. The lamport lockup is permanent."
|
|
1618
|
+
}
|
|
1619
|
+
];
|
|
1620
|
+
var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
|
|
1621
|
+
SyntaxKind9.ForStatement,
|
|
1622
|
+
SyntaxKind9.ForOfStatement,
|
|
1623
|
+
SyntaxKind9.ForInStatement,
|
|
1624
|
+
SyntaxKind9.WhileStatement,
|
|
1625
|
+
SyntaxKind9.DoStatement
|
|
1626
|
+
]);
|
|
1627
|
+
var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
|
|
1628
|
+
function isInsideLoop(node) {
|
|
1629
|
+
let cur = node;
|
|
1630
|
+
while (cur) {
|
|
1631
|
+
const k = cur.getKind?.();
|
|
1632
|
+
if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
|
|
1633
|
+
return { scalable: true, note: `call is inside a ${SyntaxKind9[k]} \u2014 cost scales with loop iterations` };
|
|
1634
|
+
}
|
|
1635
|
+
if (k === SyntaxKind9.CallExpression) {
|
|
1636
|
+
const expr = cur.getExpression?.();
|
|
1637
|
+
if (expr?.getKind?.() === SyntaxKind9.PropertyAccessExpression) {
|
|
1638
|
+
const name = expr.asKind?.(SyntaxKind9.PropertyAccessExpression)?.getName?.();
|
|
1639
|
+
if (name && ARRAY_METHOD_LOOPS.has(name)) {
|
|
1640
|
+
return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
cur = cur.getParent?.();
|
|
1645
|
+
}
|
|
1646
|
+
return { scalable: false };
|
|
1647
|
+
}
|
|
1648
|
+
function detectPriorityFee(targetDir) {
|
|
1649
|
+
const project = new Project2({ skipAddingFilesFromTsConfig: true });
|
|
1650
|
+
for (const file of walk(targetDir)) {
|
|
1651
|
+
const sf = project.addSourceFileAtPath(file);
|
|
1652
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind9.CallExpression);
|
|
1653
|
+
for (const ce of calls) {
|
|
1654
|
+
const expr = ce.getExpression();
|
|
1655
|
+
const text = expr.getText();
|
|
1656
|
+
if (text.includes("setComputeUnitPrice")) {
|
|
1657
|
+
return {
|
|
1658
|
+
found: true,
|
|
1659
|
+
file,
|
|
1660
|
+
line: ce.getStartLineNumber(),
|
|
1661
|
+
detail: `ComputeBudgetProgram.setComputeUnitPrice detected at ${file}:${ce.getStartLineNumber()} \u2014 priority fee configured.`
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
return {
|
|
1667
|
+
found: false,
|
|
1668
|
+
detail: "No setComputeUnitPrice call detected. During network congestion, transactions without a priority fee may stall or be dropped. Add ComputeBudgetProgram.setComputeUnitPrice() to critical transaction paths."
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
function detectAccountFlows(targetDir) {
|
|
1672
|
+
const project = new Project2({ skipAddingFilesFromTsConfig: true });
|
|
1673
|
+
const callIndex = new Map(KNOWN_FLOWS.map((f) => [f.call, f]));
|
|
1674
|
+
const flows = [];
|
|
1675
|
+
for (const file of walk(targetDir)) {
|
|
1676
|
+
const sf = project.addSourceFileAtPath(file);
|
|
1677
|
+
const importedModules = new Set(
|
|
1678
|
+
sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
|
|
1679
|
+
);
|
|
1680
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
|
|
1681
|
+
const expr = ce.getExpression();
|
|
1682
|
+
let callName5 = null;
|
|
1683
|
+
if (expr.getKind() === SyntaxKind9.Identifier) {
|
|
1684
|
+
callName5 = expr.getText();
|
|
1685
|
+
} else if (expr.getKind() === SyntaxKind9.PropertyAccessExpression) {
|
|
1686
|
+
callName5 = expr.asKind(SyntaxKind9.PropertyAccessExpression).getName();
|
|
1687
|
+
}
|
|
1688
|
+
if (!callName5) continue;
|
|
1689
|
+
const known = callIndex.get(callName5);
|
|
1690
|
+
if (!known) continue;
|
|
1691
|
+
if (!importedModules.has(known.module)) continue;
|
|
1692
|
+
const lamports = rentExemptMinimum(known.dataLen);
|
|
1693
|
+
const { scalable, note } = isInsideLoop(ce);
|
|
1694
|
+
flows.push({
|
|
1695
|
+
call: callName5,
|
|
1696
|
+
module: known.module,
|
|
1697
|
+
accountType: known.accountType,
|
|
1698
|
+
file,
|
|
1699
|
+
line: ce.getStartLineNumber(),
|
|
1700
|
+
dataLen: known.dataLen,
|
|
1701
|
+
lamports,
|
|
1702
|
+
sol: lamportsToSol(lamports),
|
|
1703
|
+
recoverability: known.recoverability,
|
|
1704
|
+
recoverabilityNote: known.recoverabilityNote,
|
|
1705
|
+
scalable,
|
|
1706
|
+
scalableNote: note
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return flows;
|
|
1711
|
+
}
|
|
1712
|
+
function analyzeCosts(targetDir) {
|
|
1713
|
+
const accountFlows = detectAccountFlows(targetDir);
|
|
1714
|
+
const priorityFee = detectPriorityFee(targetDir);
|
|
1715
|
+
const staticFlows = accountFlows.filter((f) => !f.scalable);
|
|
1716
|
+
const scalableFlows = accountFlows.filter((f) => f.scalable);
|
|
1717
|
+
const totalLockupLamports = staticFlows.reduce((s, f) => s + f.lamports, 0);
|
|
1718
|
+
return {
|
|
1719
|
+
accountFlows,
|
|
1720
|
+
priorityFee,
|
|
1721
|
+
totalLockupLamports,
|
|
1722
|
+
totalLockupSol: lamportsToSol(totalLockupLamports),
|
|
1723
|
+
scalableFlows,
|
|
1724
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
function renderCostReportMd(r) {
|
|
1728
|
+
const lines = ["## Cost & Rent Analysis\n"];
|
|
1729
|
+
if (r.priorityFee.found) {
|
|
1730
|
+
lines.push(`\u2705 **Priority fee configured** \u2014 \`setComputeUnitPrice\` detected.`);
|
|
1731
|
+
lines.push(` ${r.priorityFee.detail}
|
|
1732
|
+
`);
|
|
1733
|
+
} else {
|
|
1734
|
+
lines.push(`\u26A0\uFE0F **HIGH \u2014 Priority fee not configured**`);
|
|
1735
|
+
lines.push(` ${r.priorityFee.detail}
|
|
1736
|
+
`);
|
|
1737
|
+
}
|
|
1738
|
+
if (r.accountFlows.length === 0) {
|
|
1739
|
+
lines.push("_No account-creation calls from tracked modules detected._\n");
|
|
1740
|
+
return lines.join("\n");
|
|
1741
|
+
}
|
|
1742
|
+
lines.push("### Account Creation Flows\n");
|
|
1743
|
+
lines.push("| Call | Account Type | Data | Lamports Locked | SOL | Recoverable? |");
|
|
1744
|
+
lines.push("|------|-------------|------|-----------------|-----|--------------|");
|
|
1745
|
+
for (const f of r.accountFlows) {
|
|
1746
|
+
const file = f.file.split("/").slice(-2).join("/");
|
|
1747
|
+
const recov = f.recoverability === "recoverable" ? "\u2705 Yes" : f.recoverability === "conditionally-recoverable" ? "\u26A0\uFE0F Conditional" : "\u274C No";
|
|
1748
|
+
const scaleMark = f.scalable ? " \u{1F504}" : "";
|
|
1749
|
+
lines.push(
|
|
1750
|
+
`| \`${f.call}\`${scaleMark} (${file}:${f.line}) | ${f.accountType} | ${f.dataLen} B | ${f.lamports.toLocaleString()} | ${f.sol} SOL | ${recov} |`
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
lines.push("");
|
|
1754
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1755
|
+
for (const f of r.accountFlows) unique.set(f.accountType, f.recoverabilityNote);
|
|
1756
|
+
lines.push("**Recoverability notes:**");
|
|
1757
|
+
for (const [type, note] of unique) lines.push(`- **${type}:** ${note}`);
|
|
1758
|
+
lines.push("");
|
|
1759
|
+
if (r.totalLockupLamports > 0) {
|
|
1760
|
+
lines.push(
|
|
1761
|
+
`**Total static lockup: ${r.totalLockupLamports.toLocaleString()} lamports (~${r.totalLockupSol} SOL)**`
|
|
1762
|
+
);
|
|
1763
|
+
lines.push(
|
|
1764
|
+
`_(Excludes ${r.scalableFlows.length} scalable flow(s) whose cost grows with N \u2014 see below.)_
|
|
1765
|
+
`
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
if (r.scalableFlows.length > 0) {
|
|
1769
|
+
lines.push("### Scalable Cost Flows (cost grows with N)\n");
|
|
1770
|
+
for (const f of r.scalableFlows) {
|
|
1771
|
+
const file = f.file.split("/").slice(-2).join("/");
|
|
1772
|
+
lines.push(
|
|
1773
|
+
`- **\`${f.call}\`** at \`${file}:${f.line}\` \u2014 ${f.scalableNote}
|
|
1774
|
+
Per-iteration cost: ${f.lamports.toLocaleString()} lamports (${f.sol} SOL) for each ${f.accountType}.`
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
lines.push("");
|
|
1778
|
+
}
|
|
1779
|
+
return lines.join("\n");
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
export {
|
|
1783
|
+
findCandidates,
|
|
1784
|
+
runChecker,
|
|
1785
|
+
checkerKinds,
|
|
1786
|
+
auditWithRule,
|
|
1787
|
+
audit,
|
|
1788
|
+
renderTest,
|
|
1789
|
+
testKinds,
|
|
1790
|
+
loadRules,
|
|
1791
|
+
rules,
|
|
1792
|
+
resolveRules,
|
|
1793
|
+
loadDirectory,
|
|
1794
|
+
base58Encode,
|
|
1795
|
+
base58Decode,
|
|
1796
|
+
isValidSolanaAddress,
|
|
1797
|
+
DEFAULT_TTL_HOURS,
|
|
1798
|
+
defaultCachePath,
|
|
1799
|
+
loadProgramCache,
|
|
1800
|
+
saveProgramCache,
|
|
1801
|
+
getCacheEntry,
|
|
1802
|
+
putCacheEntry,
|
|
1803
|
+
getCacheEntryMeta,
|
|
1804
|
+
isEntryExpired,
|
|
1805
|
+
cacheSize,
|
|
1806
|
+
buildTrustGraph,
|
|
1807
|
+
renderTrustGraphMd,
|
|
1808
|
+
rentExemptMinimum,
|
|
1809
|
+
lamportsToSol,
|
|
1810
|
+
analyzeCosts,
|
|
1811
|
+
renderCostReportMd
|
|
1812
|
+
};
|