brainblast 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSB-117
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # brainblast
2
+
3
+ Deterministic auditor for catastrophic AI-integration bugs. Point it at a repo;
4
+ it finds the silent money/auth traps an AI agent ships, and generates the
5
+ behavioral test that proves they're fixed. No LLM, no API key, no network — it
6
+ parses your code statically and runs offline.
7
+
8
+ ## Use
9
+
10
+ ```sh
11
+ npx brainblast . # scan the repo, write .agent-research/report.json
12
+ npx brainblast . --ci # exit 1 if a confirmed CRITICAL remains
13
+ npx brainblast . --ci --strict # also fail on CANT_TELL (can't statically prove)
14
+ ```
15
+
16
+ Exit codes: **0** clean · **1** a confirmed FAIL at/above the threshold · CANT_TELL
17
+ is a warning by default (a red build always means a real, confirmed problem).
18
+
19
+ ## What it catches (today, Node/TypeScript)
20
+
21
+ - **Stripe webhooks** that don't verify the signature on the **raw** body →
22
+ forged `payment_intent.succeeded` events accepted.
23
+ - **Privy / JWT** access tokens decoded without verifying the signature, or
24
+ without asserting `aud` + `iss` → auth bypass / cross-app token reuse.
25
+ - **Bags (Solana token launch)** fee-share configs that omit the creator from
26
+ `feeClaimers`, or whose `userBps` don't sum to 10000 → the creator earns **zero
27
+ fees forever** (the config is immutable on-chain after launch).
28
+
29
+ Each finding lands in `report.json` (stable, versioned `schemaVersion: "1.0"`)
30
+ with a `checks[]` array a CI gate can read.
31
+
32
+ ## Rules are data
33
+
34
+ Detection lives in `*.yaml` rules (facts) that bind to a small set of vetted,
35
+ human-maintained checker + test templates by `kind` — never executable code in a
36
+ rule. Drop project-specific rules in `.agent-research/rules/*.yaml` and the
37
+ auditor loads them on top of the bundled pack (they can add traps, not shadow
38
+ bundled ones). Invalid rules are rejected at load.
39
+
40
+ ## Library API
41
+
42
+ ```ts
43
+ import { audit, resolveRules } from "brainblast";
44
+ const { checks, report } = audit(process.cwd(), resolveRules(process.cwd()));
45
+ ```
46
+
47
+ ## Security model
48
+
49
+ - **The audit is static.** `brainblast <dir>` parses source with ts-morph and
50
+ never executes it, so auditing untrusted code does not run it. YAML rules are
51
+ data only (no code execution, no prototype pollution).
52
+ - **Generated behavioral tests execute the audited repo's code when you run
53
+ them.** That's expected when you audit your own repo. If you run brainblast on
54
+ untrusted code (e.g. a fork PR) and then run the generated tests, run them in a
55
+ sandbox — the same caution as running any untrusted test suite.
56
+
57
+ ## Develop
58
+
59
+ ```sh
60
+ npm install
61
+ npm test # unit suite
62
+ npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
63
+ npm run build # produce dist/ (the published artifact)
64
+ ```
65
+
66
+ MIT.
@@ -0,0 +1,494 @@
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
+ if (!(importsModule || name && nameRe.test(name) || bodyCallsAnyOf(fn, triggers))) return;
42
+ out.push({
43
+ filePath: file,
44
+ fnName: name || "(anonymous)",
45
+ params: fn.getParameters().map((p) => p.getName()),
46
+ fn
47
+ });
48
+ };
49
+ for (const fn of sf.getFunctions()) consider(fn, fn.getName() ?? "");
50
+ for (const v of sf.getVariableDeclarations()) {
51
+ const arrow = v.getInitializerIfKind(SyntaxKind.ArrowFunction);
52
+ if (arrow) consider(arrow, v.getName());
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // src/checkers/positionalArgIdentity.ts
59
+ import { SyntaxKind as SyntaxKind2 } from "ts-morph";
60
+ var positionalArgIdentity = (c, p) => {
61
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind2.CallExpression).filter((call) => {
62
+ const exp = call.getExpression();
63
+ return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
64
+ });
65
+ if (calls.length === 0) return { result: "fail", detail: p.absentDetail };
66
+ const arg = calls[0].getArguments()[p.argIndex];
67
+ const wantParam = c.params[p.paramIndex];
68
+ if (arg && wantParam && arg.getKind() === SyntaxKind2.Identifier && arg.getText() === wantParam) {
69
+ return { result: "pass", detail: String(p.passDetail).replace("{param}", wantParam) };
70
+ }
71
+ if (arg && arg.getKind() === SyntaxKind2.CallExpression) {
72
+ return { result: "fail", detail: p.parsedDetail };
73
+ }
74
+ return {
75
+ result: "cant_tell",
76
+ detail: `Could not confirm argument ${p.argIndex} of ${p.call} is the raw input.`
77
+ };
78
+ };
79
+
80
+ // src/checkers/requiredCallWithOptions.ts
81
+ import { SyntaxKind as SyntaxKind3 } from "ts-morph";
82
+ function callName(call) {
83
+ const exp = call.getExpression();
84
+ if (exp.getKind() === SyntaxKind3.Identifier) return exp.getText();
85
+ if (exp.getKind() === SyntaxKind3.PropertyAccessExpression) {
86
+ return exp.asKind(SyntaxKind3.PropertyAccessExpression).getName();
87
+ }
88
+ return "";
89
+ }
90
+ function hasAllProps(call, groups) {
91
+ for (const arg of call.getArguments()) {
92
+ const obj = arg.asKind(SyntaxKind3.ObjectLiteralExpression);
93
+ if (!obj) continue;
94
+ const names = obj.getProperties().map((pr) => {
95
+ const pa = pr.asKind(SyntaxKind3.PropertyAssignment) ?? pr.asKind(SyntaxKind3.ShorthandPropertyAssignment);
96
+ return pa?.getName() ?? "";
97
+ });
98
+ if (groups.every((g) => g.some((n) => names.includes(n)))) return true;
99
+ }
100
+ return false;
101
+ }
102
+ var requiredCallWithOptions = (c, p) => {
103
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind3.CallExpression);
104
+ const verify = calls.filter((x) => p.verifyCalls.includes(callName(x)));
105
+ const decode = calls.filter((x) => p.decodeCalls.includes(callName(x)));
106
+ if (verify.length > 0) {
107
+ if (verify.some((v) => hasAllProps(v, p.requiredProps))) {
108
+ return { result: "pass", detail: p.passDetail };
109
+ }
110
+ return { result: "fail", detail: p.missingPropsDetail };
111
+ }
112
+ if (decode.length > 0) return { result: "fail", detail: p.decodeOnlyDetail };
113
+ return { result: "cant_tell", detail: "No verification or decode call found." };
114
+ };
115
+
116
+ // src/checkers/feeAllocationShape.ts
117
+ import { SyntaxKind as SyntaxKind4 } from "ts-morph";
118
+ function callName2(call) {
119
+ const exp = call.getExpression();
120
+ if (exp.getKind() === SyntaxKind4.Identifier) return exp.getText();
121
+ if (exp.getKind() === SyntaxKind4.PropertyAccessExpression) {
122
+ return exp.asKind(SyntaxKind4.PropertyAccessExpression).getName();
123
+ }
124
+ return "";
125
+ }
126
+ function asArrayLiteral(expr, c) {
127
+ if (!expr) return void 0;
128
+ const direct = expr.asKind(SyntaxKind4.ArrayLiteralExpression);
129
+ if (direct) return direct;
130
+ if (expr.getKind() === SyntaxKind4.Identifier) {
131
+ const name = expr.getText();
132
+ for (const decl of c.fn.getDescendantsOfKind(SyntaxKind4.VariableDeclaration)) {
133
+ if (decl.getName() === name) {
134
+ return decl.getInitializerIfKind(SyntaxKind4.ArrayLiteralExpression);
135
+ }
136
+ }
137
+ }
138
+ return void 0;
139
+ }
140
+ function feeArray(call, prop, c) {
141
+ for (const arg of call.getArguments()) {
142
+ const obj = arg.asKind(SyntaxKind4.ObjectLiteralExpression);
143
+ if (!obj) continue;
144
+ const member = obj.getProperty(prop);
145
+ if (!member) continue;
146
+ const pa = member.asKind(SyntaxKind4.PropertyAssignment);
147
+ if (pa) return asArrayLiteral(pa.getInitializer(), c) ?? null;
148
+ const shorthand = member.asKind(SyntaxKind4.ShorthandPropertyAssignment);
149
+ if (shorthand) return asArrayLiteral(shorthand.getNameNode(), c) ?? null;
150
+ return null;
151
+ }
152
+ return void 0;
153
+ }
154
+ function propInit(entry, name) {
155
+ return entry.getProperty(name)?.asKind(SyntaxKind4.PropertyAssignment)?.getInitializer();
156
+ }
157
+ var feeAllocationShape = (c, p) => {
158
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind4.CallExpression).filter((x) => callName2(x) === p.configCall);
159
+ if (calls.length === 0) return { result: "fail", detail: p.absentDetail };
160
+ const arr = feeArray(calls[0], p.arrayProp, c);
161
+ if (arr === void 0 || arr === null) {
162
+ return { result: "cant_tell", detail: p.dynamicDetail };
163
+ }
164
+ const entries = arr.getElements().map((e) => e.asKind(SyntaxKind4.ObjectLiteralExpression));
165
+ if (entries.length === 0 || entries.some((e) => !e)) {
166
+ return { result: "cant_tell", detail: p.dynamicDetail };
167
+ }
168
+ const creatorParam = c.params.find((name) => new RegExp(p.creatorParamRegex, "i").test(name));
169
+ const creatorIncluded = creatorParam ? entries.some((e) => propInit(e, p.walletProp)?.getText() === creatorParam) : false;
170
+ if (!creatorIncluded) return { result: "fail", detail: p.creatorMissingDetail };
171
+ let sum = 0;
172
+ let allNumeric = true;
173
+ for (const e of entries) {
174
+ const lit = propInit(e, p.bpsProp)?.asKind(SyntaxKind4.NumericLiteral);
175
+ if (!lit) {
176
+ allNumeric = false;
177
+ break;
178
+ }
179
+ sum += Number(lit.getLiteralValue());
180
+ }
181
+ if (!allNumeric) return { result: "cant_tell", detail: p.dynamicDetail };
182
+ if (sum !== p.bpsTotal) {
183
+ return { result: "fail", detail: String(p.bpsSumDetail).replace("{sum}", String(sum)) };
184
+ }
185
+ return { result: "pass", detail: String(p.passDetail).replace("{param}", creatorParam) };
186
+ };
187
+
188
+ // src/checkers/index.ts
189
+ var registry = {
190
+ "positional-arg-identity": positionalArgIdentity,
191
+ "required-call-with-options": requiredCallWithOptions,
192
+ "fee-allocation-shape": feeAllocationShape
193
+ };
194
+ function runChecker(kind, c, params) {
195
+ const fn = registry[kind];
196
+ if (!fn) return { result: "cant_tell", detail: `Unknown checker kind '${kind}'.` };
197
+ return fn(c, params);
198
+ }
199
+ var checkerKinds = Object.keys(registry);
200
+
201
+ // src/emit.ts
202
+ function buildReport(target, checks, rules2) {
203
+ const byId = new Map(rules2.map((r) => [r.id, r]));
204
+ const checkTotals = { pass: 0, fail: 0, cant_tell: 0 };
205
+ for (const c of checks) checkTotals[c.result]++;
206
+ const riskTotals = { critical: 0, high: 0, medium: 0, low: 0 };
207
+ for (const c of checks) if (c.result === "fail") riskTotals[c.severity]++;
208
+ const ruleIdsSeen = [...new Set(checks.map((c) => c.ruleId))];
209
+ const components = ruleIdsSeen.map((id) => {
210
+ const rule = byId.get(id);
211
+ const fails = checks.filter((c) => c.ruleId === id && c.result === "fail");
212
+ return {
213
+ name: rule?.component.name ?? id,
214
+ type: rule?.component.type ?? "Other",
215
+ version: rule?.component.version ?? "unversioned",
216
+ sourceUrl: rule?.component.sourceUrl ?? null,
217
+ status: "fresh",
218
+ risks: fails.map((c) => ({ severity: c.severity, title: c.title, detail: c.detail }))
219
+ };
220
+ });
221
+ const now = /* @__PURE__ */ new Date();
222
+ const totalFails = checkTotals.fail;
223
+ return {
224
+ schemaVersion: "1.0",
225
+ run: {
226
+ id: now.toISOString().replace(/[-:T]/g, "").slice(0, 14),
227
+ date: now.toISOString().slice(0, 10),
228
+ requirements: `Catastrophic-integration audit of ${target}`,
229
+ generator: "@brainblast/core"
230
+ },
231
+ summary: {
232
+ building: "external integrations",
233
+ verdict: totalFails > 0 ? "blocked" : "ready",
234
+ topRisk: totalFails > 0 ? checks.find((c) => c.result === "fail")?.detail ?? null : null,
235
+ mustDecideFirst: null,
236
+ watchOutFor: null
237
+ },
238
+ components,
239
+ riskTotals,
240
+ checks: checks.map((c) => ({
241
+ ruleId: c.ruleId,
242
+ severity: c.severity,
243
+ result: c.result,
244
+ file: c.file,
245
+ line: c.line,
246
+ title: c.title,
247
+ detail: c.detail
248
+ })),
249
+ checkTotals,
250
+ openQuestions: []
251
+ };
252
+ }
253
+
254
+ // src/audit.ts
255
+ function auditWithRule(targetDir, rule) {
256
+ return findCandidates(targetDir, rule).map((c) => {
257
+ const outcome = runChecker(rule.check.kind, c, rule.check.params);
258
+ return {
259
+ ruleId: rule.id,
260
+ severity: rule.severity,
261
+ title: rule.title,
262
+ file: c.filePath,
263
+ line: c.fn.getStartLineNumber(),
264
+ exportName: c.fnName,
265
+ ...outcome
266
+ };
267
+ });
268
+ }
269
+ function audit(targetDir, rules2) {
270
+ const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
271
+ const report = buildReport(targetDir, checks, rules2);
272
+ return { checks, report };
273
+ }
274
+
275
+ // src/testTemplates/stripeWebhookSignature.ts
276
+ var stripeWebhookSignature = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the webhook
277
+ // verifies the signature on the RAW body.
278
+ import { describe, it, expect, beforeAll } from "vitest";
279
+ import Stripe from "stripe";
280
+ import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
281
+
282
+ const SECRET = "whsec_brainblast_test_secret";
283
+ const stripe = new Stripe("sk_test_brainblast");
284
+ const payload = JSON.stringify({ id: "evt_1", type: "payment_intent.succeeded", data: { object: { id: "pi_1" } } });
285
+
286
+ beforeAll(() => {
287
+ process.env.STRIPE_WEBHOOK_SECRET = SECRET;
288
+ process.env.STRIPE_SECRET_KEY = "sk_test_brainblast";
289
+ });
290
+
291
+ describe("Stripe webhook signature verification (brainblast contract)", () => {
292
+ it("accepts a validly-signed event", () => {
293
+ const sig = stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET });
294
+ expect(() => ${handlerExport}(payload, sig)).not.toThrow();
295
+ });
296
+ it("REJECTS an invalid signature (forged event)", () => {
297
+ expect(() => ${handlerExport}(payload, "t=1,v1=deadbeef")).toThrow();
298
+ });
299
+ it("REJECTS a mutated body under a valid-looking signature", () => {
300
+ const sig = stripe.webhooks.generateTestHeaderString({ payload, secret: SECRET });
301
+ const mutated = payload.replace("succeeded", "failed");
302
+ expect(() => ${handlerExport}(mutated, sig)).toThrow();
303
+ });
304
+ });
305
+ `;
306
+
307
+ // src/testTemplates/privyJwtClaims.ts
308
+ var privyJwtClaims = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the token is
309
+ // verified (signature + aud + iss).
310
+ import { describe, it, expect, beforeAll } from "vitest";
311
+ import { SignJWT, generateKeyPair, exportSPKI } from "jose";
312
+ import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
313
+
314
+ const APP_ID = "app_brainblast";
315
+ let valid = "", badSignature = "", wrongAudience = "", wrongIssuer = "";
316
+
317
+ beforeAll(async () => {
318
+ const { publicKey, privateKey } = await generateKeyPair("ES256");
319
+ const attacker = await generateKeyPair("ES256");
320
+ process.env.PRIVY_APP_ID = APP_ID;
321
+ process.env.PRIVY_VERIFICATION_KEY = await exportSPKI(publicKey);
322
+
323
+ const mint = (signer: any, iss: string, aud: string) =>
324
+ new SignJWT({})
325
+ .setProtectedHeader({ alg: "ES256" })
326
+ .setIssuer(iss)
327
+ .setAudience(aud)
328
+ .setSubject("did:privy:user_1")
329
+ .setExpirationTime("1h")
330
+ .sign(signer);
331
+
332
+ valid = await mint(privateKey, "privy.io", APP_ID);
333
+ badSignature = await mint(attacker.privateKey, "privy.io", APP_ID);
334
+ wrongAudience = await mint(privateKey, "privy.io", "app_evil");
335
+ wrongIssuer = await mint(privateKey, "evil.com", APP_ID);
336
+ });
337
+
338
+ const call = (t: string) => Promise.resolve().then(() => ${handlerExport}(t));
339
+
340
+ describe("Privy access-token verification (brainblast contract)", () => {
341
+ it("accepts a valid token", async () => { await expect(call(valid)).resolves.toBeDefined(); });
342
+ it("REJECTS a bad signature (forged token)", async () => { await expect(call(badSignature)).rejects.toThrow(); });
343
+ it("REJECTS a wrong audience (token from another app)", async () => { await expect(call(wrongAudience)).rejects.toThrow(); });
344
+ it("REJECTS a wrong issuer", async () => { await expect(call(wrongIssuer)).rejects.toThrow(); });
345
+ });
346
+ `;
347
+
348
+ // src/testTemplates/bagsFeeShare.ts
349
+ var bagsFeeShare = ({ handlerImportPath, handlerExport }) => `// GENERATED by @brainblast/core. Durable guardrail \u2014 RED until the fee-share
350
+ // config includes the creator wallet with a non-zero userBps summing to 10000.
351
+ import { describe, it, expect, vi, beforeEach } from "vitest";
352
+
353
+ let captured: any;
354
+ vi.mock("@bagsfm/bags-sdk", () => ({
355
+ createBagsFeeShareConfig: (cfg: any) => {
356
+ captured = cfg;
357
+ return { meteoraConfigKey: "mock-config-key" };
358
+ },
359
+ BagsSDK: class {},
360
+ }));
361
+
362
+ import { ${handlerExport} } from ${JSON.stringify(handlerImportPath)};
363
+
364
+ const CREATOR = "CreatorWa11etPubKey1111111111111111111111111";
365
+
366
+ describe("Bags fee-share creator inclusion (brainblast contract)", () => {
367
+ beforeEach(() => {
368
+ captured = undefined;
369
+ });
370
+
371
+ it("passes the creator wallet into feeClaimers", () => {
372
+ ${handlerExport}(CREATOR);
373
+ const claimers = captured?.feeClaimers ?? [];
374
+ expect(claimers.some((c: any) => c.user === CREATOR)).toBe(true);
375
+ });
376
+
377
+ it("allocates the creator a non-zero userBps", () => {
378
+ ${handlerExport}(CREATOR);
379
+ const entry = (captured?.feeClaimers ?? []).find((c: any) => c.user === CREATOR);
380
+ expect(entry?.userBps ?? 0).toBeGreaterThan(0);
381
+ });
382
+
383
+ it("feeClaimers userBps sum to exactly 10000", () => {
384
+ ${handlerExport}(CREATOR);
385
+ const sum = (captured?.feeClaimers ?? []).reduce((s: number, c: any) => s + c.userBps, 0);
386
+ expect(sum).toBe(10000);
387
+ });
388
+ });
389
+ `;
390
+
391
+ // src/testTemplates/index.ts
392
+ var registry2 = {
393
+ "stripe-webhook-signature": stripeWebhookSignature,
394
+ "privy-jwt-claims": privyJwtClaims,
395
+ "bags-fee-share": bagsFeeShare
396
+ };
397
+ var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
398
+ function renderTest(kind, opts) {
399
+ const tpl = registry2[kind];
400
+ if (!tpl) throw new Error(`Unknown test template kind '${kind}'.`);
401
+ if (!JS_IDENTIFIER.test(opts.handlerExport)) {
402
+ throw new Error(`Unsafe handler export name '${opts.handlerExport}' (not a JS identifier).`);
403
+ }
404
+ return tpl(opts);
405
+ }
406
+ var testKinds = Object.keys(registry2);
407
+
408
+ // src/loadRules.ts
409
+ import { readdirSync as readdirSync2, readFileSync } from "fs";
410
+ import { join as join2 } from "path";
411
+ import { parse } from "yaml";
412
+ var SEVERITIES = ["critical", "high", "medium", "low"];
413
+ function validateRule(r, file) {
414
+ const errs = [];
415
+ if (!r || typeof r !== "object") {
416
+ throw new Error(`invalid rule in ${file}: not a mapping`);
417
+ }
418
+ if (!r.id || typeof r.id !== "string") errs.push("missing id");
419
+ if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
420
+ if (!r.title || typeof r.title !== "string") errs.push("missing title");
421
+ if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
422
+ if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
423
+ errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
424
+ } else {
425
+ try {
426
+ new RegExp(r.detect.nameRegex);
427
+ } catch {
428
+ errs.push(`detect.nameRegex is not a valid regex: ${r.detect.nameRegex}`);
429
+ }
430
+ }
431
+ if (!r.check || !checkerKinds.includes(r.check.kind)) {
432
+ errs.push(`check.kind must be one of ${checkerKinds.join("|")} (got '${r.check?.kind}')`);
433
+ }
434
+ if (!r.test || !testKinds.includes(r.test.kind)) {
435
+ errs.push(`test.kind must be one of ${testKinds.join("|")} (got '${r.test?.kind}')`);
436
+ }
437
+ if (errs.length) throw new Error(`invalid rule in ${file}: ${errs.join("; ")}`);
438
+ }
439
+ function loadRules(dir) {
440
+ const files = readdirSync2(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
441
+ const rules2 = [];
442
+ for (const f of files) {
443
+ const raw = parse(readFileSync(join2(dir, f), "utf8"));
444
+ validateRule(raw, f);
445
+ rules2.push(raw);
446
+ }
447
+ return rules2;
448
+ }
449
+
450
+ // rules/index.ts
451
+ import { existsSync } from "fs";
452
+ import { dirname, join as join3 } from "path";
453
+ import { fileURLToPath } from "url";
454
+ function bundledRulesDir() {
455
+ const here = dirname(fileURLToPath(import.meta.url));
456
+ if (existsSync(join3(here, "stripe-webhook-raw-body.yaml"))) return here;
457
+ const sub = join3(here, "rules");
458
+ if (existsSync(join3(sub, "stripe-webhook-raw-body.yaml"))) return sub;
459
+ return here;
460
+ }
461
+ var rules = loadRules(bundledRulesDir());
462
+
463
+ // src/resolveRules.ts
464
+ import { existsSync as existsSync2 } from "fs";
465
+ import { join as join4 } from "path";
466
+ function resolveRules(targetDir) {
467
+ const all = [...rules];
468
+ const projDir = join4(targetDir, ".agent-research", "rules");
469
+ if (existsSync2(projDir)) {
470
+ const seen = new Set(all.map((r) => r.id));
471
+ for (const r of loadRules(projDir)) {
472
+ if (seen.has(r.id)) {
473
+ console.warn(`brainblast: project rule '${r.id}' shadows a bundled rule; keeping bundled.`);
474
+ continue;
475
+ }
476
+ all.push(r);
477
+ seen.add(r.id);
478
+ }
479
+ }
480
+ return all;
481
+ }
482
+
483
+ export {
484
+ findCandidates,
485
+ runChecker,
486
+ checkerKinds,
487
+ auditWithRule,
488
+ audit,
489
+ renderTest,
490
+ testKinds,
491
+ loadRules,
492
+ rules,
493
+ resolveRules
494
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ audit,
4
+ resolveRules
5
+ } from "./chunk-H2Y75CSH.js";
6
+
7
+ // src/cli.ts
8
+ import { writeFileSync, mkdirSync } from "fs";
9
+ import { join } from "path";
10
+ var args = process.argv.slice(2);
11
+ var ci = args.includes("--ci");
12
+ var strict = args.includes("--strict");
13
+ var targetDir = args.find((a) => !a.startsWith("--")) ?? process.cwd();
14
+ var rules = resolveRules(targetDir);
15
+ var { checks, report } = audit(targetDir, rules);
16
+ var outDir = join(targetDir, ".agent-research");
17
+ mkdirSync(outDir, { recursive: true });
18
+ var reportPath = join(outDir, "report.json");
19
+ writeFileSync(reportPath, JSON.stringify(report, null, 2));
20
+ console.log(`brainblast: scanned ${targetDir} with ${rules.length} rule(s)`);
21
+ if (checks.length === 0) console.log(" (no catastrophic components detected)");
22
+ for (const c of checks) {
23
+ const tag = c.result === "pass" ? "PASS " : c.result === "fail" ? "FAIL " : "WARN ";
24
+ console.log(` [${tag}] ${c.ruleId} ${c.file}:${c.line}`);
25
+ console.log(` ${c.detail}`);
26
+ }
27
+ var fails = checks.filter((c) => c.result === "fail").length;
28
+ var cantTell = checks.filter((c) => c.result === "cant_tell").length;
29
+ console.log(` verdict: ${report.summary.verdict} (fail=${fails}, cant_tell=${cantTell})`);
30
+ if (cantTell > 0 && !strict) {
31
+ console.log(` warning: ${cantTell} cant_tell (not gating \u2014 pass --strict to fail on these)`);
32
+ }
33
+ console.log(` report: ${reportPath}`);
34
+ if (ci) {
35
+ const gateFail = fails > 0 || strict && cantTell > 0;
36
+ process.exit(gateFail ? 1 : 0);
37
+ }
@@ -0,0 +1,122 @@
1
+ import { FunctionDeclaration, ArrowFunction } from 'ts-morph';
2
+
3
+ type Severity = "critical" | "high" | "medium" | "low";
4
+ type CheckResultKind = "pass" | "fail" | "cant_tell";
5
+ interface Candidate {
6
+ filePath: string;
7
+ fnName: string;
8
+ params: string[];
9
+ fn: FunctionDeclaration | ArrowFunction;
10
+ }
11
+ interface CheckOutcome {
12
+ result: CheckResultKind;
13
+ detail: string;
14
+ }
15
+ interface CheckResult extends CheckOutcome {
16
+ ruleId: string;
17
+ severity: Severity;
18
+ title: string;
19
+ file: string;
20
+ line: number;
21
+ exportName: string;
22
+ }
23
+ interface Rule {
24
+ id: string;
25
+ severity: Severity;
26
+ title: string;
27
+ component: {
28
+ name: string;
29
+ type: string;
30
+ version?: string;
31
+ sourceUrl?: string;
32
+ };
33
+ detect: {
34
+ modules: string[];
35
+ nameRegex: string;
36
+ triggerCalls: string[];
37
+ };
38
+ check: {
39
+ kind: string;
40
+ params: Record<string, any>;
41
+ };
42
+ test: {
43
+ kind: string;
44
+ params?: Record<string, any>;
45
+ };
46
+ }
47
+
48
+ declare function auditWithRule(targetDir: string, rule: Rule): CheckResult[];
49
+ declare function audit(targetDir: string, rules: Rule[]): {
50
+ checks: CheckResult[];
51
+ report: {
52
+ schemaVersion: string;
53
+ run: {
54
+ id: string;
55
+ date: string;
56
+ requirements: string;
57
+ generator: string;
58
+ };
59
+ summary: {
60
+ building: string;
61
+ verdict: string;
62
+ topRisk: string | null;
63
+ mustDecideFirst: null;
64
+ watchOutFor: null;
65
+ };
66
+ components: {
67
+ name: string;
68
+ type: string;
69
+ version: string;
70
+ sourceUrl: string | null;
71
+ status: string;
72
+ risks: {
73
+ severity: Severity;
74
+ title: string;
75
+ detail: string;
76
+ }[];
77
+ }[];
78
+ riskTotals: {
79
+ critical: number;
80
+ high: number;
81
+ medium: number;
82
+ low: number;
83
+ };
84
+ checks: {
85
+ ruleId: string;
86
+ severity: Severity;
87
+ result: CheckResultKind;
88
+ file: string;
89
+ line: number;
90
+ title: string;
91
+ detail: string;
92
+ }[];
93
+ checkTotals: {
94
+ pass: number;
95
+ fail: number;
96
+ cant_tell: number;
97
+ };
98
+ openQuestions: never[];
99
+ };
100
+ };
101
+
102
+ declare function resolveRules(targetDir: string): Rule[];
103
+
104
+ declare function loadRules(dir: string): Rule[];
105
+
106
+ declare const rules: Rule[];
107
+
108
+ declare function generateTestForResult(result: CheckResult, rule: Rule, outPath: string): string;
109
+
110
+ declare function renderTest(kind: string, opts: {
111
+ handlerImportPath: string;
112
+ handlerExport: string;
113
+ params?: any;
114
+ }): string;
115
+ declare const testKinds: string[];
116
+
117
+ declare function runChecker(kind: string, c: Candidate, params: any): CheckOutcome;
118
+ declare const checkerKinds: string[];
119
+
120
+ declare function findCandidates(targetDir: string, rule: Rule): Candidate[];
121
+
122
+ export { type Candidate, type CheckOutcome, type CheckResult, type CheckResultKind, type Rule, type Severity, audit, auditWithRule, rules as bundledRules, checkerKinds, findCandidates, generateTestForResult, loadRules, renderTest, resolveRules, runChecker, testKinds };
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ import {
2
+ audit,
3
+ auditWithRule,
4
+ checkerKinds,
5
+ findCandidates,
6
+ loadRules,
7
+ renderTest,
8
+ resolveRules,
9
+ rules,
10
+ runChecker,
11
+ testKinds
12
+ } from "./chunk-H2Y75CSH.js";
13
+
14
+ // src/generate.ts
15
+ import { writeFileSync, mkdirSync } from "fs";
16
+ import { dirname } from "path";
17
+ function generateTestForResult(result, rule, outPath) {
18
+ const src = renderTest(rule.test.kind, {
19
+ handlerImportPath: result.file,
20
+ handlerExport: result.exportName,
21
+ params: rule.test.params
22
+ });
23
+ mkdirSync(dirname(outPath), { recursive: true });
24
+ writeFileSync(outPath, src);
25
+ return outPath;
26
+ }
27
+ export {
28
+ audit,
29
+ auditWithRule,
30
+ rules as bundledRules,
31
+ checkerKinds,
32
+ findCandidates,
33
+ generateTestForResult,
34
+ loadRules,
35
+ renderTest,
36
+ resolveRules,
37
+ runChecker,
38
+ testKinds
39
+ };
@@ -0,0 +1,40 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: bags-fee-share-creator-included
3
+ severity: critical
4
+ title: Bags fee-share config includes the creator wallet and BPS sum to 10000
5
+ component:
6
+ name: Bags Token Launch v2
7
+ type: API
8
+ version: unversioned
9
+ sourceUrl: https://docs.bags.fm/changelog/changelog
10
+ detect:
11
+ modules: ["@bagsfm/bags-sdk"]
12
+ nameRegex: "feeshare|feeclaimer|bagsfee|launchtoken|tokenlaunch"
13
+ triggerCalls: [createBagsFeeShareConfig]
14
+ check:
15
+ kind: fee-allocation-shape
16
+ params:
17
+ configCall: createBagsFeeShareConfig
18
+ arrayProp: feeClaimers
19
+ walletProp: user
20
+ bpsProp: userBps
21
+ bpsTotal: 10000
22
+ creatorParamRegex: "creator"
23
+ absentDetail: >-
24
+ No createBagsFeeShareConfig call in the builder. Token Launch v2 requires a
25
+ fee-share config for every launch; without one the launch cannot proceed.
26
+ dynamicDetail: >-
27
+ feeClaimers is assembled dynamically (variable, spread, or .map), so creator
28
+ inclusion and the 10000-BPS total can't be confirmed statically. Verify by
29
+ hand that the creator wallet is present with a non-zero userBps.
30
+ creatorMissingDetail: >-
31
+ The creator wallet is not in feeClaimers. The creator must be an explicit
32
+ entry — omitting them does NOT default them to any share; they earn zero
33
+ fees forever, and the fee config is immutable on-chain after launch.
34
+ bpsSumDetail: >-
35
+ feeClaimers userBps sum to {sum}, not 10000. Bags requires the allocation to
36
+ total exactly 10000 (100%); a mismatch is rejected at config creation.
37
+ passDetail: >-
38
+ Creator wallet '{param}' is an explicit feeClaimers entry and userBps sum to 10000.
39
+ test:
40
+ kind: bags-fee-share
@@ -0,0 +1,30 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: privy-jwt-verification
3
+ severity: critical
4
+ title: Privy access token verified (signature + aud + iss)
5
+ component:
6
+ name: Privy auth
7
+ type: Auth
8
+ version: unversioned
9
+ sourceUrl: https://docs.privy.io/authentication/user-authentication/access-tokens
10
+ detect:
11
+ modules: [jose, jsonwebtoken, "@privy-io/node", "@privy-io/server-auth"]
12
+ nameRegex: "token|auth|verify|privy|jwt"
13
+ triggerCalls: [decodeJwt, jwtVerify, verify, decode]
14
+ check:
15
+ kind: required-call-with-options
16
+ params:
17
+ verifyCalls: [jwtVerify, verify]
18
+ decodeCalls: [decodeJwt, decode]
19
+ requiredProps:
20
+ - [audience, aud]
21
+ - [issuer, iss]
22
+ passDetail: Token signature is verified and both aud and iss are asserted.
23
+ missingPropsDetail: >-
24
+ Token signature is verified but aud/iss are not asserted. A valid token
25
+ from another Privy app is accepted.
26
+ decodeOnlyDetail: >-
27
+ Token is decoded without verifying its signature. Any forged token is
28
+ accepted — auth bypass.
29
+ test:
30
+ kind: privy-jwt-claims
@@ -0,0 +1,28 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: stripe-webhook-raw-body-verification
3
+ severity: critical
4
+ title: Stripe webhook signature verified on the raw body
5
+ component:
6
+ name: Stripe webhook
7
+ type: API
8
+ version: unversioned
9
+ sourceUrl: https://docs.stripe.com/webhooks/signature
10
+ detect:
11
+ modules: [stripe]
12
+ nameRegex: webhook
13
+ triggerCalls: [constructEvent]
14
+ check:
15
+ kind: positional-arg-identity
16
+ params:
17
+ call: constructEvent
18
+ argIndex: 0
19
+ paramIndex: 0
20
+ absentDetail: >-
21
+ No stripe.webhooks.constructEvent call in the handler. Signatures are not
22
+ verified; forged 'payment_intent.succeeded' events are accepted.
23
+ parsedDetail: >-
24
+ constructEvent is called on a parsed/derived value, not the raw request
25
+ body. Verification is bypassed.
26
+ passDetail: constructEvent verifies the raw body parameter '{param}'.
27
+ test:
28
+ kind: stripe-webhook-signature
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "brainblast",
3
+ "version": "0.2.0",
4
+ "type": "module",
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
+ "keywords": ["security", "static-analysis", "stripe", "webhook", "jwt", "solana", "bags", "ci", "ai", "audit"],
7
+ "license": "MIT",
8
+ "author": "DSB-117",
9
+ "homepage": "https://github.com/DSB-117/brainblast/tree/main/packages/core#readme",
10
+ "bugs": { "url": "https://github.com/DSB-117/brainblast/issues" },
11
+ "repository": { "type": "git", "url": "git+https://github.com/DSB-117/brainblast.git", "directory": "packages/core" },
12
+ "bin": { "brainblast": "dist/cli.js" },
13
+ "exports": {
14
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
15
+ },
16
+ "files": ["dist", "README.md"],
17
+ "engines": { "node": ">=18" },
18
+ "publishConfig": { "access": "public", "provenance": true },
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
22
+ "audit": "tsx src/cli.ts",
23
+ "prove": "tsx scripts/prove.ts",
24
+ "test": "vitest run",
25
+ "coverage": "vitest run --coverage",
26
+ "typecheck": "tsc --noEmit -p tsconfig.json"
27
+ },
28
+ "dependencies": {
29
+ "ts-morph": "^23",
30
+ "yaml": "^2"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22",
34
+ "@vitest/coverage-v8": "^2",
35
+ "jose": "^5",
36
+ "stripe": "^17",
37
+ "tsup": "^8",
38
+ "tsx": "^4",
39
+ "typescript": "^5",
40
+ "vitest": "^2"
41
+ }
42
+ }