brainblast 0.4.1 → 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 +20 -1
- package/dist/{chunk-P7K7NRVN.js → chunk-Q72MTJXQ.js} +149 -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 +43 -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,7 @@ 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 (directly, via a local variable, or one hop through a same-file helper) is passed to `console.log`/`res.json`/`res.send`/etc. | Credentials end up in logs, error trackers, or API responses — readable by anyone with log/response access |
|
|
84
103
|
|
|
85
104
|
Each finding lands in `.agent-research/report.json` (stable `schemaVersion: "1.0"`)
|
|
86
105
|
with a `checks[]` array a CI gate can read. Each confirmed FAIL ships a
|
|
@@ -164,7 +183,7 @@ All types are exported: `Rule`, `CheckResult`, `CostReport`, `AccountFlow`,
|
|
|
164
183
|
|
|
165
184
|
```sh
|
|
166
185
|
npm install
|
|
167
|
-
npm test # unit suite (
|
|
186
|
+
npm test # unit suite (173 tests)
|
|
168
187
|
npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
|
|
169
188
|
npm run build # produce dist/ (the published artifact)
|
|
170
189
|
```
|
|
@@ -487,6 +487,96 @@ 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/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
|
+
|
|
490
580
|
// src/checkers/index.ts
|
|
491
581
|
var registry = {
|
|
492
582
|
"positional-arg-identity": positionalArgIdentity,
|
|
@@ -495,7 +585,8 @@ var registry = {
|
|
|
495
585
|
"arg-equals-constant-identifier": argEqualsConstantIdentifier,
|
|
496
586
|
"object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
|
|
497
587
|
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
|
|
498
|
-
"env-secrets-committed": envSecretsCommitted
|
|
588
|
+
"env-secrets-committed": envSecretsCommitted,
|
|
589
|
+
"env-taint-to-sink": envTaintToSink
|
|
499
590
|
};
|
|
500
591
|
function runChecker(kind, c, params) {
|
|
501
592
|
const fn = registry[kind];
|
|
@@ -711,7 +802,7 @@ function findRustCandidates(targetDir, rule) {
|
|
|
711
802
|
}
|
|
712
803
|
|
|
713
804
|
// src/fixers/positionalArgIdentity.ts
|
|
714
|
-
import { SyntaxKind as
|
|
805
|
+
import { SyntaxKind as SyntaxKind8 } from "ts-morph";
|
|
715
806
|
|
|
716
807
|
// src/fixers/diffUtil.ts
|
|
717
808
|
function buildDiff(node, replacement) {
|
|
@@ -736,9 +827,9 @@ function buildDiff(node, replacement) {
|
|
|
736
827
|
// src/fixers/positionalArgIdentity.ts
|
|
737
828
|
var fixPositionalArgIdentity = (c, p, outcome) => {
|
|
738
829
|
if (outcome.result !== "fail") return void 0;
|
|
739
|
-
const calls = c.fn.getDescendantsOfKind(
|
|
830
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression).filter((call) => {
|
|
740
831
|
const exp = call.getExpression();
|
|
741
|
-
return exp.getKind() ===
|
|
832
|
+
return exp.getKind() === SyntaxKind8.PropertyAccessExpression && exp.asKind(SyntaxKind8.PropertyAccessExpression).getName() === p.call;
|
|
742
833
|
});
|
|
743
834
|
if (calls.length === 0) {
|
|
744
835
|
const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
|
|
@@ -753,7 +844,7 @@ Do not call JSON.parse() on the body before this verification step.`
|
|
|
753
844
|
}
|
|
754
845
|
const arg = calls[0].getArguments()[p.argIndex];
|
|
755
846
|
const wantParam = c.params[p.paramIndex];
|
|
756
|
-
if (arg && wantParam && arg.getKind() ===
|
|
847
|
+
if (arg && wantParam && arg.getKind() === SyntaxKind8.CallExpression) {
|
|
757
848
|
return {
|
|
758
849
|
summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
|
|
759
850
|
diff: buildDiff(arg, wantParam)
|
|
@@ -763,12 +854,12 @@ Do not call JSON.parse() on the body before this verification step.`
|
|
|
763
854
|
};
|
|
764
855
|
|
|
765
856
|
// src/fixers/requiredCallWithOptions.ts
|
|
766
|
-
import { SyntaxKind as
|
|
857
|
+
import { SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
767
858
|
function callName4(call) {
|
|
768
859
|
const exp = call.getExpression();
|
|
769
|
-
if (exp.getKind() ===
|
|
770
|
-
if (exp.getKind() ===
|
|
771
|
-
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();
|
|
772
863
|
}
|
|
773
864
|
return "";
|
|
774
865
|
}
|
|
@@ -786,15 +877,15 @@ function placeholderFor(propName) {
|
|
|
786
877
|
}
|
|
787
878
|
var fixRequiredCallWithOptions = (c, p, outcome) => {
|
|
788
879
|
if (outcome.result !== "fail") return void 0;
|
|
789
|
-
const calls = c.fn.getDescendantsOfKind(
|
|
880
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind9.CallExpression);
|
|
790
881
|
const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
|
|
791
882
|
if (verify.length > 0) {
|
|
792
883
|
const call = verify[0];
|
|
793
884
|
const args = call.getArguments();
|
|
794
885
|
const lastArg = args[args.length - 1];
|
|
795
|
-
const obj = lastArg?.asKind(
|
|
886
|
+
const obj = lastArg?.asKind(SyntaxKind9.ObjectLiteralExpression);
|
|
796
887
|
const presentNames = obj ? obj.getProperties().map((pr) => {
|
|
797
|
-
const pa = pr.asKind(
|
|
888
|
+
const pa = pr.asKind(SyntaxKind9.PropertyAssignment) ?? pr.asKind(SyntaxKind9.ShorthandPropertyAssignment);
|
|
798
889
|
return pa?.getName() ?? "";
|
|
799
890
|
}) : [];
|
|
800
891
|
const missingGroups = p.requiredProps.filter(
|
|
@@ -1730,7 +1821,7 @@ function renderTrustGraphMd(g) {
|
|
|
1730
1821
|
}
|
|
1731
1822
|
|
|
1732
1823
|
// src/costAnalysis.ts
|
|
1733
|
-
import { Project as Project2, SyntaxKind as
|
|
1824
|
+
import { Project as Project2, SyntaxKind as SyntaxKind10 } from "ts-morph";
|
|
1734
1825
|
var LAMPORTS_PER_BYTE_YEAR = 3480;
|
|
1735
1826
|
var EXEMPTION_THRESHOLD = 2;
|
|
1736
1827
|
var OVERHEAD_BYTES = 128;
|
|
@@ -1811,11 +1902,11 @@ var KNOWN_FLOWS = [
|
|
|
1811
1902
|
}
|
|
1812
1903
|
];
|
|
1813
1904
|
var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1905
|
+
SyntaxKind10.ForStatement,
|
|
1906
|
+
SyntaxKind10.ForOfStatement,
|
|
1907
|
+
SyntaxKind10.ForInStatement,
|
|
1908
|
+
SyntaxKind10.WhileStatement,
|
|
1909
|
+
SyntaxKind10.DoStatement
|
|
1819
1910
|
]);
|
|
1820
1911
|
var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
|
|
1821
1912
|
function isInsideLoop(node) {
|
|
@@ -1823,12 +1914,12 @@ function isInsideLoop(node) {
|
|
|
1823
1914
|
while (cur) {
|
|
1824
1915
|
const k = cur.getKind?.();
|
|
1825
1916
|
if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
|
|
1826
|
-
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` };
|
|
1827
1918
|
}
|
|
1828
|
-
if (k ===
|
|
1919
|
+
if (k === SyntaxKind10.CallExpression) {
|
|
1829
1920
|
const expr = cur.getExpression?.();
|
|
1830
|
-
if (expr?.getKind?.() ===
|
|
1831
|
-
const name = expr.asKind?.(
|
|
1921
|
+
if (expr?.getKind?.() === SyntaxKind10.PropertyAccessExpression) {
|
|
1922
|
+
const name = expr.asKind?.(SyntaxKind10.PropertyAccessExpression)?.getName?.();
|
|
1832
1923
|
if (name && ARRAY_METHOD_LOOPS.has(name)) {
|
|
1833
1924
|
return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
|
|
1834
1925
|
}
|
|
@@ -1842,7 +1933,7 @@ function detectPriorityFee(targetDir) {
|
|
|
1842
1933
|
const project = new Project2({ skipAddingFilesFromTsConfig: true });
|
|
1843
1934
|
for (const file of walk(targetDir)) {
|
|
1844
1935
|
const sf = project.addSourceFileAtPath(file);
|
|
1845
|
-
const calls = sf.getDescendantsOfKind(
|
|
1936
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind10.CallExpression);
|
|
1846
1937
|
for (const ce of calls) {
|
|
1847
1938
|
const expr = ce.getExpression();
|
|
1848
1939
|
const text = expr.getText();
|
|
@@ -1870,13 +1961,13 @@ function detectAccountFlows(targetDir) {
|
|
|
1870
1961
|
const importedModules = new Set(
|
|
1871
1962
|
sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
|
|
1872
1963
|
);
|
|
1873
|
-
for (const ce of sf.getDescendantsOfKind(
|
|
1964
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind10.CallExpression)) {
|
|
1874
1965
|
const expr = ce.getExpression();
|
|
1875
1966
|
let callName5 = null;
|
|
1876
|
-
if (expr.getKind() ===
|
|
1967
|
+
if (expr.getKind() === SyntaxKind10.Identifier) {
|
|
1877
1968
|
callName5 = expr.getText();
|
|
1878
|
-
} else if (expr.getKind() ===
|
|
1879
|
-
callName5 = expr.asKind(
|
|
1969
|
+
} else if (expr.getKind() === SyntaxKind10.PropertyAccessExpression) {
|
|
1970
|
+
callName5 = expr.asKind(SyntaxKind10.PropertyAccessExpression).getName();
|
|
1880
1971
|
}
|
|
1881
1972
|
if (!callName5) continue;
|
|
1882
1973
|
const known = callIndex.get(callName5);
|
|
@@ -2029,6 +2120,34 @@ function startWatch(targetDir, opts = {}) {
|
|
|
2029
2120
|
};
|
|
2030
2121
|
}
|
|
2031
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
|
+
|
|
2032
2151
|
export {
|
|
2033
2152
|
findCandidates,
|
|
2034
2153
|
findConfigCandidates,
|
|
@@ -2065,5 +2184,7 @@ export {
|
|
|
2065
2184
|
analyzeCosts,
|
|
2066
2185
|
renderCostReportMd,
|
|
2067
2186
|
runIncrementalScan,
|
|
2068
|
-
startWatch
|
|
2187
|
+
startWatch,
|
|
2188
|
+
parseDiff,
|
|
2189
|
+
applyDiffToFile
|
|
2069
2190
|
};
|
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-Q72MTJXQ.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-Q72MTJXQ.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,43 @@
|
|
|
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: env-taint-to-sink
|
|
29
|
+
params:
|
|
30
|
+
sinkCalls:
|
|
31
|
+
- log
|
|
32
|
+
- error
|
|
33
|
+
- warn
|
|
34
|
+
- info
|
|
35
|
+
- debug
|
|
36
|
+
- json
|
|
37
|
+
- send
|
|
38
|
+
- write
|
|
39
|
+
- end
|
|
40
|
+
# Key names that typically hold credentials/secrets.
|
|
41
|
+
secretKeyPattern: "(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)"
|
|
42
|
+
test:
|
|
43
|
+
kind: none
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainblast",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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": [
|