brainblast 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,8 @@ npx brainblast . # scan the repo, write .agent-research/report.json
12
12
  npx brainblast . --ci # exit 1 if a confirmed FAIL remains
13
13
  npx brainblast . --ci --strict # also fail on CANT_TELL (can't statically prove)
14
14
  npx brainblast . --since origin/main # diff-aware: only audit what changed
15
+ npx brainblast fix . # dry run: list mechanical fixes
16
+ npx brainblast fix . --apply # write fixes, re-audit RED -> GREEN
15
17
  ```
16
18
 
17
19
  Exit codes: **0** clean · **1** a confirmed FAIL · CANT_TELL is a warning by
@@ -58,6 +60,22 @@ git work tree). This is the integration point for an agent daemon — tail
58
60
  stdout for structured findings instead of polling `.agent-research/report.json`.
59
61
  Exit with Ctrl-C / SIGTERM.
60
62
 
63
+ ### Auto-fix (`brainblast fix`)
64
+
65
+ ```sh
66
+ npx brainblast fix . # dry run: list available mechanical fixes
67
+ npx brainblast fix . --apply # write each fix.diff to disk, then re-audit
68
+ npx brainblast fix . --apply --branch # also commit to brainblast/auto-fix-<ts>
69
+ ```
70
+
71
+ Every confirmed FAIL that ships a mechanical `fix.diff` (e.g. Stripe raw-body,
72
+ Privy `audience`/`issuer`) can be applied directly. `--apply` writes each diff,
73
+ then re-runs the audit to confirm the finding now passes (RED -> GREEN) — any
74
+ fix that doesn't take is reported, not silently dropped. Findings with only a
75
+ `suggestion` (structural fixes brainblast won't auto-synthesize) are listed as
76
+ guidance, not applied. `--branch` additionally creates a new branch and commits
77
+ the applied changes.
78
+
61
79
  ## What it catches
62
80
 
63
81
  ### Web2 / Node.js
@@ -81,6 +99,8 @@ Exit with Ctrl-C / SIGTERM.
81
99
  | Rule | What's wrong | Consequence |
82
100
  |------|--------------|-------------|
83
101
  | `env-secrets-committed` | A `.env*` file (not `.env.example`/`.sample`/`.template`) is tracked by git and contains a secret-shaped key (`SECRET`, `*_PRIVATE_KEY`, `*_API_KEY`, `*_TOKEN`, `*_PASSWORD`, etc.) with a real-looking (non-placeholder) value | Anyone with read access to the repo — including forks of a public repo — can read the live credential |
102
+ | `env-secret-leaked-to-sink` | A secret-shaped `process.env.X` value flows — directly, via a local variable, forward through helper functions (same-file or imported from another file), or backward into a function that's called elsewhere in the project with a tainted argument — into `console.log`/`res.json`/`res.send`/etc., up to 2 hops across the whole project | Credentials end up in logs, error trackers, or API responses — readable by anyone with log/response access |
103
+ | `request-input-command-injection` | Untrusted `req.body`/`req.query`/`req.params`/`req.headers` data flows — directly or across files — into `exec`/`execSync`/`spawn`/`spawnSync`/`execFile`/`execFileSync` | A malicious request can run arbitrary shell commands on the server |
84
104
 
85
105
  Each finding lands in `.agent-research/report.json` (stable `schemaVersion: "1.0"`)
86
106
  with a `checks[]` array a CI gate can read. Each confirmed FAIL ships a
@@ -164,7 +184,7 @@ All types are exported: `Rule`, `CheckResult`, `CostReport`, `AccountFlow`,
164
184
 
165
185
  ```sh
166
186
  npm install
167
- npm test # unit suite (164 tests)
187
+ npm test # unit suite (180 tests)
168
188
  npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
169
189
  npm run build # produce dist/ (the published artifact)
