brainblast 0.3.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-BZVZ3WAU.js → chunk-P7K7NRVN.js} +470 -60
- package/dist/cli.js +147 -11
- package/dist/index.d.ts +94 -6
- package/dist/index.js +15 -1
- package/dist/rules/env-secrets-committed.yaml +32 -0
- package/dist/rules/privy-jwt-verification.yaml +13 -1
- package/dist/rules/stripe-webhook-raw-body.yaml +5 -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) {
|
|
@@ -38,7 +49,13 @@ function findCandidates(targetDir, rule) {
|
|
|
38
49
|
const sf = project.addSourceFileAtPath(file);
|
|
39
50
|
const importsModule = sf.getImportDeclarations().some((d) => modules.has(d.getModuleSpecifierValue()));
|
|
40
51
|
const consider = (fn, name) => {
|
|
41
|
-
|
|
52
|
+
const hasName = !!(name && nameRe.test(name));
|
|
53
|
+
const hasTrigger = bodyCallsAnyOf(fn, triggers);
|
|
54
|
+
if (rule.detect.requiresImport) {
|
|
55
|
+
if (!(importsModule && (hasName || hasTrigger))) return;
|
|
56
|
+
} else {
|
|
57
|
+
if (!(hasName || hasTrigger)) return;
|
|
58
|
+
}
|
|
42
59
|
out.push({
|
|
43
60
|
filePath: file,
|
|
44
61
|
fnName: name || "(anonymous)",
|
|
@@ -55,6 +72,42 @@ function findCandidates(targetDir, rule) {
|
|
|
55
72
|
return out;
|
|
56
73
|
}
|
|
57
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
|
+
|
|
58
111
|
// src/checkers/positionalArgIdentity.ts
|
|
59
112
|
import { SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
60
113
|
var positionalArgIdentity = (c, p) => {
|
|
@@ -62,7 +115,20 @@ var positionalArgIdentity = (c, p) => {
|
|
|
62
115
|
const exp = call.getExpression();
|
|
63
116
|
return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
|
|
64
117
|
});
|
|
65
|
-
if (calls.length === 0)
|
|
118
|
+
if (calls.length === 0) {
|
|
119
|
+
const sf = c.fn.getSourceFile();
|
|
120
|
+
const existsInFile = sf.getDescendantsOfKind(SyntaxKind2.CallExpression).some((call) => {
|
|
121
|
+
const exp = call.getExpression();
|
|
122
|
+
return exp.getKind() === SyntaxKind2.PropertyAccessExpression && exp.asKind(SyntaxKind2.PropertyAccessExpression).getName() === p.call;
|
|
123
|
+
});
|
|
124
|
+
if (existsInFile) {
|
|
125
|
+
return {
|
|
126
|
+
result: "cant_tell",
|
|
127
|
+
detail: `${p.call} is called elsewhere in this file; unable to confirm this function's delegation path statically.`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { result: "fail", detail: p.absentDetail };
|
|
131
|
+
}
|
|
66
132
|
const arg = calls[0].getArguments()[p.argIndex];
|
|
67
133
|
const wantParam = c.params[p.paramIndex];
|
|
68
134
|
if (arg && wantParam && arg.getKind() === SyntaxKind2.Identifier && arg.getText() === wantParam) {
|
|
@@ -391,6 +457,36 @@ function anchorInitIfNeededGuarded(c, p) {
|
|
|
391
457
|
};
|
|
392
458
|
}
|
|
393
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
|
+
|
|
394
490
|
// src/checkers/index.ts
|
|
395
491
|
var registry = {
|
|
396
492
|
"positional-arg-identity": positionalArgIdentity,
|
|
@@ -398,7 +494,8 @@ var registry = {
|
|
|
398
494
|
"fee-allocation-shape": feeAllocationShape,
|
|
399
495
|
"arg-equals-constant-identifier": argEqualsConstantIdentifier,
|
|
400
496
|
"object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
|
|
401
|
-
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded
|
|
497
|
+
"anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
|
|
498
|
+
"env-secrets-committed": envSecretsCommitted
|
|
402
499
|
};
|
|
403
500
|
function runChecker(kind, c, params) {
|
|
404
501
|
const fn = registry[kind];
|
|
@@ -407,14 +504,89 @@ function runChecker(kind, c, params) {
|
|
|
407
504
|
}
|
|
408
505
|
var checkerKinds = Object.keys(registry);
|
|
409
506
|
|
|
410
|
-
// src/
|
|
411
|
-
import {
|
|
507
|
+
// src/gitDiff.ts
|
|
508
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
509
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
412
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";
|
|
413
585
|
import { createRequire } from "module";
|
|
414
586
|
function walkRust(dir, out = []) {
|
|
415
587
|
for (const entry of readdirSync2(dir)) {
|
|
416
588
|
if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
|
|
417
|
-
const p =
|
|
589
|
+
const p = join3(dir, entry);
|
|
418
590
|
const st = statSync2(p);
|
|
419
591
|
if (st.isDirectory()) walkRust(p, out);
|
|
420
592
|
else if (p.endsWith(".rs")) out.push(p);
|
|
@@ -494,7 +666,7 @@ function findRustCandidates(targetDir, rule) {
|
|
|
494
666
|
const out = [];
|
|
495
667
|
for (const file of walkRust(targetDir)) {
|
|
496
668
|
if (!file.endsWith(".rs")) continue;
|
|
497
|
-
const src =
|
|
669
|
+
const src = readFileSync3(file, "utf8");
|
|
498
670
|
const tree = parser.parse(src);
|
|
499
671
|
const structMap = /* @__PURE__ */ new Map();
|
|
500
672
|
const topPairs = itemsWithAttrs(tree.rootNode);
|
|
@@ -538,6 +710,136 @@ function findRustCandidates(targetDir, rule) {
|
|
|
538
710
|
return out;
|
|
539
711
|
}
|
|
540
712
|
|
|
713
|
+
// src/fixers/positionalArgIdentity.ts
|
|
714
|
+
import { SyntaxKind as SyntaxKind7 } from "ts-morph";
|
|
715
|
+
|
|
716
|
+
// src/fixers/diffUtil.ts
|
|
717
|
+
function buildDiff(node, replacement) {
|
|
718
|
+
const sf = node.getSourceFile();
|
|
719
|
+
const filePath = sf.getFilePath();
|
|
720
|
+
const fullText = sf.getFullText();
|
|
721
|
+
const start = node.getStart();
|
|
722
|
+
const end = node.getEnd();
|
|
723
|
+
const startPos = sf.getLineAndColumnAtPos(start);
|
|
724
|
+
const endPos = sf.getLineAndColumnAtPos(end);
|
|
725
|
+
const lines = fullText.split("\n");
|
|
726
|
+
const oldMiddle = lines.slice(startPos.line - 1, endPos.line);
|
|
727
|
+
const oldFirst = oldMiddle[0].slice(0, startPos.column - 1);
|
|
728
|
+
const oldLast = oldMiddle[oldMiddle.length - 1].slice(endPos.column - 1);
|
|
729
|
+
const newMiddle = (oldFirst + replacement + oldLast).split("\n");
|
|
730
|
+
const removed = oldMiddle.map((l) => `-${l}`);
|
|
731
|
+
const added = newMiddle.map((l) => `+${l}`);
|
|
732
|
+
const hunkHeader = `@@ -${startPos.line},${oldMiddle.length} +${startPos.line},${newMiddle.length} @@`;
|
|
733
|
+
return [`--- a${filePath}`, `+++ b${filePath}`, hunkHeader, ...removed, ...added].join("\n");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/fixers/positionalArgIdentity.ts
|
|
737
|
+
var fixPositionalArgIdentity = (c, p, outcome) => {
|
|
738
|
+
if (outcome.result !== "fail") return void 0;
|
|
739
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind7.CallExpression).filter((call) => {
|
|
740
|
+
const exp = call.getExpression();
|
|
741
|
+
return exp.getKind() === SyntaxKind7.PropertyAccessExpression && exp.asKind(SyntaxKind7.PropertyAccessExpression).getName() === p.call;
|
|
742
|
+
});
|
|
743
|
+
if (calls.length === 0) {
|
|
744
|
+
const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
|
|
745
|
+
return {
|
|
746
|
+
summary: `Add a ${p.call} call that verifies the raw request body`,
|
|
747
|
+
suggestion: `No '${p.call}' call was found in this handler. Verify the signature against the raw, unparsed request body \u2014 parameter '${wantParam2}' \u2014 before trusting the event, e.g.:
|
|
748
|
+
|
|
749
|
+
const event = stripe.webhooks.constructEvent(${wantParam2}, signature, process.env.STRIPE_WEBHOOK_SECRET!);
|
|
750
|
+
|
|
751
|
+
Do not call JSON.parse() on the body before this verification step.`
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const arg = calls[0].getArguments()[p.argIndex];
|
|
755
|
+
const wantParam = c.params[p.paramIndex];
|
|
756
|
+
if (arg && wantParam && arg.getKind() === SyntaxKind7.CallExpression) {
|
|
757
|
+
return {
|
|
758
|
+
summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
|
|
759
|
+
diff: buildDiff(arg, wantParam)
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
return void 0;
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
// src/fixers/requiredCallWithOptions.ts
|
|
766
|
+
import { SyntaxKind as SyntaxKind8 } from "ts-morph";
|
|
767
|
+
function callName4(call) {
|
|
768
|
+
const exp = call.getExpression();
|
|
769
|
+
if (exp.getKind() === SyntaxKind8.Identifier) return exp.getText();
|
|
770
|
+
if (exp.getKind() === SyntaxKind8.PropertyAccessExpression) {
|
|
771
|
+
return exp.asKind(SyntaxKind8.PropertyAccessExpression).getName();
|
|
772
|
+
}
|
|
773
|
+
return "";
|
|
774
|
+
}
|
|
775
|
+
function placeholderFor(propName) {
|
|
776
|
+
switch (propName) {
|
|
777
|
+
case "audience":
|
|
778
|
+
case "aud":
|
|
779
|
+
return `audience: process.env.PRIVY_APP_ID`;
|
|
780
|
+
case "issuer":
|
|
781
|
+
case "iss":
|
|
782
|
+
return `issuer: "https://privy.io"`;
|
|
783
|
+
default:
|
|
784
|
+
return `${propName}: undefined /* TODO: brainblast could not infer this value */`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
var fixRequiredCallWithOptions = (c, p, outcome) => {
|
|
788
|
+
if (outcome.result !== "fail") return void 0;
|
|
789
|
+
const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression);
|
|
790
|
+
const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
|
|
791
|
+
if (verify.length > 0) {
|
|
792
|
+
const call = verify[0];
|
|
793
|
+
const args = call.getArguments();
|
|
794
|
+
const lastArg = args[args.length - 1];
|
|
795
|
+
const obj = lastArg?.asKind(SyntaxKind8.ObjectLiteralExpression);
|
|
796
|
+
const presentNames = obj ? obj.getProperties().map((pr) => {
|
|
797
|
+
const pa = pr.asKind(SyntaxKind8.PropertyAssignment) ?? pr.asKind(SyntaxKind8.ShorthandPropertyAssignment);
|
|
798
|
+
return pa?.getName() ?? "";
|
|
799
|
+
}) : [];
|
|
800
|
+
const missingGroups = p.requiredProps.filter(
|
|
801
|
+
(g) => !g.some((n) => presentNames.includes(n))
|
|
802
|
+
);
|
|
803
|
+
if (missingGroups.length === 0) return void 0;
|
|
804
|
+
const newProps = missingGroups.map((g) => placeholderFor(g[0])).join(", ");
|
|
805
|
+
const summary = `Add ${missingGroups.map((g) => g[0]).join(" and ")} to the ${callName4(call)} call`;
|
|
806
|
+
if (obj) {
|
|
807
|
+
const inner = obj.getText().slice(1, -1).trim();
|
|
808
|
+
const newText = inner.length > 0 ? `{ ${inner}, ${newProps} }` : `{ ${newProps} }`;
|
|
809
|
+
return { summary, diff: buildDiff(obj, newText) };
|
|
810
|
+
}
|
|
811
|
+
if (lastArg) {
|
|
812
|
+
const newText = `${lastArg.getText()}, { ${newProps} }`;
|
|
813
|
+
return { summary, diff: buildDiff(lastArg, newText) };
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
summary,
|
|
817
|
+
suggestion: `Add an options object ({ ${newProps} }) as an argument to ${callName4(call)}.`
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
summary: "Replace the decode-only call with a verified call",
|
|
822
|
+
suggestion: `This token is decoded without verifying its signature, accepting any forged token. Replace the decode call with a verifying call that asserts audience and issuer, e.g.:
|
|
823
|
+
|
|
824
|
+
const { payload } = await jwtVerify(token, JWKS, { audience: process.env.PRIVY_APP_ID, issuer: "https://privy.io" });
|
|
825
|
+
|
|
826
|
+
JWKS must come from Privy's published JWKS endpoint for your app.`
|
|
827
|
+
};
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// src/fixers/index.ts
|
|
831
|
+
var registry2 = {
|
|
832
|
+
"positional-arg-identity": fixPositionalArgIdentity,
|
|
833
|
+
"required-call-with-options": fixRequiredCallWithOptions
|
|
834
|
+
};
|
|
835
|
+
function runFixer(kind, c, params, outcome) {
|
|
836
|
+
if (outcome.result !== "fail") return void 0;
|
|
837
|
+
const fn = registry2[kind];
|
|
838
|
+
if (!fn) return void 0;
|
|
839
|
+
return fn(c, params, outcome);
|
|
840
|
+
}
|
|
841
|
+
var fixerKinds = Object.keys(registry2);
|
|
842
|
+
|
|
541
843
|
// src/emit.ts
|
|
542
844
|
function buildReport(target, checks, rules2, costReport) {
|
|
543
845
|
const byId = new Map(rules2.map((r) => [r.id, r]));
|
|
@@ -584,7 +886,9 @@ function buildReport(target, checks, rules2, costReport) {
|
|
|
584
886
|
file: c.file,
|
|
585
887
|
line: c.line,
|
|
586
888
|
title: c.title,
|
|
587
|
-
detail: c.detail
|
|
889
|
+
detail: c.detail,
|
|
890
|
+
...c.fix ? { fix: c.fix } : {},
|
|
891
|
+
...c.precedent ? { precedent: c.precedent } : {}
|
|
588
892
|
})),
|
|
589
893
|
checkTotals,
|
|
590
894
|
openQuestions: [],
|
|
@@ -593,9 +897,28 @@ function buildReport(target, checks, rules2, costReport) {
|
|
|
593
897
|
}
|
|
594
898
|
|
|
595
899
|
// src/audit.ts
|
|
596
|
-
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
|
+
}
|
|
597
915
|
if (rule.detect.lang === "rust") {
|
|
598
|
-
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) => {
|
|
599
922
|
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
600
923
|
return {
|
|
601
924
|
ruleId: rule.id,
|
|
@@ -609,8 +932,12 @@ function auditWithRule(targetDir, rule) {
|
|
|
609
932
|
};
|
|
610
933
|
});
|
|
611
934
|
}
|
|
612
|
-
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) => {
|
|
613
939
|
const outcome = runChecker(rule.check.kind, c, rule.check.params);
|
|
940
|
+
const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
|
|
614
941
|
return {
|
|
615
942
|
ruleId: rule.id,
|
|
616
943
|
severity: rule.severity,
|
|
@@ -618,12 +945,13 @@ function auditWithRule(targetDir, rule) {
|
|
|
618
945
|
file: c.filePath,
|
|
619
946
|
line: c.fn.getStartLineNumber(),
|
|
620
947
|
exportName: c.fnName,
|
|
621
|
-
...outcome
|
|
948
|
+
...outcome,
|
|
949
|
+
...fix ? { fix } : {}
|
|
622
950
|
};
|
|
623
951
|
});
|
|
624
952
|
}
|
|
625
|
-
function audit(targetDir, rules2) {
|
|
626
|
-
const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
|
|
953
|
+
function audit(targetDir, rules2, changedRanges) {
|
|
954
|
+
const checks = rules2.flatMap((r) => auditWithRule(targetDir, r, changedRanges));
|
|
627
955
|
const report = buildReport(targetDir, checks, rules2);
|
|
628
956
|
return { checks, report };
|
|
629
957
|
}
|
|
@@ -902,29 +1230,35 @@ mod brainblast_reinit_guard_test {
|
|
|
902
1230
|
}
|
|
903
1231
|
`;
|
|
904
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
|
+
|
|
905
1238
|
// src/testTemplates/index.ts
|
|
906
|
-
var
|
|
1239
|
+
var registry3 = {
|
|
907
1240
|
"stripe-webhook-signature": stripeWebhookSignature,
|
|
908
1241
|
"privy-jwt-claims": privyJwtClaims,
|
|
909
1242
|
"bags-fee-share": bagsFeeShare,
|
|
910
1243
|
"token-program-consistency": tokenProgramConsistency,
|
|
911
1244
|
"metaplex-immutable-metadata": metaplexImmutableMetadata,
|
|
912
|
-
"anchor-program-test": anchorProgramTest
|
|
1245
|
+
"anchor-program-test": anchorProgramTest,
|
|
1246
|
+
none
|
|
913
1247
|
};
|
|
914
1248
|
var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
915
1249
|
function renderTest(kind, opts) {
|
|
916
|
-
const tpl =
|
|
1250
|
+
const tpl = registry3[kind];
|
|
917
1251
|
if (!tpl) throw new Error(`Unknown test template kind '${kind}'.`);
|
|
918
1252
|
if (!JS_IDENTIFIER.test(opts.handlerExport)) {
|
|
919
1253
|
throw new Error(`Unsafe handler export name '${opts.handlerExport}' (not a JS identifier).`);
|
|
920
1254
|
}
|
|
921
1255
|
return tpl(opts);
|
|
922
1256
|
}
|
|
923
|
-
var testKinds = Object.keys(
|
|
1257
|
+
var testKinds = Object.keys(registry3);
|
|
924
1258
|
|
|
925
1259
|
// src/loadRules.ts
|
|
926
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
927
|
-
import { join as
|
|
1260
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
|
|
1261
|
+
import { join as join4 } from "path";
|
|
928
1262
|
import { parse } from "yaml";
|
|
929
1263
|
var SEVERITIES = ["critical", "high", "medium", "low"];
|
|
930
1264
|
function validateRule(r, file) {
|
|
@@ -936,7 +1270,19 @@ function validateRule(r, file) {
|
|
|
936
1270
|
if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
|
|
937
1271
|
if (!r.title || typeof r.title !== "string") errs.push("missing title");
|
|
938
1272
|
if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
|
|
939
|
-
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)) {
|
|
940
1286
|
errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
|
|
941
1287
|
} else {
|
|
942
1288
|
try {
|
|
@@ -957,7 +1303,7 @@ function loadRules(dir) {
|
|
|
957
1303
|
const files = readdirSync3(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
|
|
958
1304
|
const rules2 = [];
|
|
959
1305
|
for (const f of files) {
|
|
960
|
-
const raw = parse(
|
|
1306
|
+
const raw = parse(readFileSync4(join4(dir, f), "utf8"));
|
|
961
1307
|
validateRule(raw, f);
|
|
962
1308
|
rules2.push(raw);
|
|
963
1309
|
}
|
|
@@ -966,23 +1312,23 @@ function loadRules(dir) {
|
|
|
966
1312
|
|
|
967
1313
|
// rules/index.ts
|
|
968
1314
|
import { existsSync } from "fs";
|
|
969
|
-
import { dirname, join as
|
|
1315
|
+
import { dirname, join as join5 } from "path";
|
|
970
1316
|
import { fileURLToPath } from "url";
|
|
971
1317
|
function bundledRulesDir() {
|
|
972
1318
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
973
|
-
if (existsSync(
|
|
974
|
-
const sub =
|
|
975
|
-
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;
|
|
976
1322
|
return here;
|
|
977
1323
|
}
|
|
978
1324
|
var rules = loadRules(bundledRulesDir());
|
|
979
1325
|
|
|
980
1326
|
// src/resolveRules.ts
|
|
981
1327
|
import { existsSync as existsSync2 } from "fs";
|
|
982
|
-
import { join as
|
|
1328
|
+
import { join as join6 } from "path";
|
|
983
1329
|
function resolveRules(targetDir) {
|
|
984
1330
|
const all = [...rules];
|
|
985
|
-
const projDir =
|
|
1331
|
+
const projDir = join6(targetDir, ".agent-research", "rules");
|
|
986
1332
|
if (existsSync2(projDir)) {
|
|
987
1333
|
const seen = new Set(all.map((r) => r.id));
|
|
988
1334
|
for (const r of loadRules(projDir)) {
|
|
@@ -998,19 +1344,19 @@ function resolveRules(targetDir) {
|
|
|
998
1344
|
}
|
|
999
1345
|
|
|
1000
1346
|
// src/trustGraph/directory.ts
|
|
1001
|
-
import { readFileSync as
|
|
1347
|
+
import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
|
|
1002
1348
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1003
|
-
import { join as
|
|
1349
|
+
import { join as join7 } from "path";
|
|
1004
1350
|
import { parse as parse2 } from "yaml";
|
|
1005
1351
|
var cache = null;
|
|
1006
1352
|
function bundledPath() {
|
|
1007
1353
|
const here = fileURLToPath2(new URL(".", import.meta.url));
|
|
1008
1354
|
const candidates = [
|
|
1009
|
-
|
|
1355
|
+
join7(here, "programs", "directory.yaml"),
|
|
1010
1356
|
// dist/programs/directory.yaml
|
|
1011
|
-
|
|
1357
|
+
join7(here, "..", "..", "programs", "directory.yaml"),
|
|
1012
1358
|
// src/../../programs/
|
|
1013
|
-
|
|
1359
|
+
join7(here, "..", "programs", "directory.yaml")
|
|
1014
1360
|
// fallback
|
|
1015
1361
|
];
|
|
1016
1362
|
for (const c of candidates) {
|
|
@@ -1020,7 +1366,7 @@ function bundledPath() {
|
|
|
1020
1366
|
}
|
|
1021
1367
|
function loadDirectory(path = bundledPath()) {
|
|
1022
1368
|
if (cache && path === bundledPath()) return cache;
|
|
1023
|
-
const raw = parse2(
|
|
1369
|
+
const raw = parse2(readFileSync5(path, "utf8"));
|
|
1024
1370
|
if (!raw || !Array.isArray(raw.programs)) {
|
|
1025
1371
|
throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
|
|
1026
1372
|
}
|
|
@@ -1091,14 +1437,14 @@ function isValidSolanaAddress(s) {
|
|
|
1091
1437
|
}
|
|
1092
1438
|
|
|
1093
1439
|
// src/trustGraph/programCache.ts
|
|
1094
|
-
import { readFileSync as
|
|
1095
|
-
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";
|
|
1096
1442
|
import { homedir } from "os";
|
|
1097
1443
|
var DEFAULT_TTL_HOURS = 168;
|
|
1098
1444
|
var SCHEMA_VERSION = "1.0";
|
|
1099
1445
|
function defaultCachePath() {
|
|
1100
1446
|
const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
|
|
1101
|
-
return envOverride ??
|
|
1447
|
+
return envOverride ?? join8(homedir(), ".brainblast", "program-cache.json");
|
|
1102
1448
|
}
|
|
1103
1449
|
function emptyCache() {
|
|
1104
1450
|
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
@@ -1107,7 +1453,7 @@ function loadProgramCache(cachePath) {
|
|
|
1107
1453
|
const path = cachePath ?? defaultCachePath();
|
|
1108
1454
|
if (!existsSync4(path)) return emptyCache();
|
|
1109
1455
|
try {
|
|
1110
|
-
const raw = JSON.parse(
|
|
1456
|
+
const raw = JSON.parse(readFileSync6(path, "utf8"));
|
|
1111
1457
|
if (raw?.schemaVersion !== SCHEMA_VERSION) {
|
|
1112
1458
|
return emptyCache();
|
|
1113
1459
|
}
|
|
@@ -1384,7 +1730,7 @@ function renderTrustGraphMd(g) {
|
|
|
1384
1730
|
}
|
|
1385
1731
|
|
|
1386
1732
|
// src/costAnalysis.ts
|
|
1387
|
-
import { Project as Project2, SyntaxKind as
|
|
1733
|
+
import { Project as Project2, SyntaxKind as SyntaxKind9 } from "ts-morph";
|
|
1388
1734
|
var LAMPORTS_PER_BYTE_YEAR = 3480;
|
|
1389
1735
|
var EXEMPTION_THRESHOLD = 2;
|
|
1390
1736
|
var OVERHEAD_BYTES = 128;
|
|
@@ -1465,11 +1811,11 @@ var KNOWN_FLOWS = [
|
|
|
1465
1811
|
}
|
|
1466
1812
|
];
|
|
1467
1813
|
var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1814
|
+
SyntaxKind9.ForStatement,
|
|
1815
|
+
SyntaxKind9.ForOfStatement,
|
|
1816
|
+
SyntaxKind9.ForInStatement,
|
|
1817
|
+
SyntaxKind9.WhileStatement,
|
|
1818
|
+
SyntaxKind9.DoStatement
|
|
1473
1819
|
]);
|
|
1474
1820
|
var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
|
|
1475
1821
|
function isInsideLoop(node) {
|
|
@@ -1477,12 +1823,12 @@ function isInsideLoop(node) {
|
|
|
1477
1823
|
while (cur) {
|
|
1478
1824
|
const k = cur.getKind?.();
|
|
1479
1825
|
if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
|
|
1480
|
-
return { scalable: true, note: `call is inside a ${
|
|
1826
|
+
return { scalable: true, note: `call is inside a ${SyntaxKind9[k]} \u2014 cost scales with loop iterations` };
|
|
1481
1827
|
}
|
|
1482
|
-
if (k ===
|
|
1828
|
+
if (k === SyntaxKind9.CallExpression) {
|
|
1483
1829
|
const expr = cur.getExpression?.();
|
|
1484
|
-
if (expr?.getKind?.() ===
|
|
1485
|
-
const name = expr.asKind?.(
|
|
1830
|
+
if (expr?.getKind?.() === SyntaxKind9.PropertyAccessExpression) {
|
|
1831
|
+
const name = expr.asKind?.(SyntaxKind9.PropertyAccessExpression)?.getName?.();
|
|
1486
1832
|
if (name && ARRAY_METHOD_LOOPS.has(name)) {
|
|
1487
1833
|
return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
|
|
1488
1834
|
}
|
|
@@ -1496,7 +1842,7 @@ function detectPriorityFee(targetDir) {
|
|
|
1496
1842
|
const project = new Project2({ skipAddingFilesFromTsConfig: true });
|
|
1497
1843
|
for (const file of walk(targetDir)) {
|
|
1498
1844
|
const sf = project.addSourceFileAtPath(file);
|
|
1499
|
-
const calls = sf.getDescendantsOfKind(
|
|
1845
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind9.CallExpression);
|
|
1500
1846
|
for (const ce of calls) {
|
|
1501
1847
|
const expr = ce.getExpression();
|
|
1502
1848
|
const text = expr.getText();
|
|
@@ -1524,22 +1870,22 @@ function detectAccountFlows(targetDir) {
|
|
|
1524
1870
|
const importedModules = new Set(
|
|
1525
1871
|
sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
|
|
1526
1872
|
);
|
|
1527
|
-
for (const ce of sf.getDescendantsOfKind(
|
|
1873
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
|
|
1528
1874
|
const expr = ce.getExpression();
|
|
1529
|
-
let
|
|
1530
|
-
if (expr.getKind() ===
|
|
1531
|
-
|
|
1532
|
-
} else if (expr.getKind() ===
|
|
1533
|
-
|
|
1875
|
+
let callName5 = null;
|
|
1876
|
+
if (expr.getKind() === SyntaxKind9.Identifier) {
|
|
1877
|
+
callName5 = expr.getText();
|
|
1878
|
+
} else if (expr.getKind() === SyntaxKind9.PropertyAccessExpression) {
|
|
1879
|
+
callName5 = expr.asKind(SyntaxKind9.PropertyAccessExpression).getName();
|
|
1534
1880
|
}
|
|
1535
|
-
if (!
|
|
1536
|
-
const known = callIndex.get(
|
|
1881
|
+
if (!callName5) continue;
|
|
1882
|
+
const known = callIndex.get(callName5);
|
|
1537
1883
|
if (!known) continue;
|
|
1538
1884
|
if (!importedModules.has(known.module)) continue;
|
|
1539
1885
|
const lamports = rentExemptMinimum(known.dataLen);
|
|
1540
1886
|
const { scalable, note } = isInsideLoop(ce);
|
|
1541
1887
|
flows.push({
|
|
1542
|
-
call:
|
|
1888
|
+
call: callName5,
|
|
1543
1889
|
module: known.module,
|
|
1544
1890
|
accountType: known.accountType,
|
|
1545
1891
|
file,
|
|
@@ -1626,10 +1972,72 @@ function renderCostReportMd(r) {
|
|
|
1626
1972
|
return lines.join("\n");
|
|
1627
1973
|
}
|
|
1628
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
|
+
|
|
1629
2032
|
export {
|
|
1630
2033
|
findCandidates,
|
|
2034
|
+
findConfigCandidates,
|
|
1631
2035
|
runChecker,
|
|
1632
2036
|
checkerKinds,
|
|
2037
|
+
getChangedRanges,
|
|
2038
|
+
getWorkingTreeChanges,
|
|
2039
|
+
fileChanged,
|
|
2040
|
+
rangeChanged,
|
|
1633
2041
|
auditWithRule,
|
|
1634
2042
|
audit,
|
|
1635
2043
|
renderTest,
|
|
@@ -1655,5 +2063,7 @@ export {
|
|
|
1655
2063
|
rentExemptMinimum,
|
|
1656
2064
|
lamportsToSol,
|
|
1657
2065
|
analyzeCosts,
|
|
1658
|
-
renderCostReportMd
|
|
2066
|
+
renderCostReportMd,
|
|
2067
|
+
runIncrementalScan,
|
|
2068
|
+
startWatch
|
|
1659
2069
|
};
|
package/dist/cli.js
CHANGED
|
@@ -5,40 +5,176 @@ 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
|
-
import { writeFileSync, mkdirSync } from "fs";
|
|
18
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
19
|
+
import { join as join2 } from "path";
|
|
20
|
+
|
|
21
|
+
// src/memory.ts
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
17
23
|
import { join } from "path";
|
|
24
|
+
var EMPTY_MEMORY = { schemaVersion: "1.0", lastRun: [], fixHistory: [] };
|
|
25
|
+
function memoryPath(targetDir2) {
|
|
26
|
+
return join(targetDir2, ".agent-research", "memory.json");
|
|
27
|
+
}
|
|
28
|
+
function loadMemory(targetDir2) {
|
|
29
|
+
const p = memoryPath(targetDir2);
|
|
30
|
+
if (!existsSync(p)) return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
33
|
+
return {
|
|
34
|
+
schemaVersion: parsed.schemaVersion ?? "1.0",
|
|
35
|
+
lastRun: Array.isArray(parsed.lastRun) ? parsed.lastRun : [],
|
|
36
|
+
fixHistory: Array.isArray(parsed.fixHistory) ? parsed.fixHistory : []
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return { ...EMPTY_MEMORY, lastRun: [], fixHistory: [] };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function saveMemory(targetDir2, memory) {
|
|
43
|
+
mkdirSync(join(targetDir2, ".agent-research"), { recursive: true });
|
|
44
|
+
writeFileSync(memoryPath(targetDir2), JSON.stringify(memory, null, 2));
|
|
45
|
+
}
|
|
46
|
+
var snapshotKey = (e) => `${e.ruleId}::${e.file}::${e.exportName}`;
|
|
47
|
+
function precedentKey(c) {
|
|
48
|
+
return `${c.ruleId}::${c.file}`;
|
|
49
|
+
}
|
|
50
|
+
function updateMemory(memory, checks2, now = /* @__PURE__ */ new Date()) {
|
|
51
|
+
const prevByKey = new Map(memory.lastRun.map((e) => [snapshotKey(e), e]));
|
|
52
|
+
const fixedAt = now.toISOString().slice(0, 10);
|
|
53
|
+
const newFixEvents = [];
|
|
54
|
+
for (const c of checks2) {
|
|
55
|
+
const prev = prevByKey.get(snapshotKey(c));
|
|
56
|
+
if (prev?.result === "fail" && c.result !== "fail") {
|
|
57
|
+
newFixEvents.push({
|
|
58
|
+
ruleId: c.ruleId,
|
|
59
|
+
file: c.file,
|
|
60
|
+
exportName: c.exportName,
|
|
61
|
+
fixedAt,
|
|
62
|
+
detail: prev.detail
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const fixHistory = [...memory.fixHistory, ...newFixEvents];
|
|
67
|
+
const precedents = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const c of checks2) {
|
|
69
|
+
if (c.result !== "fail") continue;
|
|
70
|
+
const pk = precedentKey(c);
|
|
71
|
+
if (precedents.has(pk)) continue;
|
|
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);
|
|
73
|
+
if (matches[0]) {
|
|
74
|
+
precedents.set(pk, {
|
|
75
|
+
file: matches[0].file,
|
|
76
|
+
exportName: matches[0].exportName,
|
|
77
|
+
fixedAt: matches[0].fixedAt,
|
|
78
|
+
detail: matches[0].detail
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const lastRun = checks2.map((c) => ({
|
|
83
|
+
ruleId: c.ruleId,
|
|
84
|
+
file: c.file,
|
|
85
|
+
exportName: c.exportName,
|
|
86
|
+
result: c.result,
|
|
87
|
+
detail: c.detail
|
|
88
|
+
}));
|
|
89
|
+
return { memory: { schemaVersion: "1.0", lastRun, fixHistory }, precedents };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/cli.ts
|
|
18
93
|
var args = process.argv.slice(2);
|
|
19
94
|
if (args[0] === "trust-graph") {
|
|
20
95
|
await runTrustGraph(args.slice(1));
|
|
21
96
|
process.exit(0);
|
|
22
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
|
+
}
|
|
23
106
|
var ci = args.includes("--ci");
|
|
24
107
|
var strict = args.includes("--strict");
|
|
25
|
-
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
|
+
}
|
|
26
115
|
var rules = resolveRules(targetDir);
|
|
27
|
-
var
|
|
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
|
+
}
|
|
124
|
+
}
|
|
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
|
+
}
|
|
149
|
+
}
|
|
28
150
|
var costReport = analyzeCosts(targetDir);
|
|
29
151
|
report.costAnalysis = costReport;
|
|
30
|
-
var outDir =
|
|
31
|
-
|
|
32
|
-
var reportPath =
|
|
33
|
-
|
|
34
|
-
var costMdPath =
|
|
35
|
-
|
|
152
|
+
var outDir = join2(targetDir, ".agent-research");
|
|
153
|
+
mkdirSync2(outDir, { recursive: true });
|
|
154
|
+
var reportPath = join2(outDir, "report.json");
|
|
155
|
+
writeFileSync2(reportPath, JSON.stringify(report, null, 2));
|
|
156
|
+
var costMdPath = join2(outDir, "cost-analysis.md");
|
|
157
|
+
writeFileSync2(costMdPath, renderCostReportMd(costReport));
|
|
36
158
|
console.log(`brainblast: scanned ${targetDir} with ${rules.length} rule(s)`);
|
|
37
159
|
if (checks.length === 0) console.log(" (no catastrophic components detected)");
|
|
38
160
|
for (const c of checks) {
|
|
39
161
|
const tag = c.result === "pass" ? "PASS " : c.result === "fail" ? "FAIL " : "WARN ";
|
|
40
162
|
console.log(` [${tag}] ${c.ruleId} ${c.file}:${c.line}`);
|
|
41
163
|
console.log(` ${c.detail}`);
|
|
164
|
+
if (c.precedent) {
|
|
165
|
+
console.log(
|
|
166
|
+
` memory: same issue (${c.ruleId}) was fixed in ${c.precedent.file} on ${c.precedent.fixedAt}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (c.fix) {
|
|
170
|
+
console.log(` fix: ${c.fix.summary}`);
|
|
171
|
+
if (c.fix.diff) {
|
|
172
|
+
for (const line of c.fix.diff.split("\n")) console.log(` ${line}`);
|
|
173
|
+
}
|
|
174
|
+
if (c.fix.suggestion) {
|
|
175
|
+
for (const line of c.fix.suggestion.split("\n")) console.log(` ${line}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
42
178
|
}
|
|
43
179
|
var fails = checks.filter((c) => c.result === "fail").length;
|
|
44
180
|
var cantTell = checks.filter((c) => c.result === "cant_tell").length;
|
package/dist/index.d.ts
CHANGED
|
@@ -74,10 +74,29 @@ 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;
|
|
80
88
|
}
|
|
89
|
+
interface Fix {
|
|
90
|
+
summary: string;
|
|
91
|
+
diff?: string;
|
|
92
|
+
suggestion?: string;
|
|
93
|
+
}
|
|
94
|
+
interface Precedent {
|
|
95
|
+
file: string;
|
|
96
|
+
exportName: string;
|
|
97
|
+
fixedAt: string;
|
|
98
|
+
detail: string;
|
|
99
|
+
}
|
|
81
100
|
interface CheckResult extends CheckOutcome {
|
|
82
101
|
ruleId: string;
|
|
83
102
|
severity: Severity;
|
|
@@ -85,6 +104,10 @@ interface CheckResult extends CheckOutcome {
|
|
|
85
104
|
file: string;
|
|
86
105
|
line: number;
|
|
87
106
|
exportName: string;
|
|
107
|
+
/** Present when result === "fail" and a vetted fixer for check.kind produced one. */
|
|
108
|
+
fix?: Fix;
|
|
109
|
+
/** Present when result === "fail" and the same rule was previously fixed elsewhere. */
|
|
110
|
+
precedent?: Precedent;
|
|
88
111
|
}
|
|
89
112
|
interface Rule {
|
|
90
113
|
id: string;
|
|
@@ -100,8 +123,30 @@ interface Rule {
|
|
|
100
123
|
modules: string[];
|
|
101
124
|
nameRegex: string;
|
|
102
125
|
triggerCalls: string[];
|
|
103
|
-
/**
|
|
104
|
-
|
|
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[];
|
|
137
|
+
/**
|
|
138
|
+
* When true, a module import from `modules` is a REQUIRED condition for
|
|
139
|
+
* detection: a candidate must be in a file that imports one of the listed
|
|
140
|
+
* modules, AND either its name matches nameRegex or its body calls a
|
|
141
|
+
* triggerCall. This prevents generic name-only matches (e.g. a Fastify
|
|
142
|
+
* middleware named "verifyJwt" that calls `request.jwtVerify()`) from
|
|
143
|
+
* triggering jose-specific rules.
|
|
144
|
+
*
|
|
145
|
+
* When false or omitted (default), detection is: nameRegex match OR
|
|
146
|
+
* triggerCall in body. Module import is not required, so rules like
|
|
147
|
+
* stripe-webhook can still catch handlers that don't import stripe directly.
|
|
148
|
+
*/
|
|
149
|
+
requiresImport?: boolean;
|
|
105
150
|
};
|
|
106
151
|
check: {
|
|
107
152
|
kind: string;
|
|
@@ -112,9 +157,18 @@ interface Rule {
|
|
|
112
157
|
params?: Record<string, any>;
|
|
113
158
|
};
|
|
114
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;
|
|
115
169
|
|
|
116
|
-
declare function auditWithRule(targetDir: string, rule: Rule): CheckResult[];
|
|
117
|
-
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): {
|
|
118
172
|
checks: CheckResult[];
|
|
119
173
|
report: {
|
|
120
174
|
schemaVersion: string;
|
|
@@ -150,6 +204,8 @@ declare function audit(targetDir: string, rules: Rule[]): {
|
|
|
150
204
|
low: number;
|
|
151
205
|
};
|
|
152
206
|
checks: {
|
|
207
|
+
precedent?: Precedent | undefined;
|
|
208
|
+
fix?: Fix | undefined;
|
|
153
209
|
ruleId: string;
|
|
154
210
|
severity: Severity;
|
|
155
211
|
result: CheckResultKind;
|
|
@@ -183,11 +239,43 @@ declare function renderTest(kind: string, opts: {
|
|
|
183
239
|
}): string;
|
|
184
240
|
declare const testKinds: string[];
|
|
185
241
|
|
|
186
|
-
declare function runChecker(kind: string, c: Candidate | RustCandidate, params: any): CheckOutcome;
|
|
242
|
+
declare function runChecker(kind: string, c: Candidate | RustCandidate | ConfigCandidate, params: any): CheckOutcome;
|
|
187
243
|
declare const checkerKinds: string[];
|
|
188
244
|
|
|
189
245
|
declare function findCandidates(targetDir: string, rule: Rule): Candidate[];
|
|
190
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
|
+
|
|
191
279
|
type UpgradeAuthorityKind = "renounced" | "single-key" | "multisig" | "dao" | "unknown";
|
|
192
280
|
type UpgradeAuthoritySource = "directory" | "rpc" | "research";
|
|
193
281
|
interface UpgradeAuthority {
|
|
@@ -317,4 +405,4 @@ declare function isEntryExpired(entry: ProgramCacheEntry, ttlHoursOverride?: num
|
|
|
317
405
|
*/
|
|
318
406
|
declare function cacheSize(cache: ProgramCache, ttlHoursOverride?: number): number;
|
|
319
407
|
|
|
320
|
-
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
|
|
@@ -14,7 +14,19 @@ detect:
|
|
|
14
14
|
# Privy handlers are still picked up by the @privy-io/* / jose / jsonwebtoken
|
|
15
15
|
# imports, and by triggerCalls like decodeJwt / jwtVerify.
|
|
16
16
|
nameRegex: "privy|jwt"
|
|
17
|
-
|
|
17
|
+
# Only decodeJwt triggers detection; it's specific to jose and unambiguously
|
|
18
|
+
# indicates a decode-only pattern. jwtVerify/verify/decode were removed because:
|
|
19
|
+
# - "verify" matches Node.js crypto.verify(), jsonwebtoken jwt.verify(), etc.
|
|
20
|
+
# - "decode" matches bs58.decode() (Solana), Buffer.from(), etc.
|
|
21
|
+
# - "jwtVerify" matches Fastify's request.jwtVerify() plugin method, which is
|
|
22
|
+
# not a jose-based call and requires no audience/issuer options.
|
|
23
|
+
# Functions that import jose/@privy-io/* and are named with "privy|jwt" are still
|
|
24
|
+
# detected via requiresImport + (importsModule && hasName), the primary detection path.
|
|
25
|
+
triggerCalls: [decodeJwt]
|
|
26
|
+
# A module import is REQUIRED to be a candidate. Without this guard, a Fastify
|
|
27
|
+
# middleware named "verifyJwt" (which calls request.jwtVerify(), a completely
|
|
28
|
+
# different library) would match the nameRegex and be flagged as missing aud/iss.
|
|
29
|
+
requiresImport: true
|
|
18
30
|
check:
|
|
19
31
|
kind: required-call-with-options
|
|
20
32
|
params:
|
|
@@ -11,6 +11,11 @@ detect:
|
|
|
11
11
|
modules: [stripe]
|
|
12
12
|
nameRegex: webhook
|
|
13
13
|
triggerCalls: [constructEvent]
|
|
14
|
+
# Require the file to import stripe. Without this guard, any function whose
|
|
15
|
+
# name contains "webhook" — even LemonSqueezy, Polar, or Sendgrid handlers —
|
|
16
|
+
# is flagged for missing stripe.webhooks.constructEvent, producing false
|
|
17
|
+
# positives on non-Stripe payment integrations.
|
|
18
|
+
requiresImport: true
|
|
14
19
|
check:
|
|
15
20
|
kind: positional-arg-identity
|
|
16
21
|
params:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainblast",
|
|
3
|
-
"version": "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": [
|