brainblast 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/{chunk-Q72MTJXQ.js → chunk-5LJXC66F.js} +369 -71
- package/dist/cli.js +103 -4
- package/dist/index.d.ts +83 -2
- package/dist/index.js +29 -3
- package/dist/rules/env-secret-leaked-to-sink.yaml +7 -3
- package/dist/rules/request-input-command-injection.yaml +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,7 +99,8 @@ the applied changes.
|
|
|
99
99
|
| Rule | What's wrong | Consequence |
|
|
100
100
|
|------|--------------|-------------|
|
|
101
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
|
|
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 |
|
|
103
104
|
|
|
104
105
|
Each finding lands in `.agent-research/report.json` (stable `schemaVersion: "1.0"`)
|
|
105
106
|
with a `checks[]` array a CI gate can read. Each confirmed FAIL ships a
|
|
@@ -183,7 +184,7 @@ All types are exported: `Rule`, `CheckResult`, `CostReport`, `AccountFlow`,
|
|
|
183
184
|
|
|
184
185
|
```sh
|
|
185
186
|
npm install
|
|
186
|
-
npm test # unit suite (
|
|
187
|
+
npm test # unit suite (214 tests)
|
|
187
188
|
npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
|
|
188
189
|
npm run build # produce dist/ (the published artifact)
|
|
189
190
|
```
|
|
@@ -487,7 +487,7 @@ 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/
|
|
490
|
+
// src/checkers/taintToSink.ts
|
|
491
491
|
import { SyntaxKind as SyntaxKind7 } from "ts-morph";
|
|
492
492
|
function calleeName(call) {
|
|
493
493
|
const exp = call.getExpression();
|
|
@@ -497,59 +497,65 @@ function calleeName(call) {
|
|
|
497
497
|
}
|
|
498
498
|
return "";
|
|
499
499
|
}
|
|
500
|
-
function
|
|
501
|
-
const
|
|
502
|
-
return
|
|
500
|
+
function calleeIdentifierName(call) {
|
|
501
|
+
const exp = call.getExpression();
|
|
502
|
+
return exp.getKind() === SyntaxKind7.Identifier ? exp.getText() : void 0;
|
|
503
503
|
}
|
|
504
504
|
function wordIn(text, name) {
|
|
505
505
|
return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text);
|
|
506
506
|
}
|
|
507
|
-
function
|
|
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) {
|
|
508
519
|
for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
|
|
509
520
|
const name = calleeName(call);
|
|
510
521
|
if (!sinkCalls.has(name)) continue;
|
|
511
522
|
for (const arg of call.getArguments()) {
|
|
512
523
|
const text = arg.getText();
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return `process.env.${envVar} is passed directly to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
|
|
524
|
+
if (matchesSource(text, sourceRes)) {
|
|
525
|
+
return `'${text}' is passed directly to ${name}(...) \u2014 tainted values must not reach this sink.`;
|
|
516
526
|
}
|
|
517
527
|
for (const tv of taintedNames) {
|
|
518
528
|
if (wordIn(text, tv)) {
|
|
519
|
-
return `'${tv}' (
|
|
529
|
+
return `'${tv}' (a tainted value) is passed to ${name}(...) \u2014 tainted values must not reach this sink.`;
|
|
520
530
|
}
|
|
521
531
|
}
|
|
522
532
|
}
|
|
523
533
|
}
|
|
524
534
|
return void 0;
|
|
525
535
|
}
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
if (!
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
taintedNames.add(decl.getName());
|
|
537
|
-
}
|
|
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 };
|
|
538
546
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
547
|
+
return void 0;
|
|
548
|
+
}
|
|
549
|
+
function findForwardLeak(fn, rootName, sinkCalls, sourceRes, taintedNames, hopsLeft, visited) {
|
|
550
|
+
if (hopsLeft <= 0) return void 0;
|
|
542
551
|
for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
|
|
543
|
-
const
|
|
544
|
-
if (
|
|
545
|
-
const calleeFnName = calleeExp.getText();
|
|
546
|
-
if (calleeFnName === (c.fnName ?? "")) continue;
|
|
552
|
+
const name = calleeIdentifierName(call);
|
|
553
|
+
if (!name || name === rootName) continue;
|
|
547
554
|
const args = call.getArguments();
|
|
548
555
|
const taintedArgIndices = [];
|
|
549
556
|
args.forEach((arg, i) => {
|
|
550
557
|
const text = arg.getText();
|
|
551
|
-
|
|
552
|
-
if (envVar && secretKeyRe.test(envVar)) {
|
|
558
|
+
if (matchesSource(text, sourceRes)) {
|
|
553
559
|
taintedArgIndices.push(i);
|
|
554
560
|
return;
|
|
555
561
|
}
|
|
@@ -561,20 +567,99 @@ var envTaintToSink = (c, p) => {
|
|
|
561
567
|
}
|
|
562
568
|
});
|
|
563
569
|
if (taintedArgIndices.length === 0) continue;
|
|
564
|
-
const
|
|
565
|
-
if (!
|
|
566
|
-
const
|
|
567
|
-
|
|
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
|
+
);
|
|
568
579
|
if (calleeTainted.size === 0) continue;
|
|
569
|
-
const
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
+
}
|
|
575
634
|
}
|
|
576
635
|
}
|
|
577
|
-
return
|
|
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." };
|
|
578
663
|
};
|
|
579
664
|
|
|
580
665
|
// src/checkers/index.ts
|
|
@@ -586,7 +671,7 @@ var registry = {
|
|
|
586
671
|
"object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
|
|
587
672
|
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
|
|
588
673
|
"env-secrets-committed": envSecretsCommitted,
|
|
589
|
-
"
|
|
674
|
+
"taint-to-sink": taintToSink
|
|
590
675
|
};
|
|
591
676
|
function runChecker(kind, c, params) {
|
|
592
677
|
const fn = registry[kind];
|
|
@@ -1401,63 +1486,113 @@ function loadRules(dir) {
|
|
|
1401
1486
|
return rules2;
|
|
1402
1487
|
}
|
|
1403
1488
|
|
|
1489
|
+
// src/packs.ts
|
|
1490
|
+
import { existsSync, readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
|
|
1491
|
+
import { join as join5 } from "path";
|
|
1492
|
+
import { parse as parse2 } from "yaml";
|
|
1493
|
+
var PACK_MANIFEST_FILE = "brainblast-pack.yaml";
|
|
1494
|
+
function validatePackManifest(m, file) {
|
|
1495
|
+
const errs = [];
|
|
1496
|
+
if (!m || typeof m !== "object") {
|
|
1497
|
+
throw new Error(`invalid pack manifest in ${file}: not a mapping`);
|
|
1498
|
+
}
|
|
1499
|
+
if (!m.id || typeof m.id !== "string") errs.push("missing id");
|
|
1500
|
+
if (!m.name || typeof m.name !== "string") errs.push("missing name");
|
|
1501
|
+
if (!m.version || typeof m.version !== "string") errs.push("missing version");
|
|
1502
|
+
if (!m.author || typeof m.author !== "string") errs.push("missing author");
|
|
1503
|
+
if (errs.length) throw new Error(`invalid pack manifest in ${file}: ${errs.join("; ")}`);
|
|
1504
|
+
}
|
|
1505
|
+
function loadPack(dir) {
|
|
1506
|
+
const manifestPath = join5(dir, PACK_MANIFEST_FILE);
|
|
1507
|
+
const raw = parse2(readFileSync5(manifestPath, "utf8"));
|
|
1508
|
+
validatePackManifest(raw, manifestPath);
|
|
1509
|
+
const manifest = raw;
|
|
1510
|
+
const rulesDir = join5(dir, "rules");
|
|
1511
|
+
const rules2 = existsSync(rulesDir) ? loadRules(rulesDir).map((r) => ({
|
|
1512
|
+
...r,
|
|
1513
|
+
pack: { id: manifest.id, version: manifest.version, author: manifest.author }
|
|
1514
|
+
})) : [];
|
|
1515
|
+
return { manifest, rules: rules2 };
|
|
1516
|
+
}
|
|
1517
|
+
function loadPacksFromDir(packsDir) {
|
|
1518
|
+
if (!existsSync(packsDir)) return [];
|
|
1519
|
+
const out = [];
|
|
1520
|
+
for (const entry of readdirSync4(packsDir).sort()) {
|
|
1521
|
+
const dir = join5(packsDir, entry);
|
|
1522
|
+
if (!statSync3(dir).isDirectory()) continue;
|
|
1523
|
+
if (!existsSync(join5(dir, PACK_MANIFEST_FILE))) continue;
|
|
1524
|
+
out.push(loadPack(dir));
|
|
1525
|
+
}
|
|
1526
|
+
return out;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1404
1529
|
// rules/index.ts
|
|
1405
|
-
import { existsSync } from "fs";
|
|
1406
|
-
import { dirname, join as
|
|
1530
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1531
|
+
import { dirname, join as join6 } from "path";
|
|
1407
1532
|
import { fileURLToPath } from "url";
|
|
1408
1533
|
function bundledRulesDir() {
|
|
1409
1534
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1410
|
-
if (
|
|
1411
|
-
const sub =
|
|
1412
|
-
if (
|
|
1535
|
+
if (existsSync2(join6(here, "stripe-webhook-raw-body.yaml"))) return here;
|
|
1536
|
+
const sub = join6(here, "rules");
|
|
1537
|
+
if (existsSync2(join6(sub, "stripe-webhook-raw-body.yaml"))) return sub;
|
|
1413
1538
|
return here;
|
|
1414
1539
|
}
|
|
1415
1540
|
var rules = loadRules(bundledRulesDir());
|
|
1416
1541
|
|
|
1417
1542
|
// src/resolveRules.ts
|
|
1418
|
-
import { existsSync as
|
|
1419
|
-
import { join as
|
|
1420
|
-
function resolveRules(targetDir) {
|
|
1543
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1544
|
+
import { join as join7 } from "path";
|
|
1545
|
+
function resolveRules(targetDir, extraPackDirs = []) {
|
|
1421
1546
|
const all = [...rules];
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
for (const r of loadRules(projDir)) {
|
|
1547
|
+
const seen = new Set(all.map((r) => r.id));
|
|
1548
|
+
const addRules = (rules2, sourceLabel) => {
|
|
1549
|
+
for (const r of rules2) {
|
|
1426
1550
|
if (seen.has(r.id)) {
|
|
1427
|
-
console.warn(`brainblast:
|
|
1551
|
+
console.warn(`brainblast: rule '${r.id}' from ${sourceLabel} shadows an existing rule; keeping the first one loaded.`);
|
|
1428
1552
|
continue;
|
|
1429
1553
|
}
|
|
1430
1554
|
all.push(r);
|
|
1431
1555
|
seen.add(r.id);
|
|
1432
1556
|
}
|
|
1557
|
+
};
|
|
1558
|
+
const projDir = join7(targetDir, ".agent-research", "rules");
|
|
1559
|
+
if (existsSync3(projDir)) {
|
|
1560
|
+
addRules(loadRules(projDir), "project rules");
|
|
1561
|
+
}
|
|
1562
|
+
for (const { manifest, rules: rules2 } of loadPacksFromDir(join7(targetDir, ".agent-research", "packs"))) {
|
|
1563
|
+
addRules(rules2, `pack '${manifest.id}'`);
|
|
1564
|
+
}
|
|
1565
|
+
for (const dir of extraPackDirs) {
|
|
1566
|
+
const { manifest, rules: rules2 } = loadPack(dir);
|
|
1567
|
+
addRules(rules2, `pack '${manifest.id}' (${dir})`);
|
|
1433
1568
|
}
|
|
1434
1569
|
return all;
|
|
1435
1570
|
}
|
|
1436
1571
|
|
|
1437
1572
|
// src/trustGraph/directory.ts
|
|
1438
|
-
import { readFileSync as
|
|
1573
|
+
import { readFileSync as readFileSync6, existsSync as existsSync4 } from "fs";
|
|
1439
1574
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1440
|
-
import { join as
|
|
1441
|
-
import { parse as
|
|
1575
|
+
import { join as join8 } from "path";
|
|
1576
|
+
import { parse as parse3 } from "yaml";
|
|
1442
1577
|
var cache = null;
|
|
1443
1578
|
function bundledPath() {
|
|
1444
1579
|
const here = fileURLToPath2(new URL(".", import.meta.url));
|
|
1445
1580
|
const candidates = [
|
|
1446
|
-
|
|
1581
|
+
join8(here, "programs", "directory.yaml"),
|
|
1447
1582
|
// dist/programs/directory.yaml
|
|
1448
|
-
|
|
1583
|
+
join8(here, "..", "..", "programs", "directory.yaml"),
|
|
1449
1584
|
// src/../../programs/
|
|
1450
|
-
|
|
1585
|
+
join8(here, "..", "programs", "directory.yaml")
|
|
1451
1586
|
// fallback
|
|
1452
1587
|
];
|
|
1453
1588
|
for (const c of candidates) {
|
|
1454
|
-
if (
|
|
1589
|
+
if (existsSync4(c)) return c;
|
|
1455
1590
|
}
|
|
1456
1591
|
return candidates[0];
|
|
1457
1592
|
}
|
|
1458
1593
|
function loadDirectory(path = bundledPath()) {
|
|
1459
1594
|
if (cache && path === bundledPath()) return cache;
|
|
1460
|
-
const raw =
|
|
1595
|
+
const raw = parse3(readFileSync6(path, "utf8"));
|
|
1461
1596
|
if (!raw || !Array.isArray(raw.programs)) {
|
|
1462
1597
|
throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
|
|
1463
1598
|
}
|
|
@@ -1528,23 +1663,23 @@ function isValidSolanaAddress(s) {
|
|
|
1528
1663
|
}
|
|
1529
1664
|
|
|
1530
1665
|
// src/trustGraph/programCache.ts
|
|
1531
|
-
import { readFileSync as
|
|
1532
|
-
import { join as
|
|
1666
|
+
import { readFileSync as readFileSync7, writeFileSync, mkdirSync, existsSync as existsSync5 } from "fs";
|
|
1667
|
+
import { join as join9, dirname as dirname2 } from "path";
|
|
1533
1668
|
import { homedir } from "os";
|
|
1534
1669
|
var DEFAULT_TTL_HOURS = 168;
|
|
1535
1670
|
var SCHEMA_VERSION = "1.0";
|
|
1536
1671
|
function defaultCachePath() {
|
|
1537
1672
|
const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
|
|
1538
|
-
return envOverride ??
|
|
1673
|
+
return envOverride ?? join9(homedir(), ".brainblast", "program-cache.json");
|
|
1539
1674
|
}
|
|
1540
1675
|
function emptyCache() {
|
|
1541
1676
|
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
1542
1677
|
}
|
|
1543
1678
|
function loadProgramCache(cachePath) {
|
|
1544
1679
|
const path = cachePath ?? defaultCachePath();
|
|
1545
|
-
if (!
|
|
1680
|
+
if (!existsSync5(path)) return emptyCache();
|
|
1546
1681
|
try {
|
|
1547
|
-
const raw = JSON.parse(
|
|
1682
|
+
const raw = JSON.parse(readFileSync7(path, "utf8"));
|
|
1548
1683
|
if (raw?.schemaVersion !== SCHEMA_VERSION) {
|
|
1549
1684
|
return emptyCache();
|
|
1550
1685
|
}
|
|
@@ -2121,7 +2256,7 @@ function startWatch(targetDir, opts = {}) {
|
|
|
2121
2256
|
}
|
|
2122
2257
|
|
|
2123
2258
|
// src/fixers/applyDiff.ts
|
|
2124
|
-
import { readFileSync as
|
|
2259
|
+
import { readFileSync as readFileSync8, writeFileSync as writeFileSync2 } from "fs";
|
|
2125
2260
|
function parseDiff(diff) {
|
|
2126
2261
|
const lines = diff.split("\n");
|
|
2127
2262
|
const fileLine = lines.find((l) => l.startsWith("+++ b"));
|
|
@@ -2138,7 +2273,7 @@ function parseDiff(diff) {
|
|
|
2138
2273
|
}
|
|
2139
2274
|
function applyDiffToFile(diff) {
|
|
2140
2275
|
const { filePath, oldStart, oldCount, newLines } = parseDiff(diff);
|
|
2141
|
-
const content =
|
|
2276
|
+
const content = readFileSync8(filePath, "utf8");
|
|
2142
2277
|
const fileLines = content.split("\n");
|
|
2143
2278
|
const removedLines = diff.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---")).map((l) => l.slice(1));
|
|
2144
2279
|
const actual = fileLines.slice(oldStart - 1, oldStart - 1 + oldCount);
|
|
@@ -2148,6 +2283,156 @@ function applyDiffToFile(diff) {
|
|
|
2148
2283
|
return true;
|
|
2149
2284
|
}
|
|
2150
2285
|
|
|
2286
|
+
// src/pack.ts
|
|
2287
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
2288
|
+
import { join as join10 } from "path";
|
|
2289
|
+
function initPack(dir, opts) {
|
|
2290
|
+
if (existsSync6(join10(dir, PACK_MANIFEST_FILE))) {
|
|
2291
|
+
throw new Error(`${dir} already contains a ${PACK_MANIFEST_FILE}`);
|
|
2292
|
+
}
|
|
2293
|
+
const manifest = {
|
|
2294
|
+
id: opts.id,
|
|
2295
|
+
name: opts.name ?? opts.id,
|
|
2296
|
+
version: opts.version ?? "0.1.0",
|
|
2297
|
+
author: opts.author ?? "unknown",
|
|
2298
|
+
...opts.description ? { description: opts.description } : {}
|
|
2299
|
+
};
|
|
2300
|
+
mkdirSync2(dir, { recursive: true });
|
|
2301
|
+
mkdirSync2(join10(dir, "rules"), { recursive: true });
|
|
2302
|
+
mkdirSync2(join10(dir, "fixtures"), { recursive: true });
|
|
2303
|
+
const manifestYaml = [
|
|
2304
|
+
`id: ${manifest.id}`,
|
|
2305
|
+
`name: ${manifest.name}`,
|
|
2306
|
+
`version: ${manifest.version}`,
|
|
2307
|
+
`author: ${manifest.author}`,
|
|
2308
|
+
...manifest.description ? [`description: ${manifest.description}`] : [],
|
|
2309
|
+
""
|
|
2310
|
+
].join("\n");
|
|
2311
|
+
const manifestFile = join10(dir, PACK_MANIFEST_FILE);
|
|
2312
|
+
writeFileSync3(manifestFile, manifestYaml, "utf8");
|
|
2313
|
+
return manifestFile;
|
|
2314
|
+
}
|
|
2315
|
+
function validatePack(dir) {
|
|
2316
|
+
const { manifest, rules: rules2 } = loadPack(dir);
|
|
2317
|
+
const fixturesRoot = join10(dir, "fixtures");
|
|
2318
|
+
const ruleResults = rules2.map((rule) => {
|
|
2319
|
+
const ruleFixturesDir = join10(fixturesRoot, rule.id);
|
|
2320
|
+
const vulnerableDir = join10(ruleFixturesDir, "vulnerable");
|
|
2321
|
+
const fixedDir = join10(ruleFixturesDir, "fixed");
|
|
2322
|
+
if (!existsSync6(vulnerableDir) || !existsSync6(fixedDir)) {
|
|
2323
|
+
return {
|
|
2324
|
+
ruleId: rule.id,
|
|
2325
|
+
status: "missing-fixtures",
|
|
2326
|
+
detail: `no fixtures/${rule.id}/{vulnerable,fixed}/ directory \u2014 prove gate skipped`
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
const redChecks = auditWithRule(vulnerableDir, rule);
|
|
2330
|
+
const redFails = redChecks.filter((c) => c.result === "fail");
|
|
2331
|
+
if (redFails.length === 0) {
|
|
2332
|
+
return {
|
|
2333
|
+
ruleId: rule.id,
|
|
2334
|
+
status: "red-failed",
|
|
2335
|
+
detail: `expected at least one FAIL against fixtures/${rule.id}/vulnerable/, got none`
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
const greenChecks = auditWithRule(fixedDir, rule);
|
|
2339
|
+
const greenFails = greenChecks.filter((c) => c.result === "fail");
|
|
2340
|
+
if (greenFails.length > 0) {
|
|
2341
|
+
return {
|
|
2342
|
+
ruleId: rule.id,
|
|
2343
|
+
status: "green-failed",
|
|
2344
|
+
detail: `expected no FAIL against fixtures/${rule.id}/fixed/, got ${greenFails.length}`
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
return { ruleId: rule.id, status: "ok", detail: "RED -> GREEN proven" };
|
|
2348
|
+
});
|
|
2349
|
+
const ok = ruleResults.every((r) => r.status === "ok" || r.status === "missing-fixtures");
|
|
2350
|
+
return { manifest, rules: rules2, ruleResults, ok };
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// src/telemetry.ts
|
|
2354
|
+
import { createHash, randomUUID } from "crypto";
|
|
2355
|
+
import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
|
|
2356
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
2357
|
+
import { homedir as homedir2 } from "os";
|
|
2358
|
+
import { dirname as dirname3, join as join11, resolve } from "path";
|
|
2359
|
+
function sha256Hex(s) {
|
|
2360
|
+
return createHash("sha256").update(s).digest("hex");
|
|
2361
|
+
}
|
|
2362
|
+
function isTelemetryEnabled(targetDir) {
|
|
2363
|
+
const env = process.env.BRAINBLAST_TELEMETRY;
|
|
2364
|
+
if (env === "1" || env === "true") return true;
|
|
2365
|
+
if (env === "0" || env === "false") return false;
|
|
2366
|
+
const configPath = join11(targetDir, ".agent-research", "config.json");
|
|
2367
|
+
if (!existsSync7(configPath)) return false;
|
|
2368
|
+
try {
|
|
2369
|
+
const cfg = JSON.parse(readFileSync9(configPath, "utf8"));
|
|
2370
|
+
return cfg?.telemetry === true;
|
|
2371
|
+
} catch {
|
|
2372
|
+
return false;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
function getUserHash() {
|
|
2376
|
+
const idPath = join11(homedir2(), ".brainblast", "telemetry-id");
|
|
2377
|
+
let id;
|
|
2378
|
+
if (existsSync7(idPath)) {
|
|
2379
|
+
id = readFileSync9(idPath, "utf8").trim();
|
|
2380
|
+
} else {
|
|
2381
|
+
id = randomUUID();
|
|
2382
|
+
mkdirSync3(dirname3(idPath), { recursive: true });
|
|
2383
|
+
writeFileSync4(idPath, id, "utf8");
|
|
2384
|
+
}
|
|
2385
|
+
return sha256Hex(id).slice(0, 16);
|
|
2386
|
+
}
|
|
2387
|
+
function getRepoHash(targetDir) {
|
|
2388
|
+
let key = "";
|
|
2389
|
+
try {
|
|
2390
|
+
key = execFileSync3("git", ["config", "--get", "remote.origin.url"], {
|
|
2391
|
+
cwd: targetDir,
|
|
2392
|
+
encoding: "utf8",
|
|
2393
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2394
|
+
}).trim();
|
|
2395
|
+
} catch {
|
|
2396
|
+
}
|
|
2397
|
+
if (!key) key = resolve(targetDir);
|
|
2398
|
+
return sha256Hex(key).slice(0, 16);
|
|
2399
|
+
}
|
|
2400
|
+
function telemetryFilePath(targetDir) {
|
|
2401
|
+
return join11(targetDir, ".agent-research", "telemetry.ndjson");
|
|
2402
|
+
}
|
|
2403
|
+
var DEFAULT_REGISTRY_URL = "https://registry.brainblast.tech";
|
|
2404
|
+
async function submitTelemetry(targetDir, registryUrl = process.env.BRAINBLAST_REGISTRY_URL || DEFAULT_REGISTRY_URL) {
|
|
2405
|
+
const file = telemetryFilePath(targetDir);
|
|
2406
|
+
if (!existsSync7(file)) {
|
|
2407
|
+
return { submitted: 0, accepted: 0, rejected: 0, graduations: [] };
|
|
2408
|
+
}
|
|
2409
|
+
const events = readFileSync9(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
2410
|
+
if (events.length === 0) {
|
|
2411
|
+
return { submitted: 0, accepted: 0, rejected: 0, graduations: [] };
|
|
2412
|
+
}
|
|
2413
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, "")}/api/telemetry`, {
|
|
2414
|
+
method: "POST",
|
|
2415
|
+
headers: { "content-type": "application/json" },
|
|
2416
|
+
body: JSON.stringify({ events })
|
|
2417
|
+
});
|
|
2418
|
+
if (!res.ok) {
|
|
2419
|
+
const body = await res.text().catch(() => "");
|
|
2420
|
+
throw new Error(`telemetry submit failed: ${res.status} ${res.statusText} ${body}`.trim());
|
|
2421
|
+
}
|
|
2422
|
+
const json = await res.json();
|
|
2423
|
+
return { submitted: events.length, ...json };
|
|
2424
|
+
}
|
|
2425
|
+
function recordGraduationEvents(targetDir, events) {
|
|
2426
|
+
if (events.length === 0) return;
|
|
2427
|
+
const file = telemetryFilePath(targetDir);
|
|
2428
|
+
mkdirSync3(dirname3(file), { recursive: true });
|
|
2429
|
+
const repo_hash = getRepoHash(targetDir);
|
|
2430
|
+
const user_hash = getUserHash();
|
|
2431
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2432
|
+
const lines = events.map((e) => JSON.stringify({ ...e, repo_hash, user_hash, timestamp })).join("\n");
|
|
2433
|
+
appendFileSync(file, lines + "\n", "utf8");
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2151
2436
|
export {
|
|
2152
2437
|
findCandidates,
|
|
2153
2438
|
findConfigCandidates,
|
|
@@ -2162,6 +2447,10 @@ export {
|
|
|
2162
2447
|
renderTest,
|
|
2163
2448
|
testKinds,
|
|
2164
2449
|
loadRules,
|
|
2450
|
+
PACK_MANIFEST_FILE,
|
|
2451
|
+
validatePackManifest,
|
|
2452
|
+
loadPack,
|
|
2453
|
+
loadPacksFromDir,
|
|
2165
2454
|
rules,
|
|
2166
2455
|
resolveRules,
|
|
2167
2456
|
loadDirectory,
|
|
@@ -2186,5 +2475,14 @@ export {
|
|
|
2186
2475
|
runIncrementalScan,
|
|
2187
2476
|
startWatch,
|
|
2188
2477
|
parseDiff,
|
|
2189
|
-
applyDiffToFile
|
|
2478
|
+
applyDiffToFile,
|
|
2479
|
+
initPack,
|
|
2480
|
+
validatePack,
|
|
2481
|
+
isTelemetryEnabled,
|
|
2482
|
+
getUserHash,
|
|
2483
|
+
getRepoHash,
|
|
2484
|
+
telemetryFilePath,
|
|
2485
|
+
DEFAULT_REGISTRY_URL,
|
|
2486
|
+
submitTelemetry,
|
|
2487
|
+
recordGraduationEvents
|
|
2190
2488
|
};
|
package/dist/cli.js
CHANGED
|
@@ -7,14 +7,20 @@ import {
|
|
|
7
7
|
cacheSize,
|
|
8
8
|
defaultCachePath,
|
|
9
9
|
getChangedRanges,
|
|
10
|
+
initPack,
|
|
11
|
+
isTelemetryEnabled,
|
|
10
12
|
isValidSolanaAddress,
|
|
11
13
|
loadProgramCache,
|
|
12
14
|
parseDiff,
|
|
15
|
+
recordGraduationEvents,
|
|
13
16
|
renderCostReportMd,
|
|
14
17
|
renderTrustGraphMd,
|
|
15
18
|
resolveRules,
|
|
16
|
-
startWatch
|
|
17
|
-
|
|
19
|
+
startWatch,
|
|
20
|
+
submitTelemetry,
|
|
21
|
+
telemetryFilePath,
|
|
22
|
+
validatePack
|
|
23
|
+
} from "./chunk-5LJXC66F.js";
|
|
18
24
|
|
|
19
25
|
// src/cli.ts
|
|
20
26
|
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
@@ -94,10 +100,25 @@ function updateMemory(memory, checks2, now = /* @__PURE__ */ new Date()) {
|
|
|
94
100
|
// src/cli.ts
|
|
95
101
|
import { execFileSync } from "child_process";
|
|
96
102
|
var args = process.argv.slice(2);
|
|
103
|
+
function parsePackDirs(argv) {
|
|
104
|
+
const idx = argv.indexOf("--packs");
|
|
105
|
+
if (idx < 0) return [];
|
|
106
|
+
const value = argv[idx + 1];
|
|
107
|
+
if (!value || value.startsWith("--")) return [];
|
|
108
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
109
|
+
}
|
|
97
110
|
if (args[0] === "trust-graph") {
|
|
98
111
|
await runTrustGraph(args.slice(1));
|
|
99
112
|
process.exit(0);
|
|
100
113
|
}
|
|
114
|
+
if (args[0] === "pack") {
|
|
115
|
+
runPack(args.slice(1));
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
if (args[0] === "telemetry") {
|
|
119
|
+
await runTelemetry(args.slice(1));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
101
122
|
if (args[0] === "watch") {
|
|
102
123
|
const watchDir = args.find((a, i) => i > 0 && !a.startsWith("--")) ?? process.cwd();
|
|
103
124
|
startWatch(watchDir);
|
|
@@ -119,7 +140,7 @@ if (sinceIdx >= 0 && !since) {
|
|
|
119
140
|
console.error("error: --since requires a <ref> argument, e.g. --since origin/main");
|
|
120
141
|
process.exit(2);
|
|
121
142
|
}
|
|
122
|
-
var rules = resolveRules(targetDir);
|
|
143
|
+
var rules = resolveRules(targetDir, parsePackDirs(args));
|
|
123
144
|
var changedRanges;
|
|
124
145
|
if (since) {
|
|
125
146
|
try {
|
|
@@ -211,6 +232,75 @@ if (ci) {
|
|
|
211
232
|
const gateFail = fails > 0 || strict && cantTell > 0;
|
|
212
233
|
process.exit(gateFail ? 1 : 0);
|
|
213
234
|
}
|
|
235
|
+
function runPack(argv) {
|
|
236
|
+
const sub = argv[0];
|
|
237
|
+
if (sub === "init") {
|
|
238
|
+
const dir = argv.find((a, i) => i > 0 && !a.startsWith("--") && argv[i - 1] !== "--id" && argv[i - 1] !== "--name" && argv[i - 1] !== "--author" && argv[i - 1] !== "--version" && argv[i - 1] !== "--description");
|
|
239
|
+
const flag = (name) => {
|
|
240
|
+
const idx = argv.indexOf(`--${name}`);
|
|
241
|
+
return idx >= 0 ? argv[idx + 1] : void 0;
|
|
242
|
+
};
|
|
243
|
+
const id = flag("id");
|
|
244
|
+
if (!dir || !id) {
|
|
245
|
+
console.error("usage: brainblast pack init <dir> --id <pack-id> [--name <name>] [--author <author>] [--version <semver>] [--description <text>]");
|
|
246
|
+
process.exit(2);
|
|
247
|
+
}
|
|
248
|
+
const manifestFile = initPack(dir, {
|
|
249
|
+
id,
|
|
250
|
+
name: flag("name"),
|
|
251
|
+
author: flag("author"),
|
|
252
|
+
version: flag("version"),
|
|
253
|
+
description: flag("description")
|
|
254
|
+
});
|
|
255
|
+
console.log(`brainblast pack init: wrote ${manifestFile}`);
|
|
256
|
+
console.log(` rules: ${join2(dir, "rules")}/`);
|
|
257
|
+
console.log(` fixtures: ${join2(dir, "fixtures")}/`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (sub === "validate") {
|
|
261
|
+
const dir = argv.find((a, i) => i > 0 && !a.startsWith("--"));
|
|
262
|
+
if (!dir) {
|
|
263
|
+
console.error("usage: brainblast pack validate <dir>");
|
|
264
|
+
process.exit(2);
|
|
265
|
+
}
|
|
266
|
+
const result = validatePack(dir);
|
|
267
|
+
console.log(`pack: ${result.manifest.id} v${result.manifest.version} (${result.manifest.author})`);
|
|
268
|
+
console.log(` ${result.rules.length} rule(s)`);
|
|
269
|
+
for (const r of result.ruleResults) {
|
|
270
|
+
const marker = r.status === "ok" ? "OK" : r.status === "missing-fixtures" ? "WARN" : "FAIL";
|
|
271
|
+
console.log(` [${marker}] ${r.ruleId}: ${r.detail}`);
|
|
272
|
+
}
|
|
273
|
+
process.exit(result.ok ? 0 : 1);
|
|
274
|
+
}
|
|
275
|
+
console.error("usage: brainblast pack <init|validate> ...");
|
|
276
|
+
process.exit(2);
|
|
277
|
+
}
|
|
278
|
+
async function runTelemetry(argv) {
|
|
279
|
+
const sub = argv[0];
|
|
280
|
+
if (sub !== "submit") {
|
|
281
|
+
console.error("usage: brainblast telemetry submit [targetDir]");
|
|
282
|
+
process.exit(2);
|
|
283
|
+
}
|
|
284
|
+
const targetDir2 = argv.find((a, i) => i > 0 && !a.startsWith("--")) ?? process.cwd();
|
|
285
|
+
try {
|
|
286
|
+
const result = await submitTelemetry(targetDir2);
|
|
287
|
+
if (result.submitted === 0) {
|
|
288
|
+
console.log(`brainblast telemetry submit: no events to submit (${telemetryFilePath(targetDir2)} is empty or missing)`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
console.log(`brainblast telemetry submit: sent ${result.submitted} event(s) \u2014 ${result.accepted} accepted, ${result.rejected} rate-limited`);
|
|
292
|
+
for (const g of result.graduations) {
|
|
293
|
+
if (g.graduated) {
|
|
294
|
+
console.log(` [GRADUATED] ${g.pack_id}/${g.rule_id} (${g.distinct_pairs} distinct repo/user pairs)`);
|
|
295
|
+
} else {
|
|
296
|
+
console.log(` [PROGRESS] ${g.pack_id}/${g.rule_id} ${g.distinct_pairs}/5 distinct repo/user pairs`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.error(`brainblast telemetry submit: ${e.message ?? String(e)}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
214
304
|
async function runTrustGraph(argv) {
|
|
215
305
|
const rpcIdx = argv.indexOf("--rpc");
|
|
216
306
|
const rpcUrl = rpcIdx >= 0 ? argv[rpcIdx + 1] : void 0;
|
|
@@ -248,7 +338,7 @@ async function runFix(argv) {
|
|
|
248
338
|
const apply = argv.includes("--apply");
|
|
249
339
|
const branch = argv.includes("--branch");
|
|
250
340
|
const targetDir2 = argv.find((a) => !a.startsWith("--")) ?? process.cwd();
|
|
251
|
-
const rules2 = resolveRules(targetDir2);
|
|
341
|
+
const rules2 = resolveRules(targetDir2, parsePackDirs(argv));
|
|
252
342
|
const { checks: before } = audit(targetDir2, rules2);
|
|
253
343
|
const fixable = before.filter((c) => c.result === "fail" && c.fix?.diff);
|
|
254
344
|
if (fixable.length === 0) {
|
|
@@ -300,6 +390,15 @@ Warning: ${stillFailing.length} fix(es) applied but the rule still fails:`);
|
|
|
300
390
|
} else if (applied > 0) {
|
|
301
391
|
console.log("All applied fixes now pass (or cant_tell) on re-audit. \u2713");
|
|
302
392
|
}
|
|
393
|
+
if (isTelemetryEnabled(targetDir2)) {
|
|
394
|
+
const graduated = fixable.filter((c) => !stillFailing.includes(c));
|
|
395
|
+
const events = graduated.map((c) => rules2.find((r) => r.id === c.ruleId)).filter((r) => !!r?.pack).map((r) => ({ pack_id: r.pack.id, rule_id: r.id }));
|
|
396
|
+
if (events.length > 0) {
|
|
397
|
+
recordGraduationEvents(targetDir2, events);
|
|
398
|
+
console.log(`
|
|
399
|
+
Telemetry: recorded ${events.length} graduation event(s) to ${telemetryFilePath(targetDir2)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
303
402
|
if (branch && applied > 0) {
|
|
304
403
|
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
305
404
|
const branchName = `brainblast/auto-fix-${ts}`;
|
package/dist/index.d.ts
CHANGED
|
@@ -156,6 +156,23 @@ interface Rule {
|
|
|
156
156
|
kind: string;
|
|
157
157
|
params?: Record<string, any>;
|
|
158
158
|
};
|
|
159
|
+
/**
|
|
160
|
+
* Provenance for rules loaded from a third-party rule pack (see
|
|
161
|
+
* src/packs.ts). Absent for bundled rules. Stamped by the pack loader from
|
|
162
|
+
* the pack's brainblast-pack.yaml manifest, not set by the rule author.
|
|
163
|
+
*/
|
|
164
|
+
pack?: {
|
|
165
|
+
id: string;
|
|
166
|
+
version: string;
|
|
167
|
+
author?: string;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
interface PackManifest {
|
|
171
|
+
id: string;
|
|
172
|
+
name: string;
|
|
173
|
+
version: string;
|
|
174
|
+
author: string;
|
|
175
|
+
description?: string;
|
|
159
176
|
}
|
|
160
177
|
type Checker = (candidate: Candidate, params: any) => CheckOutcome;
|
|
161
178
|
type RustChecker = (candidate: RustCandidate, params: any) => CheckOutcome;
|
|
@@ -224,7 +241,7 @@ declare function audit(targetDir: string, rules: Rule[], changedRanges?: Changed
|
|
|
224
241
|
};
|
|
225
242
|
};
|
|
226
243
|
|
|
227
|
-
declare function resolveRules(targetDir: string): Rule[];
|
|
244
|
+
declare function resolveRules(targetDir: string, extraPackDirs?: string[]): Rule[];
|
|
228
245
|
|
|
229
246
|
declare function loadRules(dir: string): Rule[];
|
|
230
247
|
|
|
@@ -285,6 +302,70 @@ interface ParsedDiff {
|
|
|
285
302
|
declare function parseDiff(diff: string): ParsedDiff;
|
|
286
303
|
declare function applyDiffToFile(diff: string): boolean;
|
|
287
304
|
|
|
305
|
+
declare const PACK_MANIFEST_FILE = "brainblast-pack.yaml";
|
|
306
|
+
declare function validatePackManifest(m: any, file: string): void;
|
|
307
|
+
declare function loadPack(dir: string): {
|
|
308
|
+
manifest: PackManifest;
|
|
309
|
+
rules: Rule[];
|
|
310
|
+
};
|
|
311
|
+
declare function loadPacksFromDir(packsDir: string): {
|
|
312
|
+
manifest: PackManifest;
|
|
313
|
+
rules: Rule[];
|
|
314
|
+
}[];
|
|
315
|
+
|
|
316
|
+
interface PackInitOptions {
|
|
317
|
+
id: string;
|
|
318
|
+
name?: string;
|
|
319
|
+
author?: string;
|
|
320
|
+
version?: string;
|
|
321
|
+
description?: string;
|
|
322
|
+
}
|
|
323
|
+
declare function initPack(dir: string, opts: PackInitOptions): string;
|
|
324
|
+
interface PackValidateResult {
|
|
325
|
+
manifest: PackManifest;
|
|
326
|
+
rules: Rule[];
|
|
327
|
+
/** Per-rule prove-gate results. */
|
|
328
|
+
ruleResults: PackRuleValidation[];
|
|
329
|
+
/** True if the manifest is valid, every rule loaded cleanly, and every rule with fixtures passed RED->GREEN. */
|
|
330
|
+
ok: boolean;
|
|
331
|
+
}
|
|
332
|
+
interface PackRuleValidation {
|
|
333
|
+
ruleId: string;
|
|
334
|
+
/** "ok" | "missing-fixtures" | "red-failed" | "green-failed" */
|
|
335
|
+
status: "ok" | "missing-fixtures" | "red-failed" | "green-failed";
|
|
336
|
+
detail: string;
|
|
337
|
+
}
|
|
338
|
+
declare function validatePack(dir: string): PackValidateResult;
|
|
339
|
+
|
|
340
|
+
interface GraduationEvent {
|
|
341
|
+
pack_id: string;
|
|
342
|
+
rule_id: string;
|
|
343
|
+
repo_hash: string;
|
|
344
|
+
user_hash: string;
|
|
345
|
+
timestamp: string;
|
|
346
|
+
}
|
|
347
|
+
declare function isTelemetryEnabled(targetDir: string): boolean;
|
|
348
|
+
declare function getUserHash(): string;
|
|
349
|
+
declare function getRepoHash(targetDir: string): string;
|
|
350
|
+
declare function telemetryFilePath(targetDir: string): string;
|
|
351
|
+
declare const DEFAULT_REGISTRY_URL = "https://registry.brainblast.tech";
|
|
352
|
+
interface TelemetrySubmitResult {
|
|
353
|
+
submitted: number;
|
|
354
|
+
accepted: number;
|
|
355
|
+
rejected: number;
|
|
356
|
+
graduations: {
|
|
357
|
+
pack_id: string;
|
|
358
|
+
rule_id: string;
|
|
359
|
+
distinct_pairs: number;
|
|
360
|
+
graduated: boolean;
|
|
361
|
+
}[];
|
|
362
|
+
}
|
|
363
|
+
declare function submitTelemetry(targetDir: string, registryUrl?: string): Promise<TelemetrySubmitResult>;
|
|
364
|
+
declare function recordGraduationEvents(targetDir: string, events: {
|
|
365
|
+
pack_id: string;
|
|
366
|
+
rule_id: string;
|
|
367
|
+
}[]): void;
|
|
368
|
+
|
|
288
369
|
type UpgradeAuthorityKind = "renounced" | "single-key" | "multisig" | "dao" | "unknown";
|
|
289
370
|
type UpgradeAuthoritySource = "directory" | "rpc" | "research";
|
|
290
371
|
interface UpgradeAuthority {
|
|
@@ -414,4 +495,4 @@ declare function isEntryExpired(entry: ProgramCacheEntry, ttlHoursOverride?: num
|
|
|
414
495
|
*/
|
|
415
496
|
declare function cacheSize(cache: ProgramCache, ttlHoursOverride?: number): number;
|
|
416
497
|
|
|
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 };
|
|
498
|
+
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_REGISTRY_URL, DEFAULT_TTL_HOURS, type GraduationEvent, type OnChainProgram, PACK_MANIFEST_FILE, type PackInitOptions, type PackManifest, type PackRuleValidation, type PackValidateResult, type ParityNote, type ParsedDiff, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type RustChecker, type Severity, type TelemetrySubmitResult, 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, getRepoHash, getUserHash, getWorkingTreeChanges, initPack, isEntryExpired, isTelemetryEnabled, isValidSolanaAddress, lamportsToSol, loadDirectory, loadPack, loadPacksFromDir, loadProgramCache, loadRules, parseDiff, putCacheEntry, rangeChanged, recordGraduationEvents, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, runIncrementalScan, saveProgramCache, startWatch, submitTelemetry, telemetryFilePath, testKinds, validatePack, validatePackManifest };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_REGISTRY_URL,
|
|
2
3
|
DEFAULT_TTL_HOURS,
|
|
4
|
+
PACK_MANIFEST_FILE,
|
|
3
5
|
analyzeCosts,
|
|
4
6
|
applyDiffToFile,
|
|
5
7
|
audit,
|
|
@@ -16,16 +18,23 @@ import {
|
|
|
16
18
|
getCacheEntry,
|
|
17
19
|
getCacheEntryMeta,
|
|
18
20
|
getChangedRanges,
|
|
21
|
+
getRepoHash,
|
|
22
|
+
getUserHash,
|
|
19
23
|
getWorkingTreeChanges,
|
|
24
|
+
initPack,
|
|
20
25
|
isEntryExpired,
|
|
26
|
+
isTelemetryEnabled,
|
|
21
27
|
isValidSolanaAddress,
|
|
22
28
|
lamportsToSol,
|
|
23
29
|
loadDirectory,
|
|
30
|
+
loadPack,
|
|
31
|
+
loadPacksFromDir,
|
|
24
32
|
loadProgramCache,
|
|
25
33
|
loadRules,
|
|
26
34
|
parseDiff,
|
|
27
35
|
putCacheEntry,
|
|
28
36
|
rangeChanged,
|
|
37
|
+
recordGraduationEvents,
|
|
29
38
|
renderCostReportMd,
|
|
30
39
|
renderTest,
|
|
31
40
|
renderTrustGraphMd,
|
|
@@ -36,8 +45,12 @@ import {
|
|
|
36
45
|
runIncrementalScan,
|
|
37
46
|
saveProgramCache,
|
|
38
47
|
startWatch,
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
submitTelemetry,
|
|
49
|
+
telemetryFilePath,
|
|
50
|
+
testKinds,
|
|
51
|
+
validatePack,
|
|
52
|
+
validatePackManifest
|
|
53
|
+
} from "./chunk-5LJXC66F.js";
|
|
41
54
|
|
|
42
55
|
// src/generate.ts
|
|
43
56
|
import { writeFileSync, mkdirSync } from "fs";
|
|
@@ -53,7 +66,9 @@ function generateTestForResult(result, rule, outPath) {
|
|
|
53
66
|
return outPath;
|
|
54
67
|
}
|
|
55
68
|
export {
|
|
69
|
+
DEFAULT_REGISTRY_URL,
|
|
56
70
|
DEFAULT_TTL_HOURS,
|
|
71
|
+
PACK_MANIFEST_FILE,
|
|
57
72
|
analyzeCosts,
|
|
58
73
|
applyDiffToFile,
|
|
59
74
|
audit,
|
|
@@ -72,16 +87,23 @@ export {
|
|
|
72
87
|
getCacheEntry,
|
|
73
88
|
getCacheEntryMeta,
|
|
74
89
|
getChangedRanges,
|
|
90
|
+
getRepoHash,
|
|
91
|
+
getUserHash,
|
|
75
92
|
getWorkingTreeChanges,
|
|
93
|
+
initPack,
|
|
76
94
|
isEntryExpired,
|
|
95
|
+
isTelemetryEnabled,
|
|
77
96
|
isValidSolanaAddress,
|
|
78
97
|
lamportsToSol,
|
|
79
98
|
loadDirectory,
|
|
99
|
+
loadPack,
|
|
100
|
+
loadPacksFromDir,
|
|
80
101
|
loadProgramCache,
|
|
81
102
|
loadRules,
|
|
82
103
|
parseDiff,
|
|
83
104
|
putCacheEntry,
|
|
84
105
|
rangeChanged,
|
|
106
|
+
recordGraduationEvents,
|
|
85
107
|
renderCostReportMd,
|
|
86
108
|
renderTest,
|
|
87
109
|
renderTrustGraphMd,
|
|
@@ -91,5 +113,9 @@ export {
|
|
|
91
113
|
runIncrementalScan,
|
|
92
114
|
saveProgramCache,
|
|
93
115
|
startWatch,
|
|
94
|
-
|
|
116
|
+
submitTelemetry,
|
|
117
|
+
telemetryFilePath,
|
|
118
|
+
testKinds,
|
|
119
|
+
validatePack,
|
|
120
|
+
validatePackManifest
|
|
95
121
|
};
|
|
@@ -25,8 +25,12 @@ detect:
|
|
|
25
25
|
- end
|
|
26
26
|
requiresImport: false
|
|
27
27
|
check:
|
|
28
|
-
kind:
|
|
28
|
+
kind: taint-to-sink
|
|
29
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_]*"
|
|
30
34
|
sinkCalls:
|
|
31
35
|
- log
|
|
32
36
|
- error
|
|
@@ -37,7 +41,7 @@ check:
|
|
|
37
41
|
- send
|
|
38
42
|
- write
|
|
39
43
|
- end
|
|
40
|
-
#
|
|
41
|
-
|
|
44
|
+
# Same-file or imported-module hops to follow before/after the candidate.
|
|
45
|
+
maxHops: 2
|
|
42
46
|
test:
|
|
43
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.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic auditor for catastrophic AI-integration bugs: scan a repo, find the silent money/auth traps, and generate the behavioral test that proves they're fixed.",
|
|
6
6
|
"keywords": [
|