170
190
  ```
@@ -487,6 +487,181 @@ var envSecretsCommitted = (c, p) => {
487
487
  return { result: "pass", detail: p.passDetail ?? "No committed secret-looking values found." };
488
488
  };
489
489
 
490
+ // src/checkers/taintToSink.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 calleeIdentifierName(call) {
501
+ const exp = call.getExpression();
502
+ return exp.getKind() === SyntaxKind7.Identifier ? exp.getText() : void 0;
503
+ }
504
+ function wordIn(text, name) {
505
+ return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text);
506
+ }
507
+ function matchesSource(text, sourceRes) {
508
+ return sourceRes.some((re) => re.test(text));
509
+ }
510
+ function localTaintedNames(fn, sourceRes) {
511
+ const names = /* @__PURE__ */ new Set();
512
+ for (const decl of fn.getDescendantsOfKind(SyntaxKind7.VariableDeclaration)) {
513
+ const init = decl.getInitializer();
514
+ if (init && matchesSource(init.getText(), sourceRes)) names.add(decl.getName());
515
+ }
516
+ return names;
517
+ }
518
+ function findDirectLeak(fn, sinkCalls, sourceRes, taintedNames) {
519
+ for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
520
+ const name = calleeName(call);
521
+ if (!sinkCalls.has(name)) continue;
522
+ for (const arg of call.getArguments()) {
523
+ const text = arg.getText();
524
+ if (matchesSource(text, sourceRes)) {
525
+ return `'${text}' is passed directly to ${name}(...) \u2014 tainted values must not reach this sink.`;
526
+ }
527
+ for (const tv of taintedNames) {
528
+ if (wordIn(text, tv)) {
529
+ return `'${tv}' (a tainted value) is passed to ${name}(...) \u2014 tainted values must not reach this sink.`;
530
+ }
531
+ }
532
+ }
533
+ }
534
+ return void 0;
535
+ }
536
+ function resolveFunction(sourceFile, name) {
537
+ const local = sourceFile.getFunction(name);
538
+ if (local) return { fn: local, sf: sourceFile };
539
+ for (const imp of sourceFile.getImportDeclarations()) {
540
+ const named2 = imp.getNamedImports().find((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name);
541
+ if (!named2) continue;
542
+ const targetSf = imp.getModuleSpecifierSourceFile();
543
+ if (!targetSf) continue;
544
+ const targetFn = targetSf.getFunction(named2.getName());
545
+ if (targetFn) return { fn: targetFn, sf: targetSf };
546
+ }
547
+ return void 0;
548
+ }
549
+ function findForwardLeak(fn, rootName, sinkCalls, sourceRes, taintedNames, hopsLeft, visited) {
550
+ if (hopsLeft <= 0) return void 0;
551
+ for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
552
+ const name = calleeIdentifierName(call);
553
+ if (!name || name === rootName) continue;
554
+ const args = call.getArguments();
555
+ const taintedArgIndices = [];
556
+ args.forEach((arg, i) => {
557
+ const text = arg.getText();
558
+ if (matchesSource(text, sourceRes)) {
559
+ taintedArgIndices.push(i);
560
+ return;
561
+ }
562
+ for (const tv of taintedNames) {
563
+ if (wordIn(text, tv)) {
564
+ taintedArgIndices.push(i);
565
+ return;
566
+ }
567
+ }
568
+ });
569
+ if (taintedArgIndices.length === 0) continue;
570
+ const resolved = resolveFunction(fn.getSourceFile(), name);
571
+ if (!resolved) continue;
572
+ const key = `${resolved.sf.getFilePath()}::${name}`;
573
+ if (visited.has(key)) continue;
574
+ visited.add(key);
575
+ const params = resolved.fn.getParameters().map((pr) => pr.getName());
576
+ const calleeTainted = new Set(
577
+ taintedArgIndices.map((i) => params[i]).filter((x) => !!x)
578
+ );
579
+ if (calleeTainted.size === 0) continue;
580
+ const direct = findDirectLeak(resolved.fn, sinkCalls, sourceRes, calleeTainted);
581
+ if (direct) {
582
+ const where = resolved.sf === fn.getSourceFile() ? "" : ` (in ${resolved.sf.getFilePath()})`;
583
+ return `A tainted value flows into '${name}(...)'${where}, where ${direct}`;
584
+ }
585
+ const deeper = findForwardLeak(resolved.fn, rootName, sinkCalls, sourceRes, calleeTainted, hopsLeft - 1, visited);
586
+ if (deeper) return `via '${name}(...)': ${deeper}`;
587
+ }
588
+ return void 0;
589
+ }
590
+ function paramsUsedInSink(fn, sinkCalls) {
591
+ const params = new Set(fn.getParameters().map((p) => p.getName()));
592
+ const sinked = /* @__PURE__ */ new Set();
593
+ for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
594
+ if (!sinkCalls.has(calleeName(call))) continue;
595
+ for (const arg of call.getArguments()) {
596
+ const text = arg.getText();
597
+ for (const p of params) {
598
+ if (wordIn(text, p)) sinked.add(p);
599
+ }
600
+ }
601
+ }
602
+ return sinked;
603
+ }
604
+ function enclosingFunction(node) {
605
+ return node.getFirstAncestor(
606
+ (a) => a.getKind() === SyntaxKind7.FunctionDeclaration || a.getKind() === SyntaxKind7.ArrowFunction
607
+ );
608
+ }
609
+ function findBackwardLeak(candidateFn, fnName, candidateFile, params, sinkedParams, sourceRes) {
610
+ const project = candidateFn.getProject();
611
+ for (const sf of project.getSourceFiles()) {
612
+ for (const call of sf.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
613
+ if (calleeIdentifierName(call) !== fnName) continue;
614
+ if (call.getFirstAncestor((a) => a === candidateFn)) continue;
615
+ const args = call.getArguments();
616
+ for (const pname of sinkedParams) {
617
+ const idx = params.indexOf(pname);
618
+ const arg = args[idx];
619
+ if (!arg) continue;
620
+ const text = arg.getText();
621
+ if (matchesSource(text, sourceRes)) {
622
+ return `'${fnName}' is called from ${sf.getFilePath()}:${call.getStartLineNumber()} with '${text}' as '${pname}', which this function passes to a sink.`;
623
+ }
624
+ if (arg.getKind() === SyntaxKind7.Identifier) {
625
+ const callerFn = enclosingFunction(arg);
626
+ if (callerFn) {
627
+ const callerTainted = localTaintedNames(callerFn, sourceRes);
628
+ if (callerTainted.has(text)) {
629
+ return `'${fnName}' is called from ${sf.getFilePath()}:${call.getStartLineNumber()} with '${text}' (a tainted value) as '${pname}', which this function passes to a sink.`;
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ return void 0;
637
+ }
638
+ var taintToSink = (c, p) => {
639
+ const sourceRes = p.sources.map((s) => new RegExp(s.pattern));
640
+ const sinkCalls = new Set(p.sinkCalls ?? []);
641
+ const maxHops = p.maxHops ?? 2;
642
+ const fn = c.fn;
643
+ const taintedNames = localTaintedNames(fn, sourceRes);
644
+ const direct = findDirectLeak(fn, sinkCalls, sourceRes, taintedNames);
645
+ if (direct) return { result: "fail", detail: direct };
646
+ const forward = findForwardLeak(fn, c.fnName, sinkCalls, sourceRes, taintedNames, maxHops, /* @__PURE__ */ new Set([
647
+ `${fn.getSourceFile().getFilePath()}::${c.fnName}`
648
+ ]));
649
+ if (forward) return { result: "fail", detail: forward };
650
+ const sinkedParams = paramsUsedInSink(fn, sinkCalls);
651
+ if (sinkedParams.size > 0) {
652
+ const backward = findBackwardLeak(
653
+ fn,
654
+ c.fnName,
655
+ c.filePath,
656
+ fn.getParameters().map((pr) => pr.getName()),
657
+ sinkedParams,
658
+ sourceRes
659
+ );
660
+ if (backward) return { result: "fail", detail: backward };
661
+ }
662
+ return { result: "pass", detail: "No tracked source value flows to a sink within the analyzed call graph." };
663
+ };
664
+
490
665
  // src/checkers/index.ts
491
666
  var registry = {
492
667
  "positional-arg-identity": positionalArgIdentity,
@@ -495,7 +670,8 @@ var registry = {
495
670
  "arg-equals-constant-identifier": argEqualsConstantIdentifier,
496
671
  "object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
497
672
  "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
498
- "env-secrets-committed": envSecretsCommitted
673
+ "env-secrets-committed": envSecretsCommitted,
674
+ "taint-to-sink": taintToSink
499
675
  };
500
676
  function runChecker(kind, c, params) {
501
677
  const fn = registry[kind];
@@ -711,7 +887,7 @@ function findRustCandidates(targetDir, rule) {
711
887
  }
712
888
 
713
889
  // src/fixers/positionalArgIdentity.ts
714
- import { SyntaxKind as SyntaxKind7 } from "ts-morph";
890
+ import { SyntaxKind as SyntaxKind8 } from "ts-morph";
715
891
 
716
892
  // src/fixers/diffUtil.ts
717
893
  function buildDiff(node, replacement) {
@@ -736,9 +912,9 @@ function buildDiff(node, replacement) {
736
912
  // src/fixers/positionalArgIdentity.ts
737
913
  var fixPositionalArgIdentity = (c, p, outcome) => {
738
914
  if (outcome.result !== "fail") return void 0;
739
- const calls = c.fn.getDescendantsOfKind(SyntaxKind7.CallExpression).filter((call) => {
915
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression).filter((call) => {
740
916
  const exp = call.getExpression();
741
- return exp.getKind() === SyntaxKind7.PropertyAccessExpression && exp.asKind(SyntaxKind7.PropertyAccessExpression).getName() === p.call;
917
+ return exp.getKind() === SyntaxKind8.PropertyAccessExpression && exp.asKind(SyntaxKind8.PropertyAccessExpression).getName() === p.call;
742
918
  });
743
919
  if (calls.length === 0) {
744
920
  const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
@@ -753,7 +929,7 @@ Do not call JSON.parse() on the body before this verification step.`
753
929
  }
