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 +21 -1
- package/dist/{chunk-P7K7NRVN.js → chunk-XQUQOBXZ.js} +234 -28
- package/dist/cli.js +83 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.js +5 -1
- package/dist/rules/env-secret-leaked-to-sink.yaml +47 -0
- package/dist/rules/request-input-command-injection.yaml +42 -0
- package/package.json +1 -1
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 (
|
|
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
|
|
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(
|
|
915
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression).filter((call) => {
|
|
740
916
|
const exp = call.getExpression();
|
|
741
|
-
return exp.getKind() ===
|
|
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() ===
|
|
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
|
|
942
|
+
import { SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
767
943
|
function callName4(call) {
|
|
768
944
|
const exp = call.getExpression();
|
|
769
|
-
if (exp.getKind() ===
|
|
770
|
-
if (exp.getKind() ===
|
|
771
|
-
return exp.asKind(
|
|
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(
|
|
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(
|
|
971
|
+
const obj = lastArg?.asKind(SyntaxKind9.ObjectLiteralExpression);
|
|
796
972
|
const presentNames = obj ? obj.getProperties().map((pr) => {
|
|
797
|
-
const pa = pr.asKind(
|
|
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
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
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 ${
|
|
2002
|
+
return { scalable: true, note: `call is inside a ${SyntaxKind10[k]} \u2014 cost scales with loop iterations` };
|
|
1827
2003
|
}
|
|
1828
|
-
if (k ===
|
|
2004
|
+
if (k === SyntaxKind10.CallExpression) {
|
|
1829
2005
|
const expr = cur.getExpression?.();
|
|
1830
|
-
if (expr?.getKind?.() ===
|
|
1831
|
-
const name = expr.asKind?.(
|
|
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(
|
|
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(
|
|
2049
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind10.CallExpression)) {
|
|
1874
2050
|
const expr = ce.getExpression();
|
|
1875
2051
|
let callName5 = null;
|
|
1876
|
-
if (expr.getKind() ===
|
|
2052
|
+
if (expr.getKind() === SyntaxKind10.Identifier) {
|
|
1877
2053
|
callName5 = expr.getText();
|
|
1878
|
-
} else if (expr.getKind() ===
|
|
1879
|
-
callName5 = expr.asKind(
|
|
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-
|
|
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-
|
|
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.
|
|
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": [
|