brainblast 0.3.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.
|
@@ -38,7 +38,13 @@ function findCandidates(targetDir, rule) {
|
|
|
38
38
|
const sf = project.addSourceFileAtPath(file);
|
|
39
39
|
const importsModule = sf.getImportDeclarations().some((d) => modules.has(d.getModuleSpecifierValue()));
|
|
40
40
|
const consider = (fn, name) => {
|
|
41
|
-
|
|
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
|
+
}
|
|
42
48
|
out.push({
|
|
43
49
|
filePath: file,
|
|
44
50
|
fnName: name || "(anonymous)",
|
|
@@ -62,7 +68,20 @@ var positionalArgIdentity = (c, p) => {
|
|
|
62
68
|
const exp = call.getExpression();
|
|
63
69
|
return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
|
|
64
70
|
});
|
|
65
|
-
if (calls.length === 0)
|
|
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
|
+
}
|
|
66
85
|
const arg = calls[0].getArguments()[p.argIndex];
|
|
67
86
|
const wantParam = c.params[p.paramIndex];
|
|
68
87
|
if (arg && wantParam && arg.getKind() === SyntaxKind2.Identifier && arg.getText() === wantParam) {
|
|
@@ -538,6 +557,136 @@ function findRustCandidates(targetDir, rule) {
|
|
|
538
557
|
return out;
|
|
539
558
|
}
|
|
540
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
|
+
|
|
541
690
|
// src/emit.ts
|
|
542
691
|
function buildReport(target, checks, rules2, costReport) {
|
|
543
692
|
const byId = new Map(rules2.map((r) => [r.id, r]));
|
|
@@ -584,7 +733,9 @@ function buildReport(target, checks, rules2, costReport) {
|
|
|
584
733
|
file: c.file,
|
|
585
734
|
line: c.line,
|
|
586
735
|
title: c.title,
|
|
587
|
-
detail: c.detail
|
|
736
|
+
detail: c.detail,
|
|
737
|
+
...c.fix ? { fix: c.fix } : {},
|
|
738
|
+
...c.precedent ? { precedent: c.precedent } : {}
|
|
588
739
|
})),
|
|
589
740
|
checkTotals,
|
|
590
741
|
openQuestions: [],
|
|
@@ -611,6 +762,7 @@ function auditWithRule(targetDir, rule) {
|
|
|
611
762
|
}
|
|
612
763
|
return findCandidates(targetDir, rule).map((c) => {
|
|
613
764
|
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
765
|
+
const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
|
|
614
766
|
return {
|
|
615
767
|
ruleId: rule.id,
|
|
616
768
|
severity: rule.severity,
|
|
@@ -618,7 +770,8 @@ function auditWithRule(targetDir, rule) {
|
|
|
618
770
|
file: c.filePath,
|
|
619
771
|
line: c.fn.getStartLineNumber(),
|
|
620
772
|
exportName: c.fnName,
|
|
621
|
-
...outcome
|
|
773
|
+
...outcome,
|
|
774
|
+
...fix ? { fix } : {}
|
|
622
775
|
};
|
|
623
776
|
});
|
|
624
777
|
}
|
|
@@ -903,7 +1056,7 @@ mod brainblast_reinit_guard_test {
|
|
|
903
1056
|
`;
|
|
904
1057
|
|
|
905
1058
|
// src/testTemplates/index.ts
|
|
906
|
-
var
|
|
1059
|
+
var registry3 = {
|
|
907
1060
|
"stripe-webhook-signature": stripeWebhookSignature,
|
|
908
1061
|
"privy-jwt-claims": privyJwtClaims,
|
|
909
1062
|
"bags-fee-share": bagsFeeShare,
|
|
@@ -913,14 +1066,14 @@ var registry2 = {
|
|
|
913
1066
|
};
|
|
914
1067
|
var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
915
1068
|
function renderTest(kind, opts) {
|
|
916
|
-
const tpl =
|
|
1069
|
+
const tpl = registry3[kind];
|
|
917
1070
|
if (!tpl) throw new Error(`Unknown test template kind '${kind}'.`);
|
|
918
1071
|
if (!JS_IDENTIFIER.test(opts.handlerExport)) {
|
|
919
1072
|
throw new Error(`Unsafe handler export name '${opts.handlerExport}' (not a JS identifier).`);
|
|
920
1073
|
}
|
|
921
1074
|
return tpl(opts);
|
|
922
1075
|
}
|
|
923
|
-
var testKinds = Object.keys(
|
|
1076
|
+
var testKinds = Object.keys(registry3);
|
|
924
1077
|
|
|
925
1078
|
// src/loadRules.ts
|
|
926
1079
|
import { readdirSync as readdirSync3, readFileSync as readFileSync2 } from "fs";
|
|
@@ -1384,7 +1537,7 @@ function renderTrustGraphMd(g) {
|
|
|
1384
1537
|
}
|
|
1385
1538
|
|
|
1386
1539
|
// src/costAnalysis.ts
|
|
1387
|
-
import { Project as Project2, SyntaxKind as
|
|
1540
|
+
import { Project as Project2, SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
1388
1541
|
var LAMPORTS_PER_BYTE_YEAR = 3480;
|
|
1389
1542
|
var EXEMPTION_THRESHOLD = 2;
|
|
1390
1543
|
var OVERHEAD_BYTES = 128;
|
|
@@ -1465,11 +1618,11 @@ var KNOWN_FLOWS = [
|
|
|
1465
1618
|
}
|
|
1466
1619
|
];
|
|
1467
1620
|
var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1621
|
+
SyntaxKind9.ForStatement,
|
|
1622
|
+
SyntaxKind9.ForOfStatement,
|
|
1623
|
+
SyntaxKind9.ForInStatement,
|
|
1624
|
+
SyntaxKind9.WhileStatement,
|
|
1625
|
+
SyntaxKind9.DoStatement
|
|
1473
1626
|
]);
|
|
1474
1627
|
var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
|
|
1475
1628
|
function isInsideLoop(node) {
|
|
@@ -1477,12 +1630,12 @@ function isInsideLoop(node) {
|
|
|
1477
1630
|
while (cur) {
|
|
1478
1631
|
const k = cur.getKind?.();
|
|
1479
1632
|
if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
|
|
1480
|
-
return { scalable: true, note: `call is inside a ${
|
|
1633
|
+
return { scalable: true, note: `call is inside a ${SyntaxKind9[k]} \u2014 cost scales with loop iterations` };
|
|
1481
1634
|
}
|
|
1482
|
-
if (k ===
|
|
1635
|
+
if (k === SyntaxKind9.CallExpression) {
|
|
1483
1636
|
const expr = cur.getExpression?.();
|
|
1484
|
-
if (expr?.getKind?.() ===
|
|
1485
|
-
const name = expr.asKind?.(
|
|
1637
|
+
if (expr?.getKind?.() === SyntaxKind9.PropertyAccessExpression) {
|
|
1638
|
+
const name = expr.asKind?.(SyntaxKind9.PropertyAccessExpression)?.getName?.();
|
|
1486
1639
|
if (name && ARRAY_METHOD_LOOPS.has(name)) {
|
|
1487
1640
|
return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
|
|
1488
1641
|
}
|
|
@@ -1496,7 +1649,7 @@ function detectPriorityFee(targetDir) {
|
|
|
1496
1649
|
const project = new Project2({ skipAddingFilesFromTsConfig: true });
|
|
1497
1650
|
for (const file of walk(targetDir)) {
|
|
1498
1651
|
const sf = project.addSourceFileAtPath(file);
|
|
1499
|
-
const calls = sf.getDescendantsOfKind(
|
|
1652
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind9.CallExpression);
|
|
1500
1653
|
for (const ce of calls) {
|
|
1501
1654
|
const expr = ce.getExpression();
|
|
1502
1655
|
const text = expr.getText();
|
|
@@ -1524,22 +1677,22 @@ function detectAccountFlows(targetDir) {
|
|
|
1524
1677
|
const importedModules = new Set(
|
|
1525
1678
|
sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
|
|
1526
1679
|
);
|
|
1527
|
-
for (const ce of sf.getDescendantsOfKind(
|
|
1680
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
|
|
1528
1681
|
const expr = ce.getExpression();
|
|
1529
|
-
let
|
|
1530
|
-
if (expr.getKind() ===
|
|
1531
|
-
|
|
1532
|
-
} else if (expr.getKind() ===
|
|
1533
|
-
|
|
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();
|
|
1534
1687
|
}
|
|
1535
|
-
if (!
|
|
1536
|
-
const known = callIndex.get(
|
|
1688
|
+
if (!callName5) continue;
|
|
1689
|
+
const known = callIndex.get(callName5);
|
|
1537
1690
|
if (!known) continue;
|
|
1538
1691
|
if (!importedModules.has(known.module)) continue;
|
|
1539
1692
|
const lamports = rentExemptMinimum(known.dataLen);
|
|
1540
1693
|
const { scalable, note } = isInsideLoop(ce);
|
|
1541
1694
|
flows.push({
|
|
1542
|
-
call:
|
|
1695
|
+
call: callName5,
|
|
1543
1696
|
module: known.module,
|
|
1544
1697
|
accountType: known.accountType,
|
|
1545
1698
|
file,
|
package/dist/cli.js
CHANGED
|
@@ -10,11 +10,84 @@ import {
|
|
|
10
10
|
renderCostReportMd,
|
|
11
11
|
renderTrustGraphMd,
|
|
12
12
|
resolveRules
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-WVHGN2HR.js";
|
|
14
14
|
|
|
15
15
|
// src/cli.ts
|
|
16
|
-
import { writeFileSync, mkdirSync } from "fs";
|
|
16
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
17
|
+
import { join as join2 } from "path";
|
|
18
|
+
|
|
19
|
+
// src/memory.ts
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
17
21
|
import { join } from "path";
|
|
22
|
+
var EMPTY_MEMORY = { schemaVersion: "1.0", lastRun: [], fixHistory: [] };
|
|
23
|
+
function memoryPath(targetDir2) {
|
|
24
|
+
return join(targetDir2, ".agent-research", "memory.json");
|
|
25
|
+
}
|
|
26
|
+
function loadMemory(targetDir2) {
|
|
27
|
+
const p = memoryPath(targetDir2);
|
|
28
|
+
if (!existsSync(p)) return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: parsed.schemaVersion ?? "1.0",
|
|
33
|
+
lastRun: Array.isArray(parsed.lastRun) ? parsed.lastRun : [],
|
|
34
|
+
fixHistory: Array.isArray(parsed.fixHistory) ? parsed.fixHistory : []
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function saveMemory(targetDir2, memory2) {
|
|
41
|
+
mkdirSync(join(targetDir2, ".agent-research"), { recursive: true });
|
|
42
|
+
writeFileSync(memoryPath(targetDir2), JSON.stringify(memory2, null, 2));
|
|
43
|
+
}
|
|
44
|
+
var snapshotKey = (e) => `${e.ruleId}::${e.file}::${e.exportName}`;
|
|
45
|
+
function precedentKey(c) {
|
|
46
|
+
return `${c.ruleId}::${c.file}`;
|
|
47
|
+
}
|
|
48
|
+
function updateMemory(memory2, checks2, now = /* @__PURE__ */ new Date()) {
|
|
49
|
+
const prevByKey = new Map(memory2.lastRun.map((e) => [snapshotKey(e), e]));
|
|
50
|
+
const fixedAt = now.toISOString().slice(0, 10);
|
|
51
|
+
const newFixEvents = [];
|
|
52
|
+
for (const c of checks2) {
|
|
53
|
+
const prev = prevByKey.get(snapshotKey(c));
|
|
54
|
+
if (prev?.result === "fail" && c.result !== "fail") {
|
|
55
|
+
newFixEvents.push({
|
|
56
|
+
ruleId: c.ruleId,
|
|
57
|
+
file: c.file,
|
|
58
|
+
exportName: c.exportName,
|
|
59
|
+
fixedAt,
|
|
60
|
+
detail: prev.detail
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const fixHistory = [...memory2.fixHistory, ...newFixEvents];
|
|
65
|
+
const precedents2 = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const c of checks2) {
|
|
67
|
+
if (c.result !== "fail") continue;
|
|
68
|
+
const pk = precedentKey(c);
|
|
69
|
+
if (precedents2.has(pk)) continue;
|
|
70
|
+
const matches = fixHistory.filter((e) => e.ruleId === c.ruleId && e.file !== c.file).sort((a, b) => a.fixedAt < b.fixedAt ? 1 : a.fixedAt > b.fixedAt ? -1 : 0);
|
|
71
|
+
if (matches[0]) {
|
|
72
|
+
precedents2.set(pk, {
|
|
73
|
+
file: matches[0].file,
|
|
74
|
+
exportName: matches[0].exportName,
|
|
75
|
+
fixedAt: matches[0].fixedAt,
|
|
76
|
+
detail: matches[0].detail
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const lastRun = checks2.map((c) => ({
|
|
81
|
+
ruleId: c.ruleId,
|
|
82
|
+
file: c.file,
|
|
83
|
+
exportName: c.exportName,
|
|
84
|
+
result: c.result,
|
|
85
|
+
detail: c.detail
|
|
86
|
+
}));
|
|
87
|
+
return { memory: { schemaVersion: "1.0", lastRun, fixHistory }, precedents: precedents2 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/cli.ts
|
|
18
91
|
var args = process.argv.slice(2);
|
|
19
92
|
if (args[0] === "trust-graph") {
|
|
20
93
|
await runTrustGraph(args.slice(1));
|
|
@@ -25,20 +98,45 @@ var strict = args.includes("--strict");
|
|
|
25
98
|
var targetDir = args.find((a) => !a.startsWith("--")) ?? process.cwd();
|
|
26
99
|
var rules = resolveRules(targetDir);
|
|
27
100
|
var { checks, report } = audit(targetDir, rules);
|
|
101
|
+
var memory = loadMemory(targetDir);
|
|
102
|
+
var { memory: nextMemory, precedents } = updateMemory(memory, checks);
|
|
103
|
+
for (const c of checks) {
|
|
104
|
+
const p = precedents.get(precedentKey(c));
|
|
105
|
+
if (p) c.precedent = p;
|
|
106
|
+
}
|
|
107
|
+
for (const rc of report.checks) {
|
|
108
|
+
const p = precedents.get(precedentKey(rc));
|
|
109
|
+
if (p) rc.precedent = p;
|
|
110
|
+
}
|
|
111
|
+
saveMemory(targetDir, nextMemory);
|
|
28
112
|
var costReport = analyzeCosts(targetDir);
|
|
29
113
|
report.costAnalysis = costReport;
|
|
30
|
-
var outDir =
|
|
31
|
-
|
|
32
|
-
var reportPath =
|
|
33
|
-
|
|
34
|
-
var costMdPath =
|
|
35
|
-
|
|
114
|
+
var outDir = join2(targetDir, ".agent-research");
|
|
115
|
+
mkdirSync2(outDir, { recursive: true });
|
|
116
|
+
var reportPath = join2(outDir, "report.json");
|
|
117
|
+
writeFileSync2(reportPath, JSON.stringify(report, null, 2));
|
|
118
|
+
var costMdPath = join2(outDir, "cost-analysis.md");
|
|
119
|
+
writeFileSync2(costMdPath, renderCostReportMd(costReport));
|
|
36
120
|
console.log(`brainblast: scanned ${targetDir} with ${rules.length} rule(s)`);
|
|
37
121
|
if (checks.length === 0) console.log(" (no catastrophic components detected)");
|
|
38
122
|
for (const c of checks) {
|
|
39
123
|
const tag = c.result === "pass" ? "PASS " : c.result === "fail" ? "FAIL " : "WARN ";
|
|
40
124
|
console.log(` [${tag}] ${c.ruleId} ${c.file}:${c.line}`);
|
|
41
125
|
console.log(` ${c.detail}`);
|
|
126
|
+
if (c.precedent) {
|
|
127
|
+
console.log(
|
|
128
|
+
` memory: same issue (${c.ruleId}) was fixed in ${c.precedent.file} on ${c.precedent.fixedAt}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (c.fix) {
|
|
132
|
+
console.log(` fix: ${c.fix.summary}`);
|
|
133
|
+
if (c.fix.diff) {
|
|
134
|
+
for (const line of c.fix.diff.split("\n")) console.log(` ${line}`);
|
|
135
|
+
}
|
|
136
|
+
if (c.fix.suggestion) {
|
|
137
|
+
for (const line of c.fix.suggestion.split("\n")) console.log(` ${line}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
42
140
|
}
|
|
43
141
|
var fails = checks.filter((c) => c.result === "fail").length;
|
|
44
142
|
var cantTell = checks.filter((c) => c.result === "cant_tell").length;
|
package/dist/index.d.ts
CHANGED
|
@@ -78,6 +78,17 @@ interface CheckOutcome {
|
|
|
78
78
|
result: CheckResultKind;
|
|
79
79
|
detail: string;
|
|
80
80
|
}
|
|
81
|
+
interface Fix {
|
|
82
|
+
summary: string;
|
|
83
|
+
diff?: string;
|
|
84
|
+
suggestion?: string;
|
|
85
|
+
}
|
|
86
|
+
interface Precedent {
|
|
87
|
+
file: string;
|
|
88
|
+
exportName: string;
|
|
89
|
+
fixedAt: string;
|
|
90
|
+
detail: string;
|
|
91
|
+
}
|
|
81
92
|
interface CheckResult extends CheckOutcome {
|
|
82
93
|
ruleId: string;
|
|
83
94
|
severity: Severity;
|
|
@@ -85,6 +96,10 @@ interface CheckResult extends CheckOutcome {
|
|
|
85
96
|
file: string;
|
|
86
97
|
line: number;
|
|
87
98
|
exportName: string;
|
|
99
|
+
/** Present when result === "fail" and a vetted fixer for check.kind produced one. */
|
|
100
|
+
fix?: Fix;
|
|
101
|
+
/** Present when result === "fail" and the same rule was previously fixed elsewhere. */
|
|
102
|
+
precedent?: Precedent;
|
|
88
103
|
}
|
|
89
104
|
interface Rule {
|
|
90
105
|
id: string;
|
|
@@ -102,6 +117,19 @@ interface Rule {
|
|
|
102
117
|
triggerCalls: string[];
|
|
103
118
|
/** Defaults to "typescript". Set to "rust" for Anchor/Rust checker kinds. */
|
|
104
119
|
lang?: "typescript" | "rust";
|
|
120
|
+
/**
|
|
121
|
+
* When true, a module import from `modules` is a REQUIRED condition for
|
|
122
|
+
* detection: a candidate must be in a file that imports one of the listed
|
|
123
|
+
* modules, AND either its name matches nameRegex or its body calls a
|
|
124
|
+
* triggerCall. This prevents generic name-only matches (e.g. a Fastify
|
|
125
|
+
* middleware named "verifyJwt" that calls `request.jwtVerify()`) from
|
|
126
|
+
* triggering jose-specific rules.
|
|
127
|
+
*
|
|
128
|
+
* When false or omitted (default), detection is: nameRegex match OR
|
|
129
|
+
* triggerCall in body. Module import is not required, so rules like
|
|
130
|
+
* stripe-webhook can still catch handlers that don't import stripe directly.
|
|
131
|
+
*/
|
|
132
|
+
requiresImport?: boolean;
|
|
105
133
|
};
|
|
106
134
|
check: {
|
|
107
135
|
kind: string;
|
|
@@ -150,6 +178,8 @@ declare function audit(targetDir: string, rules: Rule[]): {
|
|
|
150
178
|
low: number;
|
|
151
179
|
};
|
|
152
180
|
checks: {
|
|
181
|
+
precedent?: Precedent | undefined;
|
|
182
|
+
fix?: Fix | undefined;
|
|
153
183
|
ruleId: string;
|
|
154
184
|
severity: Severity;
|
|
155
185
|
result: CheckResultKind;
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,19 @@ detect:
|
|
|
14
14
|
# Privy handlers are still picked up by the @privy-io/* / jose / jsonwebtoken
|
|
15
15
|
# imports, and by triggerCalls like decodeJwt / jwtVerify.
|
|
16
16
|
nameRegex: "privy|jwt"
|
|
17
|
-
|
|
17
|
+
# Only decodeJwt triggers detection; it's specific to jose and unambiguously
|
|
18
|
+
# indicates a decode-only pattern. jwtVerify/verify/decode were removed because:
|
|
19
|
+
# - "verify" matches Node.js crypto.verify(), jsonwebtoken jwt.verify(), etc.
|
|
20
|
+
# - "decode" matches bs58.decode() (Solana), Buffer.from(), etc.
|
|
21
|
+
# - "jwtVerify" matches Fastify's request.jwtVerify() plugin method, which is
|
|
22
|
+
# not a jose-based call and requires no audience/issuer options.
|
|
23
|
+
# Functions that import jose/@privy-io/* and are named with "privy|jwt" are still
|
|
24
|
+
# detected via requiresImport + (importsModule && hasName), the primary detection path.
|
|
25
|
+
triggerCalls: [decodeJwt]
|
|
26
|
+
# A module import is REQUIRED to be a candidate. Without this guard, a Fastify
|
|
27
|
+
# middleware named "verifyJwt" (which calls request.jwtVerify(), a completely
|
|
28
|
+
# different library) would match the nameRegex and be flagged as missing aud/iss.
|
|
29
|
+
requiresImport: true
|
|
18
30
|
check:
|
|
19
31
|
kind: required-call-with-options
|
|
20
32
|
params:
|
|
@@ -11,6 +11,11 @@ detect:
|
|
|
11
11
|
modules: [stripe]
|
|
12
12
|
nameRegex: webhook
|
|
13
13
|
triggerCalls: [constructEvent]
|
|
14
|
+
# Require the file to import stripe. Without this guard, any function whose
|
|
15
|
+
# name contains "webhook" — even LemonSqueezy, Polar, or Sendgrid handlers —
|
|
16
|
+
# is flagged for missing stripe.webhooks.constructEvent, producing false
|
|
17
|
+
# positives on non-Stripe payment integrations.
|
|
18
|
+
requiresImport: true
|
|
14
19
|
check:
|
|
15
20
|
kind: positional-arg-identity
|
|
16
21
|
params:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainblast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic auditor for catastrophic AI-integration bugs: scan a repo, find the silent money/auth traps, and generate the behavioral test that proves they're fixed.",
|
|
6
6
|
"keywords": [
|