754
930
  const arg = calls[0].getArguments()[p.argIndex];
755
931
  const wantParam = c.params[p.paramIndex];
756
- if (arg && wantParam && arg.getKind() === SyntaxKind7.CallExpression) {
932
+ if (arg && wantParam && arg.getKind() === SyntaxKind8.CallExpression) {
757
933
  return {
758
934
  summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
759
935
  diff: buildDiff(arg, wantParam)
@@ -763,12 +939,12 @@ Do not call JSON.parse() on the body before this verification step.`
763
939
  };
764
940
 
765
941
  // src/fixers/requiredCallWithOptions.ts
766
- import { SyntaxKind as SyntaxKind8 } from "ts-morph";
942
+ import { SyntaxKind as SyntaxKind9 } from "ts-morph";
767
943
  function callName4(call) {
768
944
  const exp = call.getExpression();
769
- if (exp.getKind() === SyntaxKind8.Identifier) return exp.getText();
770
- if (exp.getKind() === SyntaxKind8.PropertyAccessExpression) {
771
- return exp.asKind(SyntaxKind8.PropertyAccessExpression).getName();
945
+ if (exp.getKind() === SyntaxKind9.Identifier) return exp.getText();
946
+ if (exp.getKind() === SyntaxKind9.PropertyAccessExpression) {
947
+ return exp.asKind(SyntaxKind9.PropertyAccessExpression).getName();
772
948
  }
773
949
  return "";
774
950
  }
@@ -786,15 +962,15 @@ function placeholderFor(propName) {
786
962
  }
787
963
  var fixRequiredCallWithOptions = (c, p, outcome) => {
788
964
  if (outcome.result !== "fail") return void 0;
789
- const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression);
965
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind9.CallExpression);
790
966
  const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
791
967
  if (verify.length > 0) {
792
968
  const call = verify[0];
793
969
  const args = call.getArguments();
794
970
  const lastArg = args[args.length - 1];
795
- const obj = lastArg?.asKind(SyntaxKind8.ObjectLiteralExpression);
971
+ const obj = lastArg?.asKind(SyntaxKind9.ObjectLiteralExpression);
796
972
  const presentNames = obj ? obj.getProperties().map((pr) => {
797
- const pa = pr.asKind(SyntaxKind8.PropertyAssignment) ?? pr.asKind(SyntaxKind8.ShorthandPropertyAssignment);
973
+ const pa = pr.asKind(SyntaxKind9.PropertyAssignment) ?? pr.asKind(SyntaxKind9.ShorthandPropertyAssignment);
798
974
  return pa?.getName() ?? "";
799
975
  }) : [];
800
976
  const missingGroups = p.requiredProps.filter(
@@ -1730,7 +1906,7 @@ function renderTrustGraphMd(g) {
1730
1906
  }
1731
1907
 
1732
1908
  // src/costAnalysis.ts
1733
- import { Project as Project2, SyntaxKind as SyntaxKind9 } from "ts-morph";
1909
+ import { Project as Project2, SyntaxKind as SyntaxKind10 } from "ts-morph";
1734
1910
  var LAMPORTS_PER_BYTE_YEAR = 3480;
1735
1911
  var EXEMPTION_THRESHOLD = 2;
1736
1912
  var OVERHEAD_BYTES = 128;
@@ -1811,11 +1987,11 @@ var KNOWN_FLOWS = [
1811
1987
  }
1812
1988
  ];
1813
1989
  var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
1814
- SyntaxKind9.ForStatement,
1815
- SyntaxKind9.ForOfStatement,
1816
- SyntaxKind9.ForInStatement,
1817
- SyntaxKind9.WhileStatement,
1818
- SyntaxKind9.DoStatement
1990
+ SyntaxKind10.ForStatement,
1991
+ SyntaxKind10.ForOfStatement,
1992
+ SyntaxKind10.ForInStatement,
1993
+ SyntaxKind10.WhileStatement,
1994
+ SyntaxKind10.DoStatement
1819
1995
  ]);
1820
1996
  var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
1821
1997
  function isInsideLoop(node) {
@@ -1823,12 +1999,12 @@ function isInsideLoop(node) {
1823
1999
  while (cur) {
1824
2000
  const k = cur.getKind?.();
1825
2001
  if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
1826
- return { scalable: true, note: `call is inside a ${SyntaxKind9[k]} \u2014 cost scales with loop iterations` };
2002
+ return { scalable: true, note: `call is inside a ${SyntaxKind10[k]} \u2014 cost scales with loop iterations` };
1827
2003
  }
1828
- if (k === SyntaxKind9.CallExpression) {
2004
+ if (k === SyntaxKind10.CallExpression) {
1829
2005
  const expr = cur.getExpression?.();
1830
- if (expr?.getKind?.() === SyntaxKind9.PropertyAccessExpression) {
1831
- const name = expr.asKind?.(SyntaxKind9.PropertyAccessExpression)?.getName?.();
2006
+ if (expr?.getKind?.() === SyntaxKind10.PropertyAccessExpression) {
2007
+ const name = expr.asKind?.(SyntaxKind10.PropertyAccessExpression)?.getName?.();
1832
2008
  if (name && ARRAY_METHOD_LOOPS.has(name)) {
1833
2009
  return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
1834
2010
  }
@@ -1842,7 +2018,7 @@ function detectPriorityFee(targetDir) {
1842
2018
  const project = new Project2({ skipAddingFilesFromTsConfig: true });
1843
2019
  for (const file of walk(targetDir)) {
1844
2020
  const sf = project.addSourceFileAtPath(file);
1845
- const calls = sf.getDescendantsOfKind(SyntaxKind9.CallExpression);
2021
+ const calls = sf.getDescendantsOfKind(SyntaxKind10.CallExpression);
1846
2022
  for (const ce of calls) {
1847
2023
  const expr = ce.getExpression();
1848
2024
  const text = expr.getText();
@@ -1870,13 +2046,13 @@ function detectAccountFlows(targetDir) {
1870
2046
  const importedModules = new Set(
1871
2047
  sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
1872
2048
  );
1873
- for (const ce of sf.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
2049
+ for (const ce of sf.getDescendantsOfKind(SyntaxKind10.CallExpression)) {
1874
2050
  const expr = ce.getExpression();
1875
2051
  let callName5 = null;
1876
- if (expr.getKind() === SyntaxKind9.Identifier) {
2052
+ if (expr.getKind() === SyntaxKind10.Identifier) {
1877
2053
  callName5 = expr.getText();
1878
- } else if (expr.getKind() === SyntaxKind9.PropertyAccessExpression) {
1879
- callName5 = expr.asKind(SyntaxKind9.PropertyAccessExpression).getName();
2054
+ } else if (expr.getKind() === SyntaxKind10.PropertyAccessExpression) {
2055
+ callName5 = expr.asKind(SyntaxKind10.PropertyAccessExpression).getName();
1880
2056
  }
1881
2057
  if (!callName5) continue;
1882
2058
  const known = callIndex.get(callName5);
@@ -2029,6 +2205,34 @@ function startWatch(targetDir, opts = {}) {
2029
2205
  };
2030
2206
  }
2031
2207
 
2208
+ // src/fixers/applyDiff.ts
2209
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
2210
+ function parseDiff(diff) {
2211
+ const lines = diff.split("\n");
2212
+ const fileLine = lines.find((l) => l.startsWith("+++ b"));
2213
+ if (!fileLine) throw new Error("parseDiff: no '+++ b<path>' line found");
2214
+ const filePath = fileLine.slice("+++ b".length);
2215
+ const hunkLine = lines.find((l) => l.startsWith("@@"));
2216
+ if (!hunkLine) throw new Error("parseDiff: no hunk header found");
2217
+ const m = hunkLine.match(/^@@ -(\d+),(\d+) \+\d+,\d+ @@/);
2218
+ if (!m) throw new Error(`parseDiff: unrecognized hunk header '${hunkLine}'`);
2219
+ const oldStart = Number(m[1]);
2220
+ const oldCount = Number(m[2]);
2221
+ const newLines = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1));
2222
+ return { filePath, oldStart, oldCount, newLines };
2223
+ }
2224
+ function applyDiffToFile(diff) {
2225
+ const { filePath, oldStart, oldCount, newLines } = parseDiff(diff);
2226
+ const content = readFileSync7(filePath, "utf8");
2227
+ const fileLines = content.split("\n");
2228
+ const removedLines = diff.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---")).map((l) => l.slice(1));
2229
+ const actual = fileLines.slice(oldStart - 1, oldStart - 1 + oldCount);
2230
+ if (JSON.stringify(actual) !== JSON.stringify(removedLines)) return false;
2231
+ fileLines.splice(oldStart - 1, oldCount, ...newLines);
2232
+ writeFileSync2(filePath, fileLines.join("\n"));
2233
+ return true;
2234
+ }
2235
+
2032
2236
  export {
2033
2237
  findCandidates,
2034
2238
  findConfigCandidates,
@@ -2065,5 +2269,7 @@ export {
2065
2269
  analyzeCosts,
2066
2270
  renderCostReportMd,
2067
2271
  runIncrementalScan,
2068
- startWatch
2272
+ startWatch,
2273
+ parseDiff,
2274
+ applyDiffToFile
2069
2275
  };
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  analyzeCosts,
4
+ applyDiffToFile,
4
5
  audit,
5
6
  buildTrustGraph,
6
7
  cacheSize,
@@ -8,11 +9,12 @@ import {
8
9
  getChangedRanges,
9
10
  isValidSolanaAddress,
10
11
  loadProgramCache,
12
+ parseDiff,
11
13
  renderCostReportMd,
12
14
  renderTrustGraphMd,
13
15
  resolveRules,
14
16
  startWatch
15
- } from "./chunk-P7K7NRVN.js";
17
+ } from "./chunk-XQUQOBXZ.js";
16
18
 
17
19
  // src/cli.ts
18
20
  import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
@@ -90,6 +92,7 @@ function updateMemory(memory, checks2, now = /* @__PURE__ */ new Date()) {
90
92
  }
91
93
 
92
94
  // src/cli.ts
95
+ import { execFileSync } from "child_process";
93
96
  var args = process.argv.slice(2);
94
97
  if (args[0] === "trust-graph") {
95
98
  await runTrustGraph(args.slice(1));
@@ -103,6 +106,10 @@ if (args[0] === "watch") {
103
106
  await new Promise(() => {
104
107
  });
105
108
  }
109
+ if (args[0] === "fix") {
110
+ await runFix(args.slice(1));
111
+ process.exit(0);
112
+ }
106
113
  var ci = args.includes("--ci");
107
114
  var strict = args.includes("--strict");
108
115
  var sinceIdx = args.indexOf("--since");
@@ -237,3 +244,78 @@ async function runTrustGraph(argv) {
237
244
  console.error(` program-cache: ${count} entries (${cp})`);
238
245
  }
239
246
  }
247
+ async function runFix(argv) {
248
+ const apply = argv.includes("--apply");
249
+ const branch = argv.includes("--branch");
250
+ const targetDir2 = argv.find((a) => !a.startsWith("--")) ?? process.cwd();
251
+ const rules2 = resolveRules(targetDir2);
252
+ const { checks: before } = audit(targetDir2, rules2);
253
+ const fixable = before.filter((c) => c.result === "fail" && c.fix?.diff);
254
+ if (fixable.length === 0) {
255
+ console.log("brainblast fix: no mechanical fixes available (no FAIL ships a fix.diff).");
256
+ const others = before.filter((c) => c.result === "fail" && c.fix?.suggestion);
257
+ for (const c of others) {
258
+ console.log(` [GUIDANCE] ${c.ruleId} ${c.file}:${c.line}`);
259
+ console.log(` ${c.fix.summary}`);
260
+ }
261
+ return;
262
+ }
263
+ console.log(`brainblast fix: ${fixable.length} mechanical fix(es) found.`);
264
+ for (const c of fixable) {
265
+ console.log(` [${apply ? "APPLY" : "DRY-RUN"}] ${c.ruleId} ${c.file}:${c.line} \u2014 ${c.fix.summary}`);
266
+ }
267
+ if (!apply) {
268
+ console.log("\nRe-run with --apply to write these changes to disk.");
269
+ return;
270
+ }
271
+ const byFile = /* @__PURE__ */ new Map();
272
+ for (const c of fixable) {
273
+ const file = parseDiff(c.fix.diff).filePath;
274
+ byFile.set(file, [...byFile.get(file) ?? [], c]);
275
+ }
276
+ let applied = 0;
277
+ let skipped = 0;
278
+ for (const [, group] of byFile) {
279
+ const sorted = [...group].sort((a, b) => parseDiff(b.fix.diff).oldStart - parseDiff(a.fix.diff).oldStart);
280
+ for (const c of sorted) {
281
+ const ok = applyDiffToFile(c.fix.diff);
282
+ if (ok) applied++;
283
+ else {
284
+ skipped++;
285
+ console.log(` [SKIP] ${c.ruleId} ${c.file}:${c.line} \u2014 file no longer matches the fix's expected range`);
286
+ }
287
+ }
288
+ }
289
+ console.log(`
290
+ Applied ${applied} fix(es)${skipped ? `, skipped ${skipped}` : ""}.`);
291
+ const { checks: after } = audit(targetDir2, rules2);
292
+ const stillFailing = fixable.filter((c) => {
293
+ const a = after.find((x) => x.ruleId === c.ruleId && x.file === c.file && x.exportName === c.exportName);
294
+ return a?.result === "fail";
295
+ });
296
+ if (stillFailing.length > 0) {
297
+ console.log(`
298
+ Warning: ${stillFailing.length} fix(es) applied but the rule still fails:`);
299
+ for (const c of stillFailing) console.log(` ${c.ruleId} ${c.file}:${c.line}`);
300
+ } else if (applied > 0) {
301
+ console.log("All applied fixes now pass (or cant_tell) on re-audit. \u2713");
302
+ }
303
+ if (branch && applied > 0) {
304
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
305
+ const branchName = `brainblast/auto-fix-${ts}`;
306
+ try {
307
+ execFileSync("git", ["checkout", "-b", branchName], { cwd: targetDir2, stdio: "ignore" });
308
+ execFileSync("git", ["add", "-A"], { cwd: targetDir2, stdio: "ignore" });
309
+ execFileSync(
310
+ "git",
311
+ ["commit", "-q", "-m", `brainblast fix: apply ${applied} mechanical fix(es)`],
312
+ { cwd: targetDir2, stdio: "ignore" }
313
+ );
314
+ console.log(`
315
+ Committed to new branch '${branchName}'.`);
316
+ } catch (e) {
317
+ console.error(`
318
+ Warning: could not create branch/commit: ${e.message ?? e}`);
319
+ }
320
+ }
321
+ }
package/dist/index.d.ts CHANGED
@@ -276,6 +276,15 @@ declare function startWatch(targetDir: string, opts?: WatchOptions): {
276
276
  close: () => void;
277
277
  };
