brainblast 0.4.0 → 0.4.1

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
@@ -11,10 +11,52 @@ parses your code statically and runs offline.
11
11
  npx brainblast . # scan the repo, write .agent-research/report.json
12
12
  npx brainblast . --ci # exit 1 if a confirmed FAIL remains
13
13
  npx brainblast . --ci --strict # also fail on CANT_TELL (can't statically prove)
14
+ npx brainblast . --since origin/main # diff-aware: only audit what changed
14
15
  ```
15
16
 
16
17
  Exit codes: **0** clean · **1** a confirmed FAIL · CANT_TELL is a warning by
17
- default (a red build always means a real, confirmed problem).
18
+ default (a red build always means a real, confirmed problem). `2` means
19
+ `--since <ref>` could not run `git diff` (bad ref, or not a git work tree).
20
+
21
+ ### Diff-aware scanning (`--since <ref>`)
22
+
23
+ `--since <ref>` audits only what's changed relative to `<ref>` (any git
24
+ revision: a branch, `HEAD~1`, a commit SHA): TS/Rust functions whose line
25
+ range overlaps `git diff <ref>`, and config/env files that changed at all.
26
+ This makes brainblast fast enough to run on every commit or PR instead of a
27
+ full-repo scan:
28
+
29
+ ```sh
30
+ npx brainblast . --since origin/main # CI: only the PR's diff
31
+ npx brainblast . --since HEAD # pre-commit/save hook: working tree changes
32
+ ```
33
+
34
+ Living-memory precedents (see below) are still looked up and shown in
35
+ `--since` mode, but the memory snapshot itself is only written on full
36
+ (non-`--since`) runs — a partial diff-scan never overwrites the full picture.
37
+
38
+ ### Watch mode (`brainblast watch`)
39
+
40
+ ```sh
41
+ npx brainblast watch .
42
+ ```
43
+
44
+ Runs as a daemon: every time a file is saved, brainblast re-scans only the
45
+ working-tree changes (uncommitted edits vs `HEAD`, plus untracked files —
46
+ the "what did I just save?" view) and emits one **NDJSON event per line** on
47
+ stdout:
48
+
49
+ ```json
50
+ {"type":"watch_started","targetDir":"."}
51
+ {"type":"finding","ruleId":"stripe-webhook-raw-body-verification","severity":"critical","result":"fail","file":"src/webhook.ts","line":3,"detail":"...","fix":{...}}
52
+ {"type":"scan_complete","filesChanged":1,"findings":1,"durationMs":62}
53
+ ```
54
+
55
+ Event types: `watch_started`, `finding` (one per FAIL/CANT_TELL), `scan_complete`
56
+ (per debounced save, even if nothing changed), and `scan_error` (e.g. not a
57
+ git work tree). This is the integration point for an agent daemon — tail
58
+ stdout for structured findings instead of polling `.agent-research/report.json`.
59
+ Exit with Ctrl-C / SIGTERM.
18
60
 
19
61
  ## What it catches
20
62
 
@@ -34,6 +76,12 @@ default (a red build always means a real, confirmed problem).
34
76
  | `metaplex-metadata-immutable` | `createV1` / `createNft` omits `isMutable: false` | Metadata defaults to mutable; any update authority can change the token's name, image, or attributes after launch |
35
77
  | `anchor-init-if-needed-guarded` | Anchor instruction uses `init_if_needed` without a re-initialization guard | Any user can reinitialize another user's account, overwriting its state |
36
78
 
79
+ ### Config / env
80
+
81
+ | Rule | What's wrong | Consequence |
82
+ |------|--------------|-------------|
83
+ | `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 |
84
+
37
85
  Each finding lands in `.agent-research/report.json` (stable `schemaVersion: "1.0"`)
38
86
  with a `checks[]` array a CI gate can read. Each confirmed FAIL ships a
39
87
  generated behavioral test (RED on vulnerable, GREEN on fixed).
@@ -116,7 +164,7 @@ All types are exported: `Rule`, `CheckResult`, `CostReport`, `AccountFlow`,
116
164
 
117
165
  ```sh
118
166
  npm install
119
- npm test # unit suite (135 tests)
167
+ npm test # unit suite (164 tests)
120
168
  npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
121
169
  npm run build # produce dist/ (the published artifact)
122
170
  ```
@@ -4,9 +4,10 @@ import { Project, SyntaxKind } from "ts-morph";
4
4
  // src/walk.ts
5
5
  import { readdirSync, statSync } from "fs";
6
6
  import { join } from "path";
