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 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 (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 |
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 (173 tests)
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/envTaintToSink.ts
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 envVarIn(text) {
501
- const m = text.match(/process\.env\.([A-Za-z0-9_]+)/);
502
- return m?.[1];
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 findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames) {
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
- 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.`;
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}' (holding a secret-shaped process.env value) is passed to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
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
- 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
- }
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
- const direct = findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames);
540
- if (direct) return { result: "fail", detail: direct };
541
- const sourceFile = fn.getSourceFile();
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 calleeExp = call.getExpression();
544
- if (calleeExp.getKind() !== SyntaxKind7.Identifier) continue;
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
- const envVar = envVarIn(text);
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 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));
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 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
- };
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 { result: "pass", detail: "No secret-shaped process.env value flows to a logging/response sink." };
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
- "env-taint-to-sink": envTaintToSink
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 join5 } from "path";
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 (existsSync(join5(here, "stripe-webhook-raw-body.yaml"))) return here;
1411
- const sub = join5(here, "rules");
1412
- if (existsSync(join5(sub, "stripe-webhook-raw-body.yaml"))) return sub;
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 existsSync2 } from "fs";
1419
- import { join as join6 } from "path";
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 projDir = join6(targetDir, ".agent-research", "rules");
1423
- if (existsSync2(projDir)) {
1424
- const seen = new Set(all.map((r) => r.id));
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: project rule '${r.id}' shadows a bundled rule; keeping bundled.`);
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 readFileSync5, existsSync as existsSync3 } from "fs";
1573
+ import { readFileSync as readFileSync6, existsSync as existsSync4 } from "fs";
1439
1574
  import { fileURLToPath as fileURLToPath2 } from "url";
1440
- import { join as join7 } from "path";
1441
- import { parse as parse2 } from "yaml";
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
- join7(here, "programs", "directory.yaml"),
1581
+ join8(here, "programs", "directory.yaml"),
1447
1582
  // dist/programs/directory.yaml
1448
- join7(here, "..", "..", "programs", "directory.yaml"),
1583
+ join8(here, "..", "..", "programs", "directory.yaml"),
1449
1584
  // src/../../programs/
1450
- join7(here, "..", "programs", "directory.yaml")
1585
+ join8(here, "..", "programs", "directory.yaml")
1451
1586
  // fallback
1452
1587
  ];
1453
1588
  for (const c of candidates) {
1454
- if (existsSync3(c)) return c;
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 = parse2(readFileSync5(path, "utf8"));
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 readFileSync6, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1532
- import { join as join8, dirname as dirname2 } from "path";
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 ?? join8(homedir(), ".brainblast", "program-cache.json");
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 (!existsSync4(path)) return emptyCache();
1680
+ if (!existsSync5(path)) return emptyCache();
1546
1681
  try {
1547
- const raw = JSON.parse(readFileSync6(path, "utf8"));
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 readFileSync7, writeFileSync as writeFileSync2 } from "fs";
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 = readFileSync7(filePath, "utf8");
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
- } from "./chunk-Q72MTJXQ.js";
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
- testKinds
40
- } from "./chunk-Q72MTJXQ.js";
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
- testKinds
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: env-taint-to-sink
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
- # Key names that typically hold credentials/secrets.
41
- secretKeyPattern: "(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)"
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.4.2",
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": [