brainblast 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -2
- package/dist/{chunk-WVHGN2HR.js → chunk-Q72MTJXQ.js} +437 -59
- package/dist/cli.js +142 -22
- package/dist/index.d.ts +73 -6
- package/dist/index.js +19 -1
- package/dist/rules/env-secret-leaked-to-sink.yaml +43 -0
- package/dist/rules/env-secrets-committed.yaml +32 -0
- package/package.json +1 -1
|
@@ -4,9 +4,10 @@ import { Project, SyntaxKind } from "ts-morph";
|
|
|
4
4
|
// src/walk.ts
|
|
5
5
|
import { readdirSync, statSync } from "fs";
|
|
6
6
|
import { join } from "path";
|
|
7
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".gen", "dist", ".next", ".agent-research"]);
|
|
7
8
|
function walk(dir, out = []) {
|
|
8
9
|
for (const entry of readdirSync(dir)) {
|
|
9
|
-
if (
|
|
10
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
10
11
|
const p = join(dir, entry);
|
|
11
12
|
const st = statSync(p);
|
|
12
13
|
if (st.isDirectory()) walk(p, out);
|
|
@@ -14,6 +15,16 @@ function walk(dir, out = []) {
|
|
|
14
15
|
}
|
|
15
16
|
return out;
|
|
16
17
|
}
|
|
18
|
+
function walkAllFiles(dir, out = []) {
|
|
19
|
+
for (const entry of readdirSync(dir)) {
|
|
20
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
21
|
+
const p = join(dir, entry);
|
|
22
|
+
const st = statSync(p);
|
|
23
|
+
if (st.isDirectory()) walkAllFiles(p, out);
|
|
24
|
+
else out.push(p);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
// src/finder.ts
|
|
19
30
|
function bodyCallsAnyOf(fn, names) {
|
|
@@ -61,6 +72,42 @@ function findCandidates(targetDir, rule) {
|
|
|
61
72
|
return out;
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
// src/configFinder.ts
|
|
76
|
+
import { execFileSync } from "child_process";
|
|
77
|
+
import { readFileSync } from "fs";
|
|
78
|
+
import { relative, sep } from "path";
|
|
79
|
+
function isGitIgnored(targetDir, rel) {
|
|
80
|
+
try {
|
|
81
|
+
execFileSync("git", ["check-ignore", "-q", "--", rel], { cwd: targetDir, stdio: "ignore" });
|
|
82
|
+
return true;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (typeof e?.status === "number") return false;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function findConfigCandidates(targetDir, rule) {
|
|
89
|
+
const patterns = (rule.detect.filePatterns ?? []).map((p) => new RegExp(p));
|
|
90
|
+
if (patterns.length === 0) return [];
|
|
91
|
+
const files = walkAllFiles(targetDir);
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const rel = relative(targetDir, file).split(sep).join("/");
|
|
95
|
+
if (!patterns.some((re) => re.test(rel))) continue;
|
|
96
|
+
let content;
|
|
97
|
+
try {
|
|
98
|
+
content = readFileSync(file, "utf8");
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
out.push({
|
|
103
|
+
filePath: file,
|
|
104
|
+
content,
|
|
105
|
+
tracked: !isGitIgnored(targetDir, rel)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
64
111
|
// src/checkers/positionalArgIdentity.ts
|
|
65
112
|
import { SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
66
113
|
var positionalArgIdentity = (c, p) => {
|
|
@@ -410,6 +457,126 @@ function anchorInitIfNeededGuarded(c, p) {
|
|
|
410
457
|
};
|
|
411
458
|
}
|
|
412
459
|
|
|
460
|
+
// src/checkers/envSecretsCommitted.ts
|
|
461
|
+
var envSecretsCommitted = (c, p) => {
|
|
462
|
+
if (!c.tracked) {
|
|
463
|
+
return {
|
|
464
|
+
result: "pass",
|
|
465
|
+
detail: p.ignoredDetail ?? "File is git-ignored; not committed to source control."
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const keyRe = new RegExp(p.secretKeyPattern, "i");
|
|
469
|
+
const placeholderRe = new RegExp(p.placeholderPattern, "i");
|
|
470
|
+
const offenders = [];
|
|
471
|
+
for (const rawLine of c.content.split("\n")) {
|
|
472
|
+
const line = rawLine.trim();
|
|
473
|
+
if (!line || line.startsWith("#")) continue;
|
|
474
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
475
|
+
if (!m) continue;
|
|
476
|
+
const [, key, rawValue] = m;
|
|
477
|
+
if (!keyRe.test(key)) continue;
|
|
478
|
+
const value = (rawValue ?? "").trim().replace(/^["']|["']$/g, "");
|
|
479
|
+
if (!value) continue;
|
|
480
|
+
if (placeholderRe.test(value)) continue;
|
|
481
|
+
offenders.push(key);
|
|
482
|
+
}
|
|
483
|
+
if (offenders.length > 0) {
|
|
484
|
+
const prefix = p.failDetailPrefix ?? "This file is tracked by git and contains secret-looking values";
|
|
485
|
+
return { result: "fail", detail: `${prefix}: ${offenders.join(", ")}.` };
|
|
486
|
+
}
|
|
487
|
+
return { result: "pass", detail: p.passDetail ?? "No committed secret-looking values found." };
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// src/checkers/envTaintToSink.ts
|
|
491
|
+
import { SyntaxKind as SyntaxKind7 } from "ts-morph";
|
|
492
|
+
function calleeName(call) {
|
|
493
|
+
const exp = call.getExpression();
|
|
494
|
+
if (exp.getKind() === SyntaxKind7.Identifier) return exp.getText();
|
|
495
|
+
if (exp.getKind() === SyntaxKind7.PropertyAccessExpression) {
|
|
496
|
+
return exp.asKind(SyntaxKind7.PropertyAccessExpression).getName();
|
|
497
|
+
}
|
|
498
|
+
return "";
|
|
499
|
+
}
|
|
500
|
+
function envVarIn(text) {
|
|
501
|
+
const m = text.match(/process\.env\.([A-Za-z0-9_]+)/);
|
|
502
|
+
return m?.[1];
|
|
503
|
+
}
|
|
504
|
+
function wordIn(text, name) {
|
|
505
|
+
return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text);
|
|
506
|
+
}
|
|
507
|
+
function findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames) {
|
|
508
|
+
for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
|
|
509
|
+
const name = calleeName(call);
|
|
510
|
+
if (!sinkCalls.has(name)) continue;
|
|
511
|
+
for (const arg of call.getArguments()) {
|
|
512
|
+
const text = arg.getText();
|
|
513
|
+
const envVar = envVarIn(text);
|
|
514
|
+
if (envVar && secretKeyRe.test(envVar)) {
|
|
515
|
+
return `process.env.${envVar} is passed directly to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
|
|
516
|
+
}
|
|
517
|
+
for (const tv of taintedNames) {
|
|
518
|
+
if (wordIn(text, tv)) {
|
|
519
|
+
return `'${tv}' (holding a secret-shaped process.env value) is passed to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return void 0;
|
|
525
|
+
}
|
|
526
|
+
var envTaintToSink = (c, p) => {
|
|
527
|
+
const sinkCalls = new Set(p.sinkCalls ?? []);
|
|
528
|
+
const secretKeyRe = new RegExp(p.secretKeyPattern, "i");
|
|
529
|
+
const fn = c.fn;
|
|
530
|
+
const taintedNames = /* @__PURE__ */ new Set();
|
|
531
|
+
for (const decl of fn.getDescendantsOfKind(SyntaxKind7.VariableDeclaration)) {
|
|
532
|
+
const init = decl.getInitializer();
|
|
533
|
+
if (!init) continue;
|
|
534
|
+
const envVar = envVarIn(init.getText());
|
|
535
|
+
if (envVar && secretKeyRe.test(envVar)) {
|
|
536
|
+
taintedNames.add(decl.getName());
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const direct = findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames);
|
|
540
|
+
if (direct) return { result: "fail", detail: direct };
|
|
541
|
+
const sourceFile = fn.getSourceFile();
|
|
542
|
+
for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
|
|
543
|
+
const calleeExp = call.getExpression();
|
|
544
|
+
if (calleeExp.getKind() !== SyntaxKind7.Identifier) continue;
|
|
545
|
+
const calleeFnName = calleeExp.getText();
|
|
546
|
+
if (calleeFnName === (c.fnName ?? "")) continue;
|
|
547
|
+
const args = call.getArguments();
|
|
548
|
+
const taintedArgIndices = [];
|
|
549
|
+
args.forEach((arg, i) => {
|
|
550
|
+
const text = arg.getText();
|
|
551
|
+
const envVar = envVarIn(text);
|
|
552
|
+
if (envVar && secretKeyRe.test(envVar)) {
|
|
553
|
+
taintedArgIndices.push(i);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
for (const tv of taintedNames) {
|
|
557
|
+
if (wordIn(text, tv)) {
|
|
558
|
+
taintedArgIndices.push(i);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
if (taintedArgIndices.length === 0) continue;
|
|
564
|
+
const calleeFn = sourceFile.getFunction(calleeFnName);
|
|
565
|
+
if (!calleeFn) continue;
|
|
566
|
+
const params = calleeFn.getParameters().map((pr) => pr.getName());
|
|
567
|
+
const calleeTainted = new Set(taintedArgIndices.map((i) => params[i]).filter((x) => !!x));
|
|
568
|
+
if (calleeTainted.size === 0) continue;
|
|
569
|
+
const hop = findDirectLeak(calleeFn, sinkCalls, secretKeyRe, calleeTainted);
|
|
570
|
+
if (hop) {
|
|
571
|
+
return {
|
|
572
|
+
result: "fail",
|
|
573
|
+
detail: `A secret-shaped process.env value flows into '${calleeFnName}(...)' (called from '${c.fnName}'), where ${hop}`
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return { result: "pass", detail: "No secret-shaped process.env value flows to a logging/response sink." };
|
|
578
|
+
};
|
|
579
|
+
|
|
413
580
|
// src/checkers/index.ts
|
|
414
581
|
var registry = {
|
|
415
582
|
"positional-arg-identity": positionalArgIdentity,
|
|
@@ -417,7 +584,9 @@ var registry = {
|
|
|
417
584
|
"fee-allocation-shape": feeAllocationShape,
|
|
418
585
|
"arg-equals-constant-identifier": argEqualsConstantIdentifier,
|
|
419
586
|
"object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
|
|
420
|
-
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded
|
|
587
|
+
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
|
|
588
|
+
"env-secrets-committed": envSecretsCommitted,
|
|
589
|
+
"env-taint-to-sink": envTaintToSink
|
|
421
590
|
};
|
|
422
591
|
function runChecker(kind, c, params) {
|
|
423
592
|
const fn = registry[kind];
|
|
@@ -426,14 +595,89 @@ function runChecker(kind, c, params) {
|
|
|
426
595
|
}
|
|
427
596
|
var checkerKinds = Object.keys(registry);
|
|
428
597
|
|
|
429
|
-
// src/
|
|
430
|
-
import {
|
|
598
|
+
// src/gitDiff.ts
|
|
599
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
600
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
431
601
|
import { join as join2 } from "path";
|
|
602
|
+
function getChangedRanges(targetDir, ref) {
|
|
603
|
+
let out;
|
|
604
|
+
try {
|
|
605
|
+
out = execFileSync2(
|
|
606
|
+
"git",
|
|
607
|
+
["diff", "--unified=0", "--no-color", "--no-renames", "--diff-filter=ACMR", "--relative", ref, "--"],
|
|
608
|
+
{ cwd: targetDir, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
|
|
609
|
+
);
|
|
610
|
+
} catch (e) {
|
|
611
|
+
const stderr = e?.stderr?.toString?.() ?? e?.message ?? String(e);
|
|
612
|
+
throw new Error(`brainblast: 'git diff ${ref}' failed: ${stderr.trim()}`);
|
|
613
|
+
}
|
|
614
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
615
|
+
let currentFile = null;
|
|
616
|
+
for (const line of out.split("\n")) {
|
|
617
|
+
if (line.startsWith("+++ ")) {
|
|
618
|
+
const raw = line.slice(4).trim();
|
|
619
|
+
if (raw === "/dev/null") {
|
|
620
|
+
currentFile = null;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const rel = raw.startsWith("b/") ? raw.slice(2) : raw;
|
|
624
|
+
currentFile = join2(targetDir, rel);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (line.startsWith("@@") && currentFile) {
|
|
628
|
+
const m = line.match(/\+(\d+)(?:,(\d+))?/);
|
|
629
|
+
if (!m) continue;
|
|
630
|
+
const start = parseInt(m[1], 10);
|
|
631
|
+
const count = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
|
|
632
|
+
if (count === 0) continue;
|
|
633
|
+
const end = start + count - 1;
|
|
634
|
+
const arr = ranges.get(currentFile) ?? [];
|
|
635
|
+
arr.push([start, end]);
|
|
636
|
+
ranges.set(currentFile, arr);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return ranges;
|
|
640
|
+
}
|
|
641
|
+
function getWorkingTreeChanges(targetDir) {
|
|
642
|
+
const ranges = getChangedRanges(targetDir, "HEAD");
|
|
643
|
+
let untracked;
|
|
644
|
+
try {
|
|
645
|
+
untracked = execFileSync2("git", ["ls-files", "--others", "--exclude-standard", "--", "."], {
|
|
646
|
+
cwd: targetDir,
|
|
647
|
+
encoding: "utf8"
|
|
648
|
+
});
|
|
649
|
+
} catch {
|
|
650
|
+
return ranges;
|
|
651
|
+
}
|
|
652
|
+
for (const rel of untracked.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
653
|
+
const abs = join2(targetDir, rel);
|
|
654
|
+
let lineCount = 1;
|
|
655
|
+
try {
|
|
656
|
+
lineCount = readFileSync2(abs, "utf8").split("\n").length;
|
|
657
|
+
} catch {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
ranges.set(abs, [[1, lineCount]]);
|
|
661
|
+
}
|
|
662
|
+
return ranges;
|
|
663
|
+
}
|
|
664
|
+
function fileChanged(ranges, file) {
|
|
665
|
+
return ranges.has(file);
|
|
666
|
+
}
|
|
667
|
+
function rangeChanged(ranges, file, startLine, endLine) {
|
|
668
|
+
const fileRanges = ranges.get(file);
|
|
669
|
+
if (!fileRanges) return false;
|
|
670
|
+
return fileRanges.some(([s, e]) => startLine <= e && endLine >= s);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/rustFinder.ts
|
|
674
|
+
import { readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
675
|
+
import { join as join3 } from "path";
|
|
432
676
|
import { createRequire } from "module";
|
|
433
677
|
function walkRust(dir, out = []) {
|
|
434
678
|
for (const entry of readdirSync2(dir)) {
|
|
435
679
|
if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
|
|
436
|
-
const p =
|
|
680
|
+
const p = join3(dir, entry);
|
|
437
681
|
const st = statSync2(p);
|
|
438
682
|
if (st.isDirectory()) walkRust(p, out);
|
|
439
683
|
else if (p.endsWith(".rs")) out.push(p);
|
|
@@ -513,7 +757,7 @@ function findRustCandidates(targetDir, rule) {
|
|
|
513
757
|
const out = [];
|
|
514
758
|
for (const file of walkRust(targetDir)) {
|
|
515
759
|
if (!file.endsWith(".rs")) continue;
|
|
516
|
-
const src =
|
|
760
|
+
const src = readFileSync3(file, "utf8");
|
|
517
761
|
const tree = parser.parse(src);
|
|
518
762
|
const structMap = /* @__PURE__ */ new Map();
|
|
519
763
|
const topPairs = itemsWithAttrs(tree.rootNode);
|
|
@@ -558,7 +802,7 @@ function findRustCandidates(targetDir, rule) {
|
|
|
558
802
|
}
|
|
559
803
|
|
|
560
804
|
// src/fixers/positionalArgIdentity.ts
|
|
561
|
-
import { SyntaxKind as
|
|
805
|
+
import { SyntaxKind as SyntaxKind8 } from "ts-morph";
|
|
562
806
|
|
|
563
807
|
// src/fixers/diffUtil.ts
|
|
564
808
|
function buildDiff(node, replacement) {
|
|
@@ -583,9 +827,9 @@ function buildDiff(node, replacement) {
|
|
|
583
827
|
// src/fixers/positionalArgIdentity.ts
|
|
584
828
|
var fixPositionalArgIdentity = (c, p, outcome) => {
|
|
585
829
|
if (outcome.result !== "fail") return void 0;
|
|
586
|
-
const calls = c.fn.getDescendantsOfKind(
|
|
830
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression).filter((call) => {
|
|
587
831
|
const exp = call.getExpression();
|
|
588
|
-
return exp.getKind() ===
|
|
832
|
+
return exp.getKind() === SyntaxKind8.PropertyAccessExpression && exp.asKind(SyntaxKind8.PropertyAccessExpression).getName() === p.call;
|
|
589
833
|
});
|
|
590
834
|
if (calls.length === 0) {
|
|
591
835
|
const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
|
|
@@ -600,7 +844,7 @@ Do not call JSON.parse() on the body before this verification step.`
|
|
|
600
844
|
}
|
|
601
845
|
const arg = calls[0].getArguments()[p.argIndex];
|
|
602
846
|
const wantParam = c.params[p.paramIndex];
|
|
603
|
-
if (arg && wantParam && arg.getKind() ===
|
|
847
|
+
if (arg && wantParam && arg.getKind() === SyntaxKind8.CallExpression) {
|
|
604
848
|
return {
|
|
605
849
|
summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
|
|
606
850
|
diff: buildDiff(arg, wantParam)
|
|
@@ -610,12 +854,12 @@ Do not call JSON.parse() on the body before this verification step.`
|
|
|
610
854
|
};
|
|
611
855
|
|
|
612
856
|
// src/fixers/requiredCallWithOptions.ts
|
|
613
|
-
import { SyntaxKind as
|
|
857
|
+
import { SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
614
858
|
function callName4(call) {
|
|
615
859
|
const exp = call.getExpression();
|
|
616
|
-
if (exp.getKind() ===
|
|
617
|
-
if (exp.getKind() ===
|
|
618
|
-
return exp.asKind(
|
|
860
|
+
if (exp.getKind() === SyntaxKind9.Identifier) return exp.getText();
|
|
861
|
+
if (exp.getKind() === SyntaxKind9.PropertyAccessExpression) {
|
|
862
|
+
return exp.asKind(SyntaxKind9.PropertyAccessExpression).getName();
|
|
619
863
|
}
|
|
620
864
|
return "";
|
|
621
865
|
}
|
|
@@ -633,15 +877,15 @@ function placeholderFor(propName) {
|
|
|
633
877
|
}
|
|
634
878
|
var fixRequiredCallWithOptions = (c, p, outcome) => {
|
|
635
879
|
if (outcome.result !== "fail") return void 0;
|
|
636
|
-
const calls = c.fn.getDescendantsOfKind(
|
|
880
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind9.CallExpression);
|
|
637
881
|
const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
|
|
638
882
|
if (verify.length > 0) {
|
|
639
883
|
const call = verify[0];
|
|
640
884
|
const args = call.getArguments();
|
|
641
885
|
const lastArg = args[args.length - 1];
|
|
642
|
-
const obj = lastArg?.asKind(
|
|
886
|
+
const obj = lastArg?.asKind(SyntaxKind9.ObjectLiteralExpression);
|
|
643
887
|
const presentNames = obj ? obj.getProperties().map((pr) => {
|
|
644
|
-
const pa = pr.asKind(
|
|
888
|
+
const pa = pr.asKind(SyntaxKind9.PropertyAssignment) ?? pr.asKind(SyntaxKind9.ShorthandPropertyAssignment);
|
|
645
889
|
return pa?.getName() ?? "";
|
|
646
890
|
}) : [];
|
|
647
891
|
const missingGroups = p.requiredProps.filter(
|
|
@@ -744,9 +988,28 @@ function buildReport(target, checks, rules2, costReport) {
|
|
|
744
988
|
}
|
|
745
989
|
|
|
746
990
|
// src/audit.ts
|
|
747
|
-
function auditWithRule(targetDir, rule) {
|
|
991
|
+
function auditWithRule(targetDir, rule, changedRanges) {
|
|
992
|
+
if (rule.detect.lang === "config") {
|
|
993
|
+
return findConfigCandidates(targetDir, rule).filter((c) => !changedRanges || fileChanged(changedRanges, c.filePath)).map((c) => {
|
|
994
|
+
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
995
|
+
return {
|
|
996
|
+
ruleId: rule.id,
|
|
997
|
+
severity: rule.severity,
|
|
998
|
+
title: rule.title,
|
|
999
|
+
file: c.filePath,
|
|
1000
|
+
line: 1,
|
|
1001
|
+
exportName: c.filePath,
|
|
1002
|
+
...outcome
|
|
1003
|
+
};
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
748
1006
|
if (rule.detect.lang === "rust") {
|
|
749
|
-
return findRustCandidates(targetDir, rule).
|
|
1007
|
+
return findRustCandidates(targetDir, rule).filter((c) => {
|
|
1008
|
+
if (!changedRanges) return true;
|
|
1009
|
+
const start = (c.fnBodyNode?.startPosition?.row ?? 0) + 1;
|
|
1010
|
+
const end = (c.fnBodyNode?.endPosition?.row ?? start - 1) + 1;
|
|
1011
|
+
return rangeChanged(changedRanges, c.filePath, start, end);
|
|
1012
|
+
}).map((c) => {
|
|
750
1013
|
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
751
1014
|
return {
|
|
752
1015
|
ruleId: rule.id,
|
|
@@ -760,7 +1023,10 @@ function auditWithRule(targetDir, rule) {
|
|
|
760
1023
|
};
|
|
761
1024
|
});
|
|
762
1025
|
}
|
|
763
|
-
return findCandidates(targetDir, rule).
|
|
1026
|
+
return findCandidates(targetDir, rule).filter((c) => {
|
|
1027
|
+
if (!changedRanges) return true;
|
|
1028
|
+
return rangeChanged(changedRanges, c.filePath, c.fn.getStartLineNumber(), c.fn.getEndLineNumber());
|
|
1029
|
+
}).map((c) => {
|
|
764
1030
|
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
765
1031
|
const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
|
|
766
1032
|
return {
|
|
@@ -775,8 +1041,8 @@ function auditWithRule(targetDir, rule) {
|
|
|
775
1041
|
};
|
|
776
1042
|
});
|
|
777
1043
|
}
|
|
778
|
-
function audit(targetDir, rules2) {
|
|
779
|
-
const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
|
|
1044
|
+
function audit(targetDir, rules2, changedRanges) {
|
|
1045
|
+
const checks = rules2.flatMap((r) => auditWithRule(targetDir, r, changedRanges));
|
|
780
1046
|
const report = buildReport(targetDir, checks, rules2);
|
|
781
1047
|
return { checks, report };
|
|
782
1048
|
}
|
|
@@ -1055,6 +1321,11 @@ mod brainblast_reinit_guard_test {
|
|
|
1055
1321
|
}
|
|
1056
1322
|
`;
|
|
1057
1323
|
|
|
1324
|
+
// src/testTemplates/none.ts
|
|
1325
|
+
var none = (opts) => `// No behavioral contract test applies to this rule.
|
|
1326
|
+
// Finding: ${opts.handlerExport} (${opts.handlerImportPath})
|
|
1327
|
+
`;
|
|
1328
|
+
|
|
1058
1329
|
// src/testTemplates/index.ts
|
|
1059
1330
|
var registry3 = {
|
|
1060
1331
|
"stripe-webhook-signature": stripeWebhookSignature,
|
|
@@ -1062,7 +1333,8 @@ var registry3 = {
|
|
|
1062
1333
|
"bags-fee-share": bagsFeeShare,
|
|
1063
1334
|
"token-program-consistency": tokenProgramConsistency,
|
|
1064
1335
|
"metaplex-immutable-metadata": metaplexImmutableMetadata,
|
|
1065
|
-
"anchor-program-test": anchorProgramTest
|
|
1336
|
+
"anchor-program-test": anchorProgramTest,
|
|
1337
|
+
none
|
|
1066
1338
|
};
|
|
1067
1339
|
var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
1068
1340
|
function renderTest(kind, opts) {
|
|
@@ -1076,8 +1348,8 @@ function renderTest(kind, opts) {
|
|
|
1076
1348
|
var testKinds = Object.keys(registry3);
|
|
1077
1349
|
|
|
1078
1350
|
// src/loadRules.ts
|
|
1079
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
1080
|
-
import { join as
|
|
1351
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
|
|
1352
|
+
import { join as join4 } from "path";
|
|
1081
1353
|
import { parse } from "yaml";
|
|
1082
1354
|
var SEVERITIES = ["critical", "high", "medium", "low"];
|
|
1083
1355
|
function validateRule(r, file) {
|
|
@@ -1089,7 +1361,19 @@ function validateRule(r, file) {
|
|
|
1089
1361
|
if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
|
|
1090
1362
|
if (!r.title || typeof r.title !== "string") errs.push("missing title");
|
|
1091
1363
|
if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
|
|
1092
|
-
if (
|
|
1364
|
+
if (r.detect?.lang === "config") {
|
|
1365
|
+
if (!Array.isArray(r.detect.filePatterns) || r.detect.filePatterns.length === 0) {
|
|
1366
|
+
errs.push("detect.filePatterns must be a non-empty array when detect.lang is 'config'");
|
|
1367
|
+
} else {
|
|
1368
|
+
for (const pat of r.detect.filePatterns) {
|
|
1369
|
+
try {
|
|
1370
|
+
new RegExp(pat);
|
|
1371
|
+
} catch {
|
|
1372
|
+
errs.push(`detect.filePatterns contains an invalid regex: ${pat}`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
} else if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
|
|
1093
1377
|
errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
|
|
1094
1378
|
} else {
|
|
1095
1379
|
try {
|
|
@@ -1110,7 +1394,7 @@ function loadRules(dir) {
|
|
|
1110
1394
|
const files = readdirSync3(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
|
|
1111
1395
|
const rules2 = [];
|
|
1112
1396
|
for (const f of files) {
|
|
1113
|
-
const raw = parse(
|
|
1397
|
+
const raw = parse(readFileSync4(join4(dir, f), "utf8"));
|
|
1114
1398
|
validateRule(raw, f);
|
|
1115
1399
|
rules2.push(raw);
|
|
1116
1400
|
}
|
|
@@ -1119,23 +1403,23 @@ function loadRules(dir) {
|
|
|
1119
1403
|
|
|
1120
1404
|
// rules/index.ts
|
|
1121
1405
|
import { existsSync } from "fs";
|
|
1122
|
-
import { dirname, join as
|
|
1406
|
+
import { dirname, join as join5 } from "path";
|
|
1123
1407
|
import { fileURLToPath } from "url";
|
|
1124
1408
|
function bundledRulesDir() {
|
|
1125
1409
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1126
|
-
if (existsSync(
|
|
1127
|
-
const sub =
|
|
1128
|
-
if (existsSync(
|
|
1410
|
+
if (existsSync(join5(here, "stripe-webhook-raw-body.yaml"))) return here;
|
|
1411
|
+
const sub = join5(here, "rules");
|
|
1412
|
+
if (existsSync(join5(sub, "stripe-webhook-raw-body.yaml"))) return sub;
|
|
1129
1413
|
return here;
|
|
1130
1414
|
}
|
|
1131
1415
|
var rules = loadRules(bundledRulesDir());
|
|
1132
1416
|
|
|
1133
1417
|
// src/resolveRules.ts
|
|
1134
1418
|
import { existsSync as existsSync2 } from "fs";
|
|
1135
|
-
import { join as
|
|
1419
|
+
import { join as join6 } from "path";
|
|
1136
1420
|
function resolveRules(targetDir) {
|
|
1137
1421
|
const all = [...rules];
|
|
1138
|
-
const projDir =
|
|
1422
|
+
const projDir = join6(targetDir, ".agent-research", "rules");
|
|
1139
1423
|
if (existsSync2(projDir)) {
|
|
1140
1424
|
const seen = new Set(all.map((r) => r.id));
|
|
1141
1425
|
for (const r of loadRules(projDir)) {
|
|
@@ -1151,19 +1435,19 @@ function resolveRules(targetDir) {
|
|
|
1151
1435
|
}
|
|
1152
1436
|
|
|
1153
1437
|
// src/trustGraph/directory.ts
|
|
1154
|
-
import { readFileSync as
|
|
1438
|
+
import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
|
|
1155
1439
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1156
|
-
import { join as
|
|
1440
|
+
import { join as join7 } from "path";
|
|
1157
1441
|
import { parse as parse2 } from "yaml";
|
|
1158
1442
|
var cache = null;
|
|
1159
1443
|
function bundledPath() {
|
|
1160
1444
|
const here = fileURLToPath2(new URL(".", import.meta.url));
|
|
1161
1445
|
const candidates = [
|
|
1162
|
-
|
|
1446
|
+
join7(here, "programs", "directory.yaml"),
|
|
1163
1447
|
// dist/programs/directory.yaml
|
|
1164
|
-
|
|
1448
|
+
join7(here, "..", "..", "programs", "directory.yaml"),
|
|
1165
1449
|
// src/../../programs/
|
|
1166
|
-
|
|
1450
|
+
join7(here, "..", "programs", "directory.yaml")
|
|
1167
1451
|
// fallback
|
|
1168
1452
|
];
|
|
1169
1453
|
for (const c of candidates) {
|
|
@@ -1173,7 +1457,7 @@ function bundledPath() {
|
|
|
1173
1457
|
}
|
|
1174
1458
|
function loadDirectory(path = bundledPath()) {
|
|
1175
1459
|
if (cache && path === bundledPath()) return cache;
|
|
1176
|
-
const raw = parse2(
|
|
1460
|
+
const raw = parse2(readFileSync5(path, "utf8"));
|
|
1177
1461
|
if (!raw || !Array.isArray(raw.programs)) {
|
|
1178
1462
|
throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
|
|
1179
1463
|
}
|
|
@@ -1244,14 +1528,14 @@ function isValidSolanaAddress(s) {
|
|
|
1244
1528
|
}
|
|
1245
1529
|
|
|
1246
1530
|
// src/trustGraph/programCache.ts
|
|
1247
|
-
import { readFileSync as
|
|
1248
|
-
import { join as
|
|
1531
|
+
import { readFileSync as readFileSync6, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
|
|
1532
|
+
import { join as join8, dirname as dirname2 } from "path";
|
|
1249
1533
|
import { homedir } from "os";
|
|
1250
1534
|
var DEFAULT_TTL_HOURS = 168;
|
|
1251
1535
|
var SCHEMA_VERSION = "1.0";
|
|
1252
1536
|
function defaultCachePath() {
|
|
1253
1537
|
const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
|
|
1254
|
-
return envOverride ??
|
|
1538
|
+
return envOverride ?? join8(homedir(), ".brainblast", "program-cache.json");
|
|
1255
1539
|
}
|
|
1256
1540
|
function emptyCache() {
|
|
1257
1541
|
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
@@ -1260,7 +1544,7 @@ function loadProgramCache(cachePath) {
|
|
|
1260
1544
|
const path = cachePath ?? defaultCachePath();
|
|
1261
1545
|
if (!existsSync4(path)) return emptyCache();
|
|
1262
1546
|
try {
|
|
1263
|
-
const raw = JSON.parse(
|
|
1547
|
+
const raw = JSON.parse(readFileSync6(path, "utf8"));
|
|
1264
1548
|
if (raw?.schemaVersion !== SCHEMA_VERSION) {
|
|
1265
1549
|
return emptyCache();
|
|
1266
1550
|
}
|
|
@@ -1537,7 +1821,7 @@ function renderTrustGraphMd(g) {
|
|
|
1537
1821
|
}
|
|
1538
1822
|
|
|
1539
1823
|
// src/costAnalysis.ts
|
|
1540
|
-
import { Project as Project2, SyntaxKind as
|
|
1824
|
+
import { Project as Project2, SyntaxKind as SyntaxKind10 } from "ts-morph";
|
|
1541
1825
|
var LAMPORTS_PER_BYTE_YEAR = 3480;
|
|
1542
1826
|
var EXEMPTION_THRESHOLD = 2;
|
|
1543
1827
|
var OVERHEAD_BYTES = 128;
|
|
@@ -1618,11 +1902,11 @@ var KNOWN_FLOWS = [
|
|
|
1618
1902
|
}
|
|
1619
1903
|
];
|
|
1620
1904
|
var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1905
|
+
SyntaxKind10.ForStatement,
|
|
1906
|
+
SyntaxKind10.ForOfStatement,
|
|
1907
|
+
SyntaxKind10.ForInStatement,
|
|
1908
|
+
SyntaxKind10.WhileStatement,
|
|
1909
|
+
SyntaxKind10.DoStatement
|
|
1626
1910
|
]);
|
|
1627
1911
|
var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
|
|
1628
1912
|
function isInsideLoop(node) {
|
|
@@ -1630,12 +1914,12 @@ function isInsideLoop(node) {
|
|
|
1630
1914
|
while (cur) {
|
|
1631
1915
|
const k = cur.getKind?.();
|
|
1632
1916
|
if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
|
|
1633
|
-
return { scalable: true, note: `call is inside a ${
|
|
1917
|
+
return { scalable: true, note: `call is inside a ${SyntaxKind10[k]} \u2014 cost scales with loop iterations` };
|
|
1634
1918
|
}
|
|
1635
|
-
if (k ===
|
|
1919
|
+
if (k === SyntaxKind10.CallExpression) {
|
|
1636
1920
|
const expr = cur.getExpression?.();
|
|
1637
|
-
if (expr?.getKind?.() ===
|
|
1638
|
-
const name = expr.asKind?.(
|
|
1921
|
+
if (expr?.getKind?.() === SyntaxKind10.PropertyAccessExpression) {
|
|
1922
|
+
const name = expr.asKind?.(SyntaxKind10.PropertyAccessExpression)?.getName?.();
|
|
1639
1923
|
if (name && ARRAY_METHOD_LOOPS.has(name)) {
|
|
1640
1924
|
return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
|
|
1641
1925
|
}
|
|
@@ -1649,7 +1933,7 @@ function detectPriorityFee(targetDir) {
|
|
|
1649
1933
|
const project = new Project2({ skipAddingFilesFromTsConfig: true });
|
|
1650
1934
|
for (const file of walk(targetDir)) {
|
|
1651
1935
|
const sf = project.addSourceFileAtPath(file);
|
|
1652
|
-
const calls = sf.getDescendantsOfKind(
|
|
1936
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind10.CallExpression);
|
|
1653
1937
|
for (const ce of calls) {
|
|
1654
1938
|
const expr = ce.getExpression();
|
|
1655
1939
|
const text = expr.getText();
|
|
@@ -1677,13 +1961,13 @@ function detectAccountFlows(targetDir) {
|
|
|
1677
1961
|
const importedModules = new Set(
|
|
1678
1962
|
sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
|
|
1679
1963
|
);
|
|
1680
|
-
for (const ce of sf.getDescendantsOfKind(
|
|
1964
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind10.CallExpression)) {
|
|
1681
1965
|
const expr = ce.getExpression();
|
|
1682
1966
|
let callName5 = null;
|
|
1683
|
-
if (expr.getKind() ===
|
|
1967
|
+
if (expr.getKind() === SyntaxKind10.Identifier) {
|
|
1684
1968
|
callName5 = expr.getText();
|
|
1685
|
-
} else if (expr.getKind() ===
|
|
1686
|
-
callName5 = expr.asKind(
|
|
1969
|
+
} else if (expr.getKind() === SyntaxKind10.PropertyAccessExpression) {
|
|
1970
|
+
callName5 = expr.asKind(SyntaxKind10.PropertyAccessExpression).getName();
|
|
1687
1971
|
}
|
|
1688
1972
|
if (!callName5) continue;
|
|
1689
1973
|
const known = callIndex.get(callName5);
|
|
@@ -1779,10 +2063,100 @@ function renderCostReportMd(r) {
|
|
|
1779
2063
|
return lines.join("\n");
|
|
1780
2064
|
}
|
|
1781
2065
|
|
|
2066
|
+
// src/watch.ts
|
|
2067
|
+
import { watch as fsWatch } from "fs";
|
|
2068
|
+
function runIncrementalScan(targetDir, rules2, emit) {
|
|
2069
|
+
const start = Date.now();
|
|
2070
|
+
let changedRanges;
|
|
2071
|
+
try {
|
|
2072
|
+
changedRanges = getWorkingTreeChanges(targetDir);
|
|
2073
|
+
} catch (e) {
|
|
2074
|
+
emit({ type: "scan_error", message: e?.message ?? String(e) });
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
if (changedRanges.size === 0) {
|
|
2078
|
+
emit({ type: "scan_complete", filesChanged: 0, findings: 0, durationMs: Date.now() - start });
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
const { checks } = audit(targetDir, rules2, changedRanges);
|
|
2082
|
+
let findings = 0;
|
|
2083
|
+
for (const c of checks) {
|
|
2084
|
+
if (c.result === "pass") continue;
|
|
2085
|
+
findings++;
|
|
2086
|
+
emit({
|
|
2087
|
+
type: "finding",
|
|
2088
|
+
ruleId: c.ruleId,
|
|
2089
|
+
severity: c.severity,
|
|
2090
|
+
result: c.result,
|
|
2091
|
+
file: c.file,
|
|
2092
|
+
line: c.line,
|
|
2093
|
+
detail: c.detail,
|
|
2094
|
+
...c.fix ? { fix: c.fix } : {}
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
emit({ type: "scan_complete", filesChanged: changedRanges.size, findings, durationMs: Date.now() - start });
|
|
2098
|
+
}
|
|
2099
|
+
function startWatch(targetDir, opts = {}) {
|
|
2100
|
+
const debounceMs = opts.debounceMs ?? 300;
|
|
2101
|
+
const emit = opts.emit ?? ((e) => process.stdout.write(JSON.stringify(e) + "\n"));
|
|
2102
|
+
const rules2 = resolveRules(targetDir);
|
|
2103
|
+
let timer;
|
|
2104
|
+
const scheduleScan = () => {
|
|
2105
|
+
if (timer) clearTimeout(timer);
|
|
2106
|
+
timer = setTimeout(() => runIncrementalScan(targetDir, rules2, emit), debounceMs);
|
|
2107
|
+
};
|
|
2108
|
+
const watcher = fsWatch(targetDir, { recursive: true }, (_event, filename) => {
|
|
2109
|
+
if (!filename) return;
|
|
2110
|
+
const parts = filename.split(/[\\/]/);
|
|
2111
|
+
if (parts.some((p) => SKIP_DIRS.has(p))) return;
|
|
2112
|
+
scheduleScan();
|
|
2113
|
+
});
|
|
2114
|
+
emit({ type: "watch_started", targetDir });
|
|
2115
|
+
return {
|
|
2116
|
+
close: () => {
|
|
2117
|
+
if (timer) clearTimeout(timer);
|
|
2118
|
+
watcher.close();
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// src/fixers/applyDiff.ts
|
|
2124
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
|
|
2125
|
+
function parseDiff(diff) {
|
|
2126
|
+
const lines = diff.split("\n");
|
|
2127
|
+
const fileLine = lines.find((l) => l.startsWith("+++ b"));
|
|
2128
|
+
if (!fileLine) throw new Error("parseDiff: no '+++ b<path>' line found");
|
|
2129
|
+
const filePath = fileLine.slice("+++ b".length);
|
|
2130
|
+
const hunkLine = lines.find((l) => l.startsWith("@@"));
|
|
2131
|
+
if (!hunkLine) throw new Error("parseDiff: no hunk header found");
|
|
2132
|
+
const m = hunkLine.match(/^@@ -(\d+),(\d+) \+\d+,\d+ @@/);
|
|
2133
|
+
if (!m) throw new Error(`parseDiff: unrecognized hunk header '${hunkLine}'`);
|
|
2134
|
+
const oldStart = Number(m[1]);
|
|
2135
|
+
const oldCount = Number(m[2]);
|
|
2136
|
+
const newLines = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1));
|
|
2137
|
+
return { filePath, oldStart, oldCount, newLines };
|
|
2138
|
+
}
|
|
2139
|
+
function applyDiffToFile(diff) {
|
|
2140
|
+
const { filePath, oldStart, oldCount, newLines } = parseDiff(diff);
|
|
2141
|
+
const content = readFileSync7(filePath, "utf8");
|
|
2142
|
+
const fileLines = content.split("\n");
|
|
2143
|
+
const removedLines = diff.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---")).map((l) => l.slice(1));
|
|
2144
|
+
const actual = fileLines.slice(oldStart - 1, oldStart - 1 + oldCount);
|
|
2145
|
+
if (JSON.stringify(actual) !== JSON.stringify(removedLines)) return false;
|
|
2146
|
+
fileLines.splice(oldStart - 1, oldCount, ...newLines);
|
|
2147
|
+
writeFileSync2(filePath, fileLines.join("\n"));
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
1782
2151
|
export {
|
|
1783
2152
|
findCandidates,
|
|
2153
|
+
findConfigCandidates,
|
|
1784
2154
|
runChecker,
|
|
1785
2155
|
checkerKinds,
|
|
2156
|
+
getChangedRanges,
|
|
2157
|
+
getWorkingTreeChanges,
|
|
2158
|
+
fileChanged,
|
|
2159
|
+
rangeChanged,
|
|
1786
2160
|
auditWithRule,
|
|
1787
2161
|
audit,
|
|
1788
2162
|
renderTest,
|
|
@@ -1808,5 +2182,9 @@ export {
|
|
|
1808
2182
|
rentExemptMinimum,
|
|
1809
2183
|
lamportsToSol,
|
|
1810
2184
|
analyzeCosts,
|
|
1811
|
-
renderCostReportMd
|
|
2185
|
+
renderCostReportMd,
|
|
2186
|
+
runIncrementalScan,
|
|
2187
|
+
startWatch,
|
|
2188
|
+
parseDiff,
|
|
2189
|
+
applyDiffToFile
|
|
1812
2190
|
};
|