7
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".gen", "dist", ".next", ".agent-research"]);
7
8
  function walk(dir, out = []) {
8
9
  for (const entry of readdirSync(dir)) {
9
- if (entry === "node_modules" || entry === ".git" || entry === ".gen") continue;
10
+ if (SKIP_DIRS.has(entry)) continue;
10
11
  const p = join(dir, entry);
11
12
  const st = statSync(p);
12
13
  if (st.isDirectory()) walk(p, out);
@@ -14,6 +15,16 @@ function walk(dir, out = []) {
14
15
  }
15
16
  return out;
16
17
  }
18
+ function walkAllFiles(dir, out = []) {
19
+ for (const entry of readdirSync(dir)) {
20
+ if (SKIP_DIRS.has(entry)) continue;
21
+ const p = join(dir, entry);
22
+ const st = statSync(p);
23
+ if (st.isDirectory()) walkAllFiles(p, out);
24
+ else out.push(p);
25
+ }
26
+ return out;
27
+ }
17
28
 
18
29
  // src/finder.ts
19
30
  function bodyCallsAnyOf(fn, names) {
@@ -61,6 +72,42 @@ function findCandidates(targetDir, rule) {
61
72
  return out;
62
73
  }
63
74
 
75
+ // src/configFinder.ts
76
+ import { execFileSync } from "child_process";
77
+ import { readFileSync } from "fs";
78
+ import { relative, sep } from "path";
79
+ function isGitIgnored(targetDir, rel) {
80
+ try {
81
+ execFileSync("git", ["check-ignore", "-q", "--", rel], { cwd: targetDir, stdio: "ignore" });
82
+ return true;
83
+ } catch (e) {
84
+ if (typeof e?.status === "number") return false;
85
+ return false;
86
+ }
87
+ }
88
+ function findConfigCandidates(targetDir, rule) {
89
+ const patterns = (rule.detect.filePatterns ?? []).map((p) => new RegExp(p));
90
+ if (patterns.length === 0) return [];
91
+ const files = walkAllFiles(targetDir);
92
+ const out = [];
93
+ for (const file of files) {
94
+ const rel = relative(targetDir, file).split(sep).join("/");
95
+ if (!patterns.some((re) => re.test(rel))) continue;
96
+ let content;
97
+ try {
98
+ content = readFileSync(file, "utf8");
99
+ } catch {
100
+ continue;
101
+ }
102
+ out.push({
103
+ filePath: file,
104
+ content,
105
+ tracked: !isGitIgnored(targetDir, rel)
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+
64
111
  // src/checkers/positionalArgIdentity.ts
65
112
  import { SyntaxKind as SyntaxKind2 } from "ts-morph";
66
113
  var positionalArgIdentity = (c, p) => {
@@ -410,6 +457,36 @@ function anchorInitIfNeededGuarded(c, p) {
410
457
  };
411
458
  }
412
459
 
460
+ // src/checkers/envSecretsCommitted.ts
461
+ var envSecretsCommitted = (c, p) => {
462
+ if (!c.tracked) {
463
+ return {
464
+ result: "pass",
465
+ detail: p.ignoredDetail ?? "File is git-ignored; not committed to source control."
466
+ };
467
+ }
468
+ const keyRe = new RegExp(p.secretKeyPattern, "i");
469
+ const placeholderRe = new RegExp(p.placeholderPattern, "i");
470
+ const offenders = [];
471
+ for (const rawLine of c.content.split("\n")) {
472
+ const line = rawLine.trim();
473
+ if (!line || line.startsWith("#")) continue;
474
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
475
+ if (!m) continue;
476
+ const [, key, rawValue] = m;
477
+ if (!keyRe.test(key)) continue;
478
+ const value = (rawValue ?? "").trim().replace(/^["']|["']$/g, "");
479
+ if (!value) continue;
480
+ if (placeholderRe.test(value)) continue;
481
+ offenders.push(key);
482
+ }
483
+ if (offenders.length > 0) {
484
+ const prefix = p.failDetailPrefix ?? "This file is tracked by git and contains secret-looking values";
485
+ return { result: "fail", detail: `${prefix}: ${offenders.join(", ")}.` };
486
+ }
487
+ return { result: "pass", detail: p.passDetail ?? "No committed secret-looking values found." };
488
+ };
489
+
413
490
  // src/checkers/index.ts
414
491
  var registry = {
415
492
  "positional-arg-identity": positionalArgIdentity,
@@ -417,7 +494,8 @@ var registry = {
417
494
  "fee-allocation-shape": feeAllocationShape,
418
495
  "arg-equals-constant-identifier": argEqualsConstantIdentifier,
419
496
  "object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
420
- "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded
497
+ "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
498
+ "env-secrets-committed": envSecretsCommitted
421
499
  };
422
500
  function runChecker(kind, c, params) {
423
501
  const fn = registry[kind];
@@ -426,14 +504,89 @@ function runChecker(kind, c, params) {
426
504
  }
427
505
  var checkerKinds = Object.keys(registry);
428
506
 
429
- // src/rustFinder.ts
430
- import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
507
+ // src/gitDiff.ts
508
+ import { execFileSync as execFileSync2 } from "child_process";
509
+ import { readFileSync as readFileSync2 } from "fs";
431
510
  import { join as join2 } from "path";
511
+ function getChangedRanges(targetDir, ref) {
512
+ let out;
513
+ try {
514
+ out = execFileSync2(
515
+ "git",
516
+ ["diff", "--unified=0", "--no-color", "--no-renames", "--diff-filter=ACMR", "--relative", ref, "--"],
517
+ { cwd: targetDir, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
518
+ );
519
+ } catch (e) {
520
+ const stderr = e?.stderr?.toString?.() ?? e?.message ?? String(e);
521
+ throw new Error(`brainblast: 'git diff ${ref}' failed: ${stderr.trim()}`);
522
+ }
523
+ const ranges = /* @__PURE__ */ new Map();
524
+ let currentFile = null;
525
+ for (const line of out.split("\n")) {
526
+ if (line.startsWith("+++ ")) {
527
+ const raw = line.slice(4).trim();
528
+ if (raw === "/dev/null") {
529
+ currentFile = null;
530
+ continue;
531
+ }
532
+ const rel = raw.startsWith("b/") ? raw.slice(2) : raw;
533
+ currentFile = join2(targetDir, rel);
534
+ continue;
535
+ }
536
+ if (line.startsWith("@@") && currentFile) {
537
+ const m = line.match(/\+(\d+)(?:,(\d+))?/);
538
+ if (!m) continue;
539
+ const start = parseInt(m[1], 10);
540
+ const count = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
541
+ if (count === 0) continue;
542
+ const end = start + count - 1;
543
+ const arr = ranges.get(currentFile) ?? [];
544
+ arr.push([start, end]);
545
+ ranges.set(currentFile, arr);
546
+ }
547
+ }
548
+ return ranges;
549
+ }
550
+ function getWorkingTreeChanges(targetDir) {
551
+ const ranges = getChangedRanges(targetDir, "HEAD");
552
+ let untracked;
553
+ try {
554
+ untracked = execFileSync2("git", ["ls-files", "--others", "--exclude-standard", "--", "."], {
555
+ cwd: targetDir,
556
+ encoding: "utf8"
557
+ });
558
+ } catch {
559
+ return ranges;
560
+ }
561
+ for (const rel of untracked.split("\n").map((s) => s.trim()).filter(Boolean)) {
562
+ const abs = join2(targetDir, rel);
563
+ let lineCount = 1;
564
+ try {
565
+ lineCount = readFileSync2(abs, "utf8").split("\n").length;
566
+ } catch {
567
+ continue;
568
+ }
569
+ ranges.set(abs, [[1, lineCount]]);
570
+ }
571
+ return ranges;
572
+ }
573
+ function fileChanged(ranges, file) {
574
+ return ranges.has(file);
575
+ }
576
+ function rangeChanged(ranges, file, startLine, endLine) {
577
+ const fileRanges = ranges.get(file);
578
+ if (!fileRanges) return false;
579
+ return fileRanges.some(([s, e]) => startLine <= e && endLine >= s);
580
+ }
581
+
582
+ // src/rustFinder.ts
583
+ import { readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
584
+ import { join as join3 } from "path";
432
585
  import { createRequire } from "module";
433
586
  function walkRust(dir, out = []) {
434
587
  for (const entry of readdirSync2(dir)) {
435
588
  if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
436
- const p = join2(dir, entry);
589
+ const p = join3(dir, entry);
437
590
  const st = statSync2(p);
438
591
  if (st.isDirectory()) walkRust(p, out);
439
592
  else if (p.endsWith(".rs")) out.push(p);
@@ -513,7 +666,7 @@ function findRustCandidates(targetDir, rule) {
513
666
  const out = [];
514
667
  for (const file of walkRust(targetDir)) {
515
668
  if (!file.endsWith(".rs")) continue;
516
- const src = readFileSync(file, "utf8");
669
+ const src = readFileSync3(file, "utf8");
517
670
  const tree = parser.parse(src);
518
671
  const structMap = /* @__PURE__ */ new Map();
519
672
  const topPairs = itemsWithAttrs(tree.rootNode);
@@ -744,9 +897,28 @@ function buildReport(target, checks, rules2, costReport) {
744
897
  }
745
898
 
746
899
  // src/audit.ts
747
- function auditWithRule(targetDir, rule) {
900
+ function auditWithRule(targetDir, rule, changedRanges) {
901
+ if (rule.detect.lang === "config") {
902
+ return findConfigCandidates(targetDir, rule).filter((c) => !changedRanges || fileChanged(changedRanges, c.filePath)).map((c) => {
903
+ const outcome = runChecker(rule.check.kind, c, rule.check.params);
904
+ return {
905
+ ruleId: rule.id,
906
+ severity: rule.severity,
907
+ title: rule.title,
908
+ file: c.filePath,
909
+ line: 1,
910
+ exportName: c.filePath,
911
+ ...outcome
912
+ };
913
+ });
914
+ }
748
915
  if (rule.detect.lang === "rust") {
749
- return findRustCandidates(targetDir, rule).map((c) => {
916
+ return findRustCandidates(targetDir, rule).filter((c) => {
917
+ if (!changedRanges) return true;
918
+ const start = (c.fnBodyNode?.startPosition?.row ?? 0) + 1;
919
+ const end = (c.fnBodyNode?.endPosition?.row ?? start - 1) + 1;
920
+ return rangeChanged(changedRanges, c.filePath, start, end);
921
+ }).map((c) => {
750
922
  const outcome = runChecker(rule.check.kind, c, rule.check.params);
751
923
  return {
752
924
  ruleId: rule.id,
@@ -760,7 +932,10 @@ function auditWithRule(targetDir, rule) {
760
932
  };
761
933
  });
762
934
  }
763
- return findCandidates(targetDir, rule).map((c) => {
935
+ return findCandidates(targetDir, rule).filter((c) => {
936
+ if (!changedRanges) return true;
937
+ return rangeChanged(changedRanges, c.filePath, c.fn.getStartLineNumber(), c.fn.getEndLineNumber());
938
+ }).map((c) => {
764
939
  const outcome = runChecker(rule.check.kind, c, rule.check.params);
765
940
  const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
766
941
  return {
@@ -775,8 +950,8 @@ function auditWithRule(targetDir, rule) {
775
950
  };
776
951
  });
777
952
  }
778
- function audit(targetDir, rules2) {
779
- const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
953
+ function audit(targetDir, rules2, changedRanges) {
954
+ const checks = rules2.flatMap((r) => auditWithRule(targetDir, r, changedRanges));
780
955
  const report = buildReport(targetDir, checks, rules2);
781
956
  return { checks, report };
782
957
  }
@@ -1055,6 +1230,11 @@ mod brainblast_reinit_guard_test {
1055
1230
  }
1056
1231
  `;
1057
1232
 
1233
+ // src/testTemplates/none.ts
1234
+ var none = (opts) => `// No behavioral contract test applies to this rule.
1235
+ // Finding: ${opts.handlerExport} (${opts.handlerImportPath})
1236
+ `;
1237
+
1058
1238
  // src/testTemplates/index.ts
1059
1239
  var registry3 = {
1060
1240
  "stripe-webhook-signature": stripeWebhookSignature,
@@ -1062,7 +1242,8 @@ var registry3 = {
1062
1242
  "bags-fee-share": bagsFeeShare,
1063
1243
  "token-program-consistency": tokenProgramConsistency,
1064
1244
  "metaplex-immutable-metadata": metaplexImmutableMetadata,
1065
- "anchor-program-test": anchorProgramTest
1245
+ "anchor-program-test": anchorProgramTest,
1246
+ none
1066
1247
  };
1067
1248
  var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
1068
1249
  function renderTest(kind, opts) {
@@ -1076,8 +1257,8 @@ function renderTest(kind, opts) {
1076
1257
  var testKinds = Object.keys(registry3);
1077
1258
 
1078
1259
  // src/loadRules.ts
1079
- import { readdirSync as readdirSync3, readFileSync as readFileSync2 } from "fs";
1080
- import { join as join3 } from "path";
1260
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
1261
+ import { join as join4 } from "path";
1081
1262
  import { parse } from "yaml";
1082
1263
  var SEVERITIES = ["critical", "high", "medium", "low"];
1083
1264
  function validateRule(r, file) {
@@ -1089,7 +1270,19 @@ function validateRule(r, file) {
1089
1270
  if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
1090
1271
  if (!r.title || typeof r.title !== "string") errs.push("missing title");
1091
1272
  if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
1092
- if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
1273
+ if (r.detect?.lang === "config") {
1274
+ if (!Array.isArray(r.detect.filePatterns) || r.detect.filePatterns.length === 0) {
1275
+ errs.push("detect.filePatterns must be a non-empty array when detect.lang is 'config'");
1276
+ } else {
1277
+ for (const pat of r.detect.filePatterns) {
1278
+ try {
1279
+ new RegExp(pat);
1280
+ } catch {
1281
+ errs.push(`detect.filePatterns contains an invalid regex: ${pat}`);
1282
+ }
1283
+ }
1284
+ }
1285
+ } else if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
1093
1286
  errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
1094
1287
  } else {
1095
1288
  try {
@@ -1110,7 +1303,7 @@ function loadRules(dir) {
1110
1303
  const files = readdirSync3(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
1111
1304
  const rules2 = [];
1112
1305
  for (const f of files) {
1113
- const raw = parse(readFileSync2(join3(dir, f), "utf8"));
1306
+ const raw = parse(readFileSync4(join4(dir, f), "utf8"));
1114
1307
  validateRule(raw, f);
1115
1308
  rules2.push(raw);
1116
1309
  }
@@ -1119,23 +1312,23 @@ function loadRules(dir) {
1119
1312
 
1120
1313
  // rules/index.ts
1121
1314
  import { existsSync } from "fs";
1122
- import { dirname, join as join4 } from "path";
1315
+ import { dirname, join as join5 } from "path";
1123
1316
  import { fileURLToPath } from "url";
1124
1317
  function bundledRulesDir() {
1125
1318
  const here = dirname(fileURLToPath(import.meta.url));
1126
- if (existsSync(join4(here, "stripe-webhook-raw-body.yaml"))) return here;
1127
- const sub = join4(here, "rules");
1128
- if (existsSync(join4(sub, "stripe-webhook-raw-body.yaml"))) return sub;
1319
+ if (existsSync(join5(here, "stripe-webhook-raw-body.yaml"))) return here;
1320
+ const sub = join5(here, "rules");
1321
+ if (existsSync(join5(sub, "stripe-webhook-raw-body.yaml"))) return sub;
1129
1322
  return here;
1130
1323
  }
1131
1324
  var rules = loadRules(bundledRulesDir());
1132
1325
 
1133
1326
  // src/resolveRules.ts
1134
1327
  import { existsSync as existsSync2 } from "fs";
1135
- import { join as join5 } from "path";
1328
+ import { join as join6 } from "path";
1136
1329
  function resolveRules(targetDir) {
1137
1330
  const all = [...rules];
1138
- const projDir = join5(targetDir, ".agent-research", "rules");
1331
+ const projDir = join6(targetDir, ".agent-research", "rules");
1139
1332
  if (existsSync2(projDir)) {
1140
1333
  const seen = new Set(all.map((r) => r.id));
1141
1334
  for (const r of loadRules(projDir)) {
@@ -1151,19 +1344,19 @@ function resolveRules(targetDir) {
1151
1344
  }
1152
1345
 
1153
1346
  // src/trustGraph/directory.ts
1154
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
1347
+ import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
1155
1348
  import { fileURLToPath as fileURLToPath2 } from "url";
1156
- import { join as join6 } from "path";
1349
+ import { join as join7 } from "path";
1157
1350
  import { parse as parse2 } from "yaml";
1158
1351
  var cache = null;
1159
1352
  function bundledPath() {
1160
1353
  const here = fileURLToPath2(new URL(".", import.meta.url));
1161
1354
  const candidates = [
1162
- join6(here, "programs", "directory.yaml"),
1355
+ join7(here, "programs", "directory.yaml"),
1163
1356
  // dist/programs/directory.yaml
1164
- join6(here, "..", "..", "programs", "directory.yaml"),
1357
+ join7(here, "..", "..", "programs", "directory.yaml"),
1165
1358
  // src/../../programs/
1166
- join6(here, "..", "programs", "directory.yaml")
1359
+ join7(here, "..", "programs", "directory.yaml")
1167
1360
  // fallback
1168
1361
  ];
1169
1362
  for (const c of candidates) {
@@ -1173,7 +1366,7 @@ function bundledPath() {
1173
1366
  }
1174
1367
  function loadDirectory(path = bundledPath()) {
1175
1368
  if (cache && path === bundledPath()) return cache;
1176
- const raw = parse2(readFileSync3(path, "utf8"));
1369
+ const raw = parse2(readFileSync5(path, "utf8"));
1177
1370
  if (!raw || !Array.isArray(raw.programs)) {
1178
1371
  throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
1179
1372
  }
@@ -1244,14 +1437,14 @@ function isValidSolanaAddress(s) {
1244
1437
  }
1245
1438
 
1246
1439
  // src/trustGraph/programCache.ts
1247
- import { readFileSync as readFileSync4, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1248
- import { join as join7, dirname as dirname2 } from "path";
1440
+ import { readFileSync as readFileSync6, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1441
+ import { join as join8, dirname as dirname2 } from "path";
1249
1442
  import { homedir } from "os";
1250
1443
  var DEFAULT_TTL_HOURS = 168;
1251
1444
  var SCHEMA_VERSION = "1.0";
1252
1445
  function defaultCachePath() {
1253
1446
  const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
1254
- return envOverride ?? join7(homedir(), ".brainblast", "program-cache.json");
1447
+ return envOverride ?? join8(homedir(), ".brainblast", "program-cache.json");
1255
1448
  }
1256
1449
  function emptyCache() {
1257
1450
  return { schemaVersion: SCHEMA_VERSION, entries: {} };
@@ -1260,7 +1453,7 @@ function loadProgramCache(cachePath) {
1260
1453
  const path = cachePath ?? defaultCachePath();
1261
1454
  if (!existsSync4(path)) return emptyCache();
1262
1455
  try {
1263
- const raw = JSON.parse(readFileSync4(path, "utf8"));
1456
+ const raw = JSON.parse(readFileSync6(path, "utf8"));
1264
1457
  if (raw?.schemaVersion !== SCHEMA_VERSION) {
1265
1458
  return emptyCache();
1266
1459
  }
@@ -1779,10 +1972,72 @@ function renderCostReportMd(r) {
1779
1972
  return lines.join("\n");
1780
1973
  }
1781
1974
 
1975
+ // src/watch.ts
1976
+ import { watch as fsWatch } from "fs";
1977
+ function runIncrementalScan(targetDir, rules2, emit) {
1978
+ const start = Date.now();
1979
+ let changedRanges;
1980
+ try {
1981
+ changedRanges = getWorkingTreeChanges(targetDir);
1982
+ } catch (e) {
1983
+ emit({ type: "scan_error", message: e?.message ?? String(e) });
1984
+ return;
1985
+ }
1986
+ if (changedRanges.size === 0) {
1987
+ emit({ type: "scan_complete", filesChanged: 0, findings: 0, durationMs: Date.now() - start });
1988
+ return;
1989
+ }
1990
+ const { checks } = audit(targetDir, rules2, changedRanges);
1991
+ let findings = 0;
1992
+ for (const c of checks) {
1993
+ if (c.result === "pass") continue;
1994
+ findings++;
1995
+ emit({
1996
+ type: "finding",
1997
+ ruleId: c.ruleId,
1998
+ severity: c.severity,
1999
+ result: c.result,
2000
+ file: c.file,
2001
+ line: c.line,
2002
+ detail: c.detail,
2003
+ ...c.fix ? { fix: c.fix } : {}
2004
+ });
2005
+ }
2006
+ emit({ type: "scan_complete", filesChanged: changedRanges.size, findings, durationMs: Date.now() - start });
2007
+ }
2008
+ function startWatch(targetDir, opts = {}) {
2009
+ const debounceMs = opts.debounceMs ?? 300;
2010
+ const emit = opts.emit ?? ((e) => process.stdout.write(JSON.stringify(e) + "\n"));
2011
+ const rules2 = resolveRules(targetDir);
2012
+ let timer;
2013
+ const scheduleScan = () => {
2014
+ if (timer) clearTimeout(timer);
2015
+ timer = setTimeout(() => runIncrementalScan(targetDir, rules2, emit), debounceMs);
2016
+ };
2017
+ const watcher = fsWatch(targetDir, { recursive: true }, (_event, filename) => {
2018
+ if (!filename) return;
2019
+ const parts = filename.split(/[\\/]/);
2020
+ if (parts.some((p) => SKIP_DIRS.has(p))) return;
2021
+ scheduleScan();
2022
+ });
2023
+ emit({ type: "watch_started", targetDir });
2024
+ return {
2025
+ close: () => {
2026
+ if (timer) clearTimeout(timer);
2027
+ watcher.close();
2028
+ }
2029
+ };
2030
+ }
2031
+
1782
2032
  export {
1783
2033
  findCandidates,
2034
+ findConfigCandidates,
1784
2035
  runChecker,
1785
2036
  checkerKinds,
2037
+ getChangedRanges,
2038
+ getWorkingTreeChanges,
2039
+ fileChanged,
2040
+ rangeChanged,
1786
2041
  auditWithRule,
1787
2042
  audit,
1788
2043
  renderTest,
@@ -1808,5 +2063,7 @@ export {
1808
2063
  rentExemptMinimum,
1809
2064
  lamportsToSol,
1810
2065
  analyzeCosts,
1811
- renderCostReportMd
2066
+ renderCostReportMd,
2067
+ runIncrementalScan,
2068
+ startWatch
1812
2069
  };
package/dist/cli.js CHANGED
@@ -5,12 +5,14 @@ import {
5
5
  buildTrustGraph,
6
6
  cacheSize,
7
7
  defaultCachePath,
8
+ getChangedRanges,
8
9
  isValidSolanaAddress,
9
10
  loadProgramCache,
10
11
  renderCostReportMd,
11
12
  renderTrustGraphMd,
12
- resolveRules
13
- } from "./chunk-WVHGN2HR.js";
13
+ resolveRules,
14
+ startWatch
15
+ } from "./chunk-P7K7NRVN.js";
14
16
 
15
17
  // src/cli.ts
16
18
  import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
@@ -37,16 +39,16 @@ function loadMemory(targetDir2) {
37
39
  return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
38
40
  }
39
41
  }
40
- function saveMemory(targetDir2, memory2) {
42
+ function saveMemory(targetDir2, memory) {
41
43
  mkdirSync(join(targetDir2, ".agent-research"), { recursive: true });
42
- writeFileSync(memoryPath(targetDir2), JSON.stringify(memory2, null, 2));
44
+ writeFileSync(memoryPath(targetDir2), JSON.stringify(memory, null, 2));
43
45
  }
44
46
  var snapshotKey = (e) => `${e.ruleId}::${e.file}::${e.exportName}`;
45
47
  function precedentKey(c) {
46
48
  return `${c.ruleId}::${c.file}`;
47
49
  }
48
- function updateMemory(memory2, checks2, now = /* @__PURE__ */ new Date()) {
49
- const prevByKey = new Map(memory2.lastRun.map((e) => [snapshotKey(e), e]));
50
+ function updateMemory(memory, checks2, now = /* @__PURE__ */ new Date()) {
51
+ const prevByKey = new Map(memory.lastRun.map((e) => [snapshotKey(e), e]));
50
52
  const fixedAt = now.toISOString().slice(0, 10);
51
53
  const newFixEvents = [];
52
54
  for (const c of checks2) {
@@ -61,15 +63,15 @@ function updateMemory(memory2, checks2, now = /* @__PURE__ */ new Date()) {
61
63
  });
62
64
  }
63
65
  }
64
- const fixHistory = [...memory2.fixHistory, ...newFixEvents];
65
- const precedents2 = /* @__PURE__ */ new Map();
66
+ const fixHistory = [...memory.fixHistory, ...newFixEvents];
67
+ const precedents = /* @__PURE__ */ new Map();
66
68
  for (const c of checks2) {
67
69
  if (c.result !== "fail") continue;
68
70
  const pk = precedentKey(c);
69
- if (precedents2.has(pk)) continue;
71
+ if (precedents.has(pk)) continue;
70
72
  const matches = fixHistory.filter((e) => e.ruleId === c.ruleId && e.file !== c.file).sort((a, b) => a.fixedAt < b.fixedAt ? 1 : a.fixedAt > b.fixedAt ? -1 : 0);
71
73
  if (matches[0]) {
72
- precedents2.set(pk, {
74
+ precedents.set(pk, {
73
75
  file: matches[0].file,
74
76
  exportName: matches[0].exportName,
75
77
  fixedAt: matches[0].fixedAt,
@@ -84,7 +86,7 @@ function updateMemory(memory2, checks2, now = /* @__PURE__ */ new Date()) {
84
86
  result: c.result,
85
87
  detail: c.detail
86
88
  }));
87
- return { memory: { schemaVersion: "1.0", lastRun, fixHistory }, precedents: precedents2 };
89
+ return { memory: { schemaVersion: "1.0", lastRun, fixHistory }, precedents };
88
90
  }
89
91
 
90
92
  // src/cli.ts
@@ -93,22 +95,58 @@ if (args[0] === "trust-graph") {
93
95
  await runTrustGraph(args.slice(1));
94
96
  process.exit(0);
95
97
  }
98
+ if (args[0] === "watch") {
99
+ const watchDir = args.find((a, i) => i > 0 && !a.startsWith("--")) ?? process.cwd();
100
+ startWatch(watchDir);
101
+ process.on("SIGINT", () => process.exit(0));
102
+ process.on("SIGTERM", () => process.exit(0));
103
+ await new Promise(() => {
104
+ });
105
+ }
96
106
  var ci = args.includes("--ci");
97
107
  var strict = args.includes("--strict");
98
- var targetDir = args.find((a) => !a.startsWith("--")) ?? process.cwd();
108
+ var sinceIdx = args.indexOf("--since");
109
+ var since = sinceIdx >= 0 ? args[sinceIdx + 1] : void 0;
110
+ var targetDir = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--since") ?? process.cwd();
111
+ if (sinceIdx >= 0 && !since) {
112
+ console.error("error: --since requires a <ref> argument, e.g. --since origin/main");
113
+ process.exit(2);
114
+ }
99
115
  var rules = resolveRules(targetDir);
100
- var { checks, report } = audit(targetDir, rules);
101
- var memory = loadMemory(targetDir);
102
- var { memory: nextMemory, precedents } = updateMemory(memory, checks);
103
- for (const c of checks) {
104
- const p = precedents.get(precedentKey(c));
105
- if (p) c.precedent = p;
116
+ var changedRanges;
117
+ if (since) {
118
+ try {
119
+ changedRanges = getChangedRanges(targetDir, since);
120
+ } catch (e) {
121
+ console.error(e.message ?? String(e));
122
+ process.exit(2);
123
+ }
106
124
  }
107
- for (const rc of report.checks) {
108
- const p = precedents.get(precedentKey(rc));
109
- if (p) rc.precedent = p;
125
+ var { checks, report } = audit(targetDir, rules, changedRanges);
126
+ if (!changedRanges) {
127
+ const memory = loadMemory(targetDir);
128
+ const { memory: nextMemory, precedents } = updateMemory(memory, checks);
129
+ for (const c of checks) {
130
+ const p = precedents.get(precedentKey(c));
131
+ if (p) c.precedent = p;
132
+ }
133
+ for (const rc of report.checks) {
134
+ const p = precedents.get(precedentKey(rc));
135
+ if (p) rc.precedent = p;
136
+ }
137
+ saveMemory(targetDir, nextMemory);
138
+ } else {
139
+ const memory = loadMemory(targetDir);
140
+ const { precedents } = updateMemory(memory, checks);
141
+ for (const c of checks) {
142
+ const p = precedents.get(precedentKey(c));
143
+ if (p) c.precedent = p;
144
+ }
145
+ for (const rc of report.checks) {
146
+ const p = precedents.get(precedentKey(rc));
147
+ if (p) rc.precedent = p;
148
+ }
110
149
  }
111
- saveMemory(targetDir, nextMemory);
112
150
  var costReport = analyzeCosts(targetDir);
113
151
  report.costAnalysis = costReport;
114
152
  var outDir = join2(targetDir, ".agent-research");
package/dist/index.d.ts CHANGED
@@ -74,6 +74,14 @@ interface RustCandidate {
74
74
  /** tree-sitter SyntaxNode for the function body — available for precise queries */
75
75
  fnBodyNode: any;
76
76
  }
77
+ interface ConfigCandidate {
78
+ /** Source file (absolute path) */
79
+ filePath: string;
80
+ /** Raw file contents */
81
+ content: string;
82
+ /** Whether this file is tracked by git / not covered by .gitignore */
83
+ tracked: boolean;
84
+ }
77
85
  interface CheckOutcome {
78
86
  result: CheckResultKind;
79
87
  detail: string;
@@ -115,8 +123,17 @@ interface Rule {
115
123
  modules: string[];
116
124
  nameRegex: string;
117
125
  triggerCalls: string[];
118
- /** Defaults to "typescript". Set to "rust" for Anchor/Rust checker kinds. */
119
- lang?: "typescript" | "rust";
126
+ /**
127
+ * Defaults to "typescript". Set to "rust" for Anchor/Rust checker kinds,
128
+ * or "config" for whole-file config/env audits (see `filePatterns`).
129
+ */
130
+ lang?: "typescript" | "rust" | "config";
131
+ /**
132
+ * Required when `lang: "config"`. Regexes (matched against the file path
133
+ * relative to the scan root) selecting which files this rule audits,
134
+ * e.g. `["(^|/)\\.env(\\.[^/]+)?$"]`. Ignored for "typescript"/"rust".
135
+ */
136
+ filePatterns?: string[];
120
137
  /**
121
138
  * When true, a module import from `modules` is a REQUIRED condition for
122
139
  * detection: a candidate must be in a file that imports one of the listed
@@ -140,9 +157,18 @@ interface Rule {
140
157
  params?: Record<string, any>;
141
158
  };
142
159
  }
160
+ type Checker = (candidate: Candidate, params: any) => CheckOutcome;
161
+ type RustChecker = (candidate: RustCandidate, params: any) => CheckOutcome;
162
+ type ConfigChecker = (candidate: ConfigCandidate, params: any) => CheckOutcome;
163
+
164
+ type ChangedRanges = Map<string, Array<[number, number]>>;
165
+ declare function getChangedRanges(targetDir: string, ref: string): ChangedRanges;
166
+ declare function getWorkingTreeChanges(targetDir: string): ChangedRanges;
167
+ declare function fileChanged(ranges: ChangedRanges, file: string): boolean;
168
+ declare function rangeChanged(ranges: ChangedRanges, file: string, startLine: number, endLine: number): boolean;
143
169
 
144
- declare function auditWithRule(targetDir: string, rule: Rule): CheckResult[];
145
- declare function audit(targetDir: string, rules: Rule[]): {
170
+ declare function auditWithRule(targetDir: string, rule: Rule, changedRanges?: ChangedRanges): CheckResult[];
171
+ declare function audit(targetDir: string, rules: Rule[], changedRanges?: ChangedRanges): {
146
172
  checks: CheckResult[];
147
173
  report: {
148
174
  schemaVersion: string;
@@ -213,11 +239,43 @@ declare function renderTest(kind: string, opts: {
213
239
  }): string;
214
240
  declare const testKinds: string[];
215
241
 
216
- declare function runChecker(kind: string, c: Candidate | RustCandidate, params: any): CheckOutcome;
242
+ declare function runChecker(kind: string, c: Candidate | RustCandidate | ConfigCandidate, params: any): CheckOutcome;
217
243
  declare const checkerKinds: string[];
218
244
 
219
245
  declare function findCandidates(targetDir: string, rule: Rule): Candidate[];
220
246
 
247
+ declare function findConfigCandidates(targetDir: string, rule: Rule): ConfigCandidate[];
248
+
249
+ type WatchEvent = {
250
+ type: "watch_started";
251
+ targetDir: string;
252
+ } | {
253
+ type: "scan_error";
254
+ message: string;
255
+ } | {
256
+ type: "finding";
257
+ ruleId: string;
258
+ severity: string;
259
+ result: "fail" | "cant_tell";
260
+ file: string;
261
+ line: number;
262
+ detail: string;
263
+ fix?: unknown;
264
+ } | {
265
+ type: "scan_complete";
266
+ filesChanged: number;
267
+ findings: number;
268
+ durationMs: number;
269
+ };
270
+ interface WatchOptions {
271
+ debounceMs?: number;
272
+ emit?: (event: WatchEvent) => void;
273
+ }
274
+ declare function runIncrementalScan(targetDir: string, rules: Rule[], emit: (e: WatchEvent) => void): void;
275
+ declare function startWatch(targetDir: string, opts?: WatchOptions): {
276
+ close: () => void;
277
+ };
278
+
221
279
  type UpgradeAuthorityKind = "renounced" | "single-key" | "multisig" | "dao" | "unknown";
222
280
  type UpgradeAuthoritySource = "directory" | "rpc" | "research";
223
281
  interface UpgradeAuthority {
@@ -347,4 +405,4 @@ declare function isEntryExpired(entry: ProgramCacheEntry, ttlHoursOverride?: num
347
405
  */
348
406
  declare function cacheSize(cache: ProgramCache, ttlHoursOverride?: number): number;
349
407
 
350
- export { type AccountFlow, type AuditRef, type BuildOpts, type Candidate, type CheckOutcome, type CheckResult, type CheckResultKind, type CostReport, DEFAULT_TTL_HOURS, type OnChainProgram, type ParityNote, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type Severity, type TrustGraph, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, analyzeCosts, audit, auditWithRule, base58Decode, base58Encode, buildTrustGraph, rules as bundledRules, cacheSize, checkerKinds, defaultCachePath, findCandidates, generateTestForResult, getCacheEntry, getCacheEntryMeta, isEntryExpired, isValidSolanaAddress, lamportsToSol, loadDirectory, loadProgramCache, loadRules, putCacheEntry, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, saveProgramCache, testKinds };
408
+ export { type AccountFlow, type AuditRef, type BuildOpts, type Candidate, type ChangedRanges, type CheckOutcome, type CheckResult, type CheckResultKind, type Checker, type ConfigCandidate, type ConfigChecker, type CostReport, DEFAULT_TTL_HOURS, type OnChainProgram, type ParityNote, type PriorityFeePosture, type ProgramCache, type ProgramCacheEntry, type Recoverability, type Rule, type RustAccountField, type RustCandidate, type RustChecker, type Severity, type TrustGraph, type UpgradeAuthority, type UpgradeAuthorityKind, type UpgradeAuthoritySource, type VerifiedBuildState, type WatchEvent, type WatchOptions, analyzeCosts, audit, auditWithRule, base58Decode, base58Encode, buildTrustGraph, rules as bundledRules, cacheSize, checkerKinds, defaultCachePath, fileChanged, findCandidates, findConfigCandidates, generateTestForResult, getCacheEntry, getCacheEntryMeta, getChangedRanges, getWorkingTreeChanges, isEntryExpired, isValidSolanaAddress, lamportsToSol, loadDirectory, loadProgramCache, loadRules, putCacheEntry, rangeChanged, renderCostReportMd, renderTest, renderTrustGraphMd, rentExemptMinimum, resolveRules, runChecker, runIncrementalScan, saveProgramCache, startWatch, testKinds };
package/dist/index.js CHANGED
@@ -9,9 +9,13 @@ import {
9
9
  cacheSize,
10
10
  checkerKinds,
11
11
  defaultCachePath,
12
+ fileChanged,
12
13
  findCandidates,
14
+ findConfigCandidates,
13
15
  getCacheEntry,
14
16
  getCacheEntryMeta,
17
+ getChangedRanges,
18
+ getWorkingTreeChanges,
15
19
  isEntryExpired,
16
20
  isValidSolanaAddress,
17
21
  lamportsToSol,
@@ -19,6 +23,7 @@ import {
19
23
  loadProgramCache,
20
24
  loadRules,
21
25
  putCacheEntry,
26
+ rangeChanged,
22
27
  renderCostReportMd,
23
28
  renderTest,
24
29
  renderTrustGraphMd,
@@ -26,9 +31,11 @@ import {
26
31
  resolveRules,
27
32
  rules,
28
33
  runChecker,
34
+ runIncrementalScan,
29
35
  saveProgramCache,
36
+ startWatch,
30
37
  testKinds
31
- } from "./chunk-WVHGN2HR.js";
38
+ } from "./chunk-P7K7NRVN.js";
32
39
 
33
40
  // src/generate.ts
34
41
  import { writeFileSync, mkdirSync } from "fs";
@@ -55,10 +62,14 @@ export {
55
62
  cacheSize,
56
63
  checkerKinds,
57
64
  defaultCachePath,
65
+ fileChanged,
58
66
  findCandidates,
67
+ findConfigCandidates,
59
68
  generateTestForResult,
60
69
  getCacheEntry,
61
70
  getCacheEntryMeta,
71
+ getChangedRanges,
72
+ getWorkingTreeChanges,
62
73
  isEntryExpired,
63
74
  isValidSolanaAddress,
64
75
  lamportsToSol,
@@ -66,12 +77,15 @@ export {
66
77
  loadProgramCache,
67
78
  loadRules,
68
79
  putCacheEntry,
80
+ rangeChanged,
69
81
  renderCostReportMd,
70
82
  renderTest,
71
83
  renderTrustGraphMd,
72
84
  rentExemptMinimum,
73
85
  resolveRules,
74
86
  runChecker,
87
+ runIncrementalScan,
75
88
  saveProgramCache,
89
+ startWatch,
76
90
  testKinds
77
91
  };
@@ -0,0 +1,32 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: env-secrets-committed
3
+ severity: critical
4
+ title: Secret-looking values are not committed to source control
5
+ component:
6
+ name: Environment configuration
7
+ type: Config
8
+ version: unversioned
9
+ sourceUrl: https://12factor.net/config
10
+ detect:
11
+ lang: config
12
+ # .env, .env.local, .env.production, etc. — but not .env.example/.sample/.template,
13
+ # which are meant to be committed and document expected keys with placeholders.
14
+ filePatterns:
15
+ - "(^|/)\\.env(\\.(?!example$|sample$|template$)[^/]+)?$"
16
+ check:
17
+ kind: env-secrets-committed
18
+ params:
19
+ # Key names that typically hold credentials/secrets.
20
+ secretKeyPattern: "(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)"
21
+ # Values that look like placeholders, not real secrets — these are fine to commit.
22
+ placeholderPattern: "^(your[_-]|xxx|changeme|change[_-]?me|replace|example|<|sk_test_|pk_test_|test[_-]|dummy|placeholder|\\*+$|\\.\\.\\.$)"
23
+ ignoredDetail: >-
24
+ File is git-ignored and not committed to source control.
25
+ passDetail: >-
26
+ File is tracked but contains no secret-looking values (placeholders only).
27
+ failDetailPrefix: >-
28
+ This file is committed to source control and contains what look like real
29
+ secret values. Anyone with read access to the repo (including forks) can
30
+ read these credentials
31
+ test:
32
+ kind: none
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainblast",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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": [