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 +50 -2
- package/dist/{chunk-WVHGN2HR.js → chunk-P7K7NRVN.js} +290 -33
- package/dist/cli.js +60 -22
- package/dist/index.d.ts +64 -6
- package/dist/index.js +15 -1
- package/dist/rules/env-secrets-committed.yaml +32 -0
- package/package.json +1 -1
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 (
|
|
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 (
|
|
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/
|
|
430
|
-
import {
|
|
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 =
|
|
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 =
|
|
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).
|
|
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).
|
|
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
|
|
1080
|
-
import { join as
|
|
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 (
|
|
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(
|
|
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
|
|
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(
|
|
1127
|
-
const sub =
|
|
1128
|
-
if (existsSync(
|
|
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
|
|
1328
|
+
import { join as join6 } from "path";
|
|
1136
1329
|
function resolveRules(targetDir) {
|
|
1137
1330
|
const all = [...rules];
|
|
1138
|
-
const projDir =
|
|
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
|
|
1347
|
+
import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
|
|
1155
1348
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1156
|
-
import { join as
|
|
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
|
-
|
|
1355
|
+
join7(here, "programs", "directory.yaml"),
|
|
1163
1356
|
// dist/programs/directory.yaml
|
|
1164
|
-
|
|
1357
|
+
join7(here, "..", "..", "programs", "directory.yaml"),
|
|
1165
1358
|
// src/../../programs/
|
|
1166
|
-
|
|
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(
|
|
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
|
|
1248
|
-
import { join as
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
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,
|
|
42
|
+
function saveMemory(targetDir2, memory) {
|
|
41
43
|
mkdirSync(join(targetDir2, ".agent-research"), { recursive: true });
|
|
42
|
-
writeFileSync(memoryPath(targetDir2), JSON.stringify(
|
|
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(
|
|
49
|
-
const prevByKey = new Map(
|
|
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 = [...
|
|
65
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
/**
|
|
119
|
-
|
|
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-
|
|
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.
|
|
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": [
|