278
278
 
279
+ interface ParsedDiff {
280
+ filePath: string;
281
+ oldStart: number;
282
+ oldCount: number;
283
+ newLines: string[];
284
+ }
285
+ declare function parseDiff(diff: string): ParsedDiff;
286
+ declare function applyDiffToFile(diff: string): boolean;
287
+
279
288
  type UpgradeAuthorityKind = "renounced" | "single-key" | "multisig" | "dao" | "unknown";
280
289
  type UpgradeAuthoritySource = "directory" | "rpc" | "research";
281
290
  interface UpgradeAuthority {
@@ -405,4 +414,4 @@ declare function isEntryExpired(entry: ProgramCacheEntry, ttlHoursOverride?: num
405
414
  */
406
415
  declare function cacheSize(cache: ProgramCache, ttlHoursOverride?: number): number;
407
416
 
408
- export { type AccountFlow, type AuditRef, type BuildOpts, type Candidate, type ChangedRanges, type CheckOutcome, type CheckResult, type CheckResultKind, type Checker, type ConfigCandidate, type ConfigChecker, type CostReport, DEFAULT_TTL_HOURS, type OnChainProgram, type ParityNote, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type RustChecker, type Severity, type TrustGraph, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, type WatchEvent, type WatchOptions, analyzeCosts, audit, auditWithRule, base58Decode, base58Encode, buildTrustGraph, rules as bundledRules, cacheSize, checkerKinds, defaultCachePath, fileChanged, findCandidates, findConfigCandidates, generateTestForResult, getCacheEntry, getCacheEntryMeta, getChangedRanges, getWorkingTreeChanges, isEntryExpired, isValidSolanaAddress, lamportsToSol, loadDirectory, loadProgramCache, loadRules, putCacheEntry, rangeChanged, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, runIncrementalScan, saveProgramCache, startWatch, testKinds };
417
+ export { type AccountFlow, type AuditRef, type BuildOpts, type Candidate, type ChangedRanges, type CheckOutcome, type CheckResult, type CheckResultKind, type Checker, type ConfigCandidate, type ConfigChecker, type CostReport, DEFAULT_TTL_HOURS, type OnChainProgram, type ParityNote, type ParsedDiff, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type RustChecker, type Severity, type TrustGraph, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, type WatchEvent, type WatchOptions, analyzeCosts, applyDiffToFile, audit, auditWithRule, base58Decode, base58Encode, buildTrustGraph, rules as bundledRules, cacheSize, checkerKinds, defaultCachePath, fileChanged, findCandidates, findConfigCandidates, generateTestForResult, getCacheEntry, getCacheEntryMeta, getChangedRanges, getWorkingTreeChanges, isEntryExpired, isValidSolanaAddress, lamportsToSol, loadDirectory, loadProgramCache, loadRules, parseDiff, putCacheEntry, rangeChanged, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, runIncrementalScan, saveProgramCache, startWatch, testKinds };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DEFAULT_TTL_HOURS,
3
3
  analyzeCosts,
4
+ applyDiffToFile,
4
5
  audit,
5
6
  auditWithRule,
6
7
  base58Decode,
@@ -22,6 +23,7 @@ import {
22
23
  loadDirectory,
23
24
  loadProgramCache,
24
25
  loadRules,
26
+ parseDiff,
25
27
  putCacheEntry,
26
28
  rangeChanged,
27
29
  renderCostReportMd,
@@ -35,7 +37,7 @@ import {
35
37
  saveProgramCache,
36
38
  startWatch,
37
39
  testKinds
38
- } from "./chunk-P7K7NRVN.js";
40
+ } from "./chunk-XQUQOBXZ.js";
39
41
 
40
42
  // src/generate.ts
41
43
  import { writeFileSync, mkdirSync } from "fs";
@@ -53,6 +55,7 @@ function generateTestForResult(result, rule, outPath) {
53
55
  export {
54
56
  DEFAULT_TTL_HOURS,
55
57
  analyzeCosts,
58
+ applyDiffToFile,
56
59
  audit,
57
60
  auditWithRule,
58
61
  base58Decode,
@@ -76,6 +79,7 @@ export {
76
79
  loadDirectory,
77
80
  loadProgramCache,
78
81
  loadRules,
82
+ parseDiff,
79
83
  putCacheEntry,
80
84
  rangeChanged,
81
85
  renderCostReportMd,
@@ -0,0 +1,47 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: env-secret-leaked-to-sink
3
+ severity: high
4
+ title: Secret-shaped environment value flows to a logging/response sink
5
+ component:
6
+ name: Environment configuration
7
+ type: Config
8
+ version: unversioned
9
+ sourceUrl: https://12factor.net/config
10
+ detect:
11
+ lang: typescript
12
+ modules: []
13
+ # Never matches by name alone — only the triggerCalls (sink calls) below
14
+ # select candidates. Keeps this from firing on every function in a repo.
15
+ nameRegex: "(?!)"
16
+ triggerCalls:
17
+ - log
18
+ - error
19
+ - warn
20
+ - info
21
+ - debug
22
+ - json
23
+ - send
24
+ - write
25
+ - end
26
+ requiresImport: false
27
+ check:
28
+ kind: taint-to-sink
29
+ params:
30
+ sources:
31
+ - name: env-secret
32
+ # Secret-shaped process.env.X reads.
33
+ pattern: "process\\.env\\.[A-Za-z0-9_]*(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)[A-Za-z0-9_]*"
34
+ sinkCalls:
35
+ - log
36
+ - error
37
+ - warn
38
+ - info
39
+ - debug
40
+ - json
41
+ - send
42
+ - write
43
+ - end
44
+ # Same-file or imported-module hops to follow before/after the candidate.
45
+ maxHops: 2
46
+ test:
47
+ kind: none
@@ -0,0 +1,42 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: request-input-command-injection
3
+ severity: critical
4
+ title: Untrusted request input flows into a shell command
5
+ component:
6
+ name: Node.js child_process
7
+ type: API
8
+ version: unversioned
9
+ sourceUrl: https://nodejs.org/api/child_process.html
10
+ detect:
11
+ lang: typescript
12
+ modules: []
13
+ # Never matches by name alone — only the triggerCalls (sink calls) below
14
+ # select candidates.
15
+ nameRegex: "(?!)"
16
+ triggerCalls:
17
+ - exec
18
+ - execSync
19
+ - spawn
20
+ - spawnSync
21
+ - execFile
22
+ - execFileSync
23
+ requiresImport: false
24
+ check:
25
+ kind: taint-to-sink
26
+ params:
27
+ sources:
28
+ - name: request-input
29
+ # req.body / req.query / req.params / req.headers (any object named
30
+ # req/request) — the canonical "untrusted user input" surface for an
31
+ # HTTP handler.
32
+ pattern: "\\b(req|request)\\.(body|query|params|headers)\\b"
33
+ sinkCalls:
34
+ - exec
35
+ - execSync
36
+ - spawn
37
+ - spawnSync
38
+ - execFile
39
+ - execFileSync
40
+ maxHops: 2
41
+ test:
42
+ kind: none
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainblast",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Deterministic auditor for catastrophic AI-integration bugs: scan a repo, find the silent money/auth traps, and generate the behavioral test that proves they're fixed.",
6
6
  "keywords": [