brainblast 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,7 +99,8 @@ the applied changes.
99
99
  | Rule | What's wrong | Consequence |
100
100
  |------|--------------|-------------|
101
101
  | `env-secrets-committed` | A `.env*` file (not `.env.example`/`.sample`/`.template`) is tracked by git and contains a secret-shaped key (`SECRET`, `*_PRIVATE_KEY`, `*_API_KEY`, `*_TOKEN`, `*_PASSWORD`, etc.) with a real-looking (non-placeholder) value | Anyone with read access to the repo — including forks of a public repo — can read the live credential |
102
- | `env-secret-leaked-to-sink` | A secret-shaped `process.env.X` value (directly, via a local variable, or one hop through a same-file helper) is passed to `console.log`/`res.json`/`res.send`/etc. | Credentials end up in logs, error trackers, or API responses — readable by anyone with log/response access |
102
+ | `env-secret-leaked-to-sink` | A secret-shaped `process.env.X` value flows — directly, via a local variable, forward through helper functions (same-file or imported from another file), or backward into a function that's called elsewhere in the project with a tainted argument — into `console.log`/`res.json`/`res.send`/etc., up to 2 hops across the whole project | Credentials end up in logs, error trackers, or API responses — readable by anyone with log/response access |
103
+ | `request-input-command-injection` | Untrusted `req.body`/`req.query`/`req.params`/`req.headers` data flows — directly or across files — into `exec`/`execSync`/`spawn`/`spawnSync`/`execFile`/`execFileSync` | A malicious request can run arbitrary shell commands on the server |
103
104
 
104
105
  Each finding lands in `.agent-research/report.json` (stable `schemaVersion: "1.0"`)
105
106
  with a `checks[]` array a CI gate can read. Each confirmed FAIL ships a
@@ -183,7 +184,7 @@ All types are exported: `Rule`, `CheckResult`, `CostReport`, `AccountFlow`,
183
184
 
184
185
  ```sh
185
186
  npm install
186
- npm test # unit suite (173 tests)
187
+ npm test # unit suite (180 tests)
187
188
  npm run prove # end-to-end: generated tests RED on vulnerable, GREEN on fixed
188
189
  npm run build # produce dist/ (the published artifact)
189
190
  ```
@@ -487,7 +487,7 @@ var envSecretsCommitted = (c, p) => {
487
487
  return { result: "pass", detail: p.passDetail ?? "No committed secret-looking values found." };
488
488
  };
489
489
 
490
- // src/checkers/envTaintToSink.ts
490
+ // src/checkers/taintToSink.ts
491
491
  import { SyntaxKind as SyntaxKind7 } from "ts-morph";
492
492
  function calleeName(call) {
493
493
  const exp = call.getExpression();
@@ -497,59 +497,65 @@ function calleeName(call) {
497
497
  }
498
498
  return "";
499
499
  }
500
- function envVarIn(text) {
501
- const m = text.match(/process\.env\.([A-Za-z0-9_]+)/);
502
- return m?.[1];
500
+ function calleeIdentifierName(call) {
501
+ const exp = call.getExpression();
502
+ return exp.getKind() === SyntaxKind7.Identifier ? exp.getText() : void 0;
503
503
  }
504
504
  function wordIn(text, name) {
505
505
  return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text);
506
506
  }
507
- function findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames) {
507
+ function matchesSource(text, sourceRes) {
508
+ return sourceRes.some((re) => re.test(text));
509
+ }
510
+ function localTaintedNames(fn, sourceRes) {
511
+ const names = /* @__PURE__ */ new Set();
512
+ for (const decl of fn.getDescendantsOfKind(SyntaxKind7.VariableDeclaration)) {
513
+ const init = decl.getInitializer();
514
+ if (init && matchesSource(init.getText(), sourceRes)) names.add(decl.getName());
515
+ }
516
+ return names;
517
+ }
518
+ function findDirectLeak(fn, sinkCalls, sourceRes, taintedNames) {
508
519
  for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
509
520
  const name = calleeName(call);
510
521
  if (!sinkCalls.has(name)) continue;
511
522
  for (const arg of call.getArguments()) {
512
523
  const text = arg.getText();
513
- const envVar = envVarIn(text);
514
- if (envVar && secretKeyRe.test(envVar)) {
515
- return `process.env.${envVar} is passed directly to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
524
+ if (matchesSource(text, sourceRes)) {
525
+ return `'${text}' is passed directly to ${name}(...) \u2014 tainted values must not reach this sink.`;
516
526
  }
517
527
  for (const tv of taintedNames) {
518
528
  if (wordIn(text, tv)) {
519
- return `'${tv}' (holding a secret-shaped process.env value) is passed to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
529
+ return `'${tv}' (a tainted value) is passed to ${name}(...) \u2014 tainted values must not reach this sink.`;
520
530
  }
521
531
  }
522
532
  }
523
533
  }
524
534
  return void 0;
525
535
  }
526
- var envTaintToSink = (c, p) => {
527
- const sinkCalls = new Set(p.sinkCalls ?? []);
528
- const secretKeyRe = new RegExp(p.secretKeyPattern, "i");
529
- const fn = c.fn;
530
- const taintedNames = /* @__PURE__ */ new Set();
531
- for (const decl of fn.getDescendantsOfKind(SyntaxKind7.VariableDeclaration)) {
532
- const init = decl.getInitializer();
533
- if (!init) continue;
534
- const envVar = envVarIn(init.getText());
535
- if (envVar && secretKeyRe.test(envVar)) {
536
- taintedNames.add(decl.getName());
537
- }
536
+ function resolveFunction(sourceFile, name) {
537
+ const local = sourceFile.getFunction(name);
538
+ if (local) return { fn: local, sf: sourceFile };
539
+ for (const imp of sourceFile.getImportDeclarations()) {
540
+ const named2 = imp.getNamedImports().find((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name);
541
+ if (!named2) continue;
542
+ const targetSf = imp.getModuleSpecifierSourceFile();
543
+ if (!targetSf) continue;
544
+ const targetFn = targetSf.getFunction(named2.getName());
545
+ if (targetFn) return { fn: targetFn, sf: targetSf };
538
546
  }
539
- const direct = findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames);
540
- if (direct) return { result: "fail", detail: direct };
541
- const sourceFile = fn.getSourceFile();
547
+ return void 0;
548
+ }
549
+ function findForwardLeak(fn, rootName, sinkCalls, sourceRes, taintedNames, hopsLeft, visited) {
550
+ if (hopsLeft <= 0) return void 0;
542
551
  for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
543
- const calleeExp = call.getExpression();
544
- if (calleeExp.getKind() !== SyntaxKind7.Identifier) continue;
545
- const calleeFnName = calleeExp.getText();
546
- if (calleeFnName === (c.fnName ?? "")) continue;
552
+ const name = calleeIdentifierName(call);
553
+ if (!name || name === rootName) continue;
547
554
  const args = call.getArguments();
548
555
  const taintedArgIndices = [];
549
556
  args.forEach((arg, i) => {
550
557
  const text = arg.getText();
551
- const envVar = envVarIn(text);
552
- if (envVar && secretKeyRe.test(envVar)) {
558
+ if (matchesSource(text, sourceRes)) {
553
559
  taintedArgIndices.push(i);
554
560
  return;
555
561
  }
@@ -561,20 +567,99 @@ var envTaintToSink = (c, p) => {
561
567
  }
562
568
  });
563
569
  if (taintedArgIndices.length === 0) continue;
564
- const calleeFn = sourceFile.getFunction(calleeFnName);
565
- if (!calleeFn) continue;
566
- const params = calleeFn.getParameters().map((pr) => pr.getName());
567
- const calleeTainted = new Set(taintedArgIndices.map((i) => params[i]).filter((x) => !!x));
570
+ const resolved = resolveFunction(fn.getSourceFile(), name);
571
+ if (!resolved) continue;
572
+ const key = `${resolved.sf.getFilePath()}::${name}`;
573
+ if (visited.has(key)) continue;
574
+ visited.add(key);
575
+ const params = resolved.fn.getParameters().map((pr) => pr.getName());
576
+ const calleeTainted = new Set(
577
+ taintedArgIndices.map((i) => params[i]).filter((x) => !!x)
578
+ );
568
579
  if (calleeTainted.size === 0) continue;
569
- const hop = findDirectLeak(calleeFn, sinkCalls, secretKeyRe, calleeTainted);
570
- if (hop) {
571
- return {
572
- result: "fail",
573
- detail: `A secret-shaped process.env value flows into '${calleeFnName}(...)' (called from '${c.fnName}'), where ${hop}`
574
- };
580
+ const direct = findDirectLeak(resolved.fn, sinkCalls, sourceRes, calleeTainted);
581
+ if (direct) {
582
+ const where = resolved.sf === fn.getSourceFile() ? "" : ` (in ${resolved.sf.getFilePath()})`;
583
+ return `A tainted value flows into '${name}(...)'${where}, where ${direct}`;
584
+ }
585
+ const deeper = findForwardLeak(resolved.fn, rootName, sinkCalls, sourceRes, calleeTainted, hopsLeft - 1, visited);
586
+ if (deeper) return `via '${name}(...)': ${deeper}`;
587
+ }
588
+ return void 0;
589
+ }
590
+ function paramsUsedInSink(fn, sinkCalls) {
591
+ const params = new Set(fn.getParameters().map((p) => p.getName()));
592
+ const sinked = /* @__PURE__ */ new Set();
593
+ for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
594
+ if (!sinkCalls.has(calleeName(call))) continue;
595
+ for (const arg of call.getArguments()) {
596
+ const text = arg.getText();
597
+ for (const p of params) {
598
+ if (wordIn(text, p)) sinked.add(p);
599
+ }
575
600
  }
576
601
  }
577
- return { result: "pass", detail: "No secret-shaped process.env value flows to a logging/response sink." };
602
+ return sinked;
603
+ }
604
+ function enclosingFunction(node) {
605
+ return node.getFirstAncestor(
606
+ (a) => a.getKind() === SyntaxKind7.FunctionDeclaration || a.getKind() === SyntaxKind7.ArrowFunction
607
+ );
608
+ }
609
+ function findBackwardLeak(candidateFn, fnName, candidateFile, params, sinkedParams, sourceRes) {
610
+ const project = candidateFn.getProject();
611
+ for (const sf of project.getSourceFiles()) {
612
+ for (const call of sf.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
613
+ if (calleeIdentifierName(call) !== fnName) continue;
614
+ if (call.getFirstAncestor((a) => a === candidateFn)) continue;
615
+ const args = call.getArguments();
616
+ for (const pname of sinkedParams) {
617
+ const idx = params.indexOf(pname);
618
+ const arg = args[idx];
619
+ if (!arg) continue;
620
+ const text = arg.getText();
621
+ if (matchesSource(text, sourceRes)) {
622
+ return `'${fnName}' is called from ${sf.getFilePath()}:${call.getStartLineNumber()} with '${text}' as '${pname}', which this function passes to a sink.`;
623
+ }
624
+ if (arg.getKind() === SyntaxKind7.Identifier) {
625
+ const callerFn = enclosingFunction(arg);
626
+ if (callerFn) {
627
+ const callerTainted = localTaintedNames(callerFn, sourceRes);
628
+ if (callerTainted.has(text)) {
629
+ return `'${fnName}' is called from ${sf.getFilePath()}:${call.getStartLineNumber()} with '${text}' (a tainted value) as '${pname}', which this function passes to a sink.`;
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+ }
636
+ return void 0;
637
+ }
638
+ var taintToSink = (c, p) => {
639
+ const sourceRes = p.sources.map((s) => new RegExp(s.pattern));
640
+ const sinkCalls = new Set(p.sinkCalls ?? []);
641
+ const maxHops = p.maxHops ?? 2;
642
+ const fn = c.fn;
643
+ const taintedNames = localTaintedNames(fn, sourceRes);
644
+ const direct = findDirectLeak(fn, sinkCalls, sourceRes, taintedNames);
645
+ if (direct) return { result: "fail", detail: direct };
646
+ const forward = findForwardLeak(fn, c.fnName, sinkCalls, sourceRes, taintedNames, maxHops, /* @__PURE__ */ new Set([
647
+ `${fn.getSourceFile().getFilePath()}::${c.fnName}`
648
+ ]));
649
+ if (forward) return { result: "fail", detail: forward };
650
+ const sinkedParams = paramsUsedInSink(fn, sinkCalls);
651
+ if (sinkedParams.size > 0) {
652
+ const backward = findBackwardLeak(
653
+ fn,
654
+ c.fnName,
655
+ c.filePath,
656
+ fn.getParameters().map((pr) => pr.getName()),
657
+ sinkedParams,
658
+ sourceRes
659
+ );
660
+ if (backward) return { result: "fail", detail: backward };
661
+ }
662
+ return { result: "pass", detail: "No tracked source value flows to a sink within the analyzed call graph." };
578
663
  };
579
664
 
580
665
  // src/checkers/index.ts
@@ -586,7 +671,7 @@ var registry = {
586
671
  "object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
587
672
  "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
588
673
  "env-secrets-committed": envSecretsCommitted,
589
- "env-taint-to-sink": envTaintToSink
674
+ "taint-to-sink": taintToSink
590
675
  };
591
676
  function runChecker(kind, c, params) {
592
677
  const fn = registry[kind];
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  renderTrustGraphMd,
15
15
  resolveRules,
16
16
  startWatch
17
- } from "./chunk-Q72MTJXQ.js";
17
+ } from "./chunk-XQUQOBXZ.js";
18
18
 
19
19
  // src/cli.ts
20
20
  import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  saveProgramCache,
38
38
  startWatch,
39
39
  testKinds
40
- } from "./chunk-Q72MTJXQ.js";
40
+ } from "./chunk-XQUQOBXZ.js";
41
41
 
42
42
  // src/generate.ts
43
43
  import { writeFileSync, mkdirSync } from "fs";
@@ -25,8 +25,12 @@ detect:
25
25
  - end
26
26
  requiresImport: false
27
27
  check:
28
- kind: env-taint-to-sink
28
+ kind: taint-to-sink
29
29
  params:
30
+ sources:
31
+ - name: env-secret
32
+ # Secret-shaped process.env.X reads.
33
+ pattern: "process\\.env\\.[A-Za-z0-9_]*(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)[A-Za-z0-9_]*"
30
34
  sinkCalls:
31
35
  - log
32
36
  - error
@@ -37,7 +41,7 @@ check:
37
41
  - send
38
42
  - write
39
43
  - end
40
- # Key names that typically hold credentials/secrets.
41
- secretKeyPattern: "(SECRET|PRIVATE_KEY|API_KEY|ACCESS_KEY|TOKEN|PASSWORD|CREDENTIAL)"
44
+ # Same-file or imported-module hops to follow before/after the candidate.
45
+ maxHops: 2
42
46
  test:
43
47
  kind: none
@@ -0,0 +1,42 @@
1
+ # Pure-data rule (facts). Binds to vetted templates by `check.kind`/`test.kind`.
2
+ id: request-input-command-injection
3
+ severity: critical
4
+ title: Untrusted request input flows into a shell command
5
+ component:
6
+ name: Node.js child_process
7
+ type: API
8
+ version: unversioned
9
+ sourceUrl: https://nodejs.org/api/child_process.html
10
+ detect:
11
+ lang: typescript
12
+ modules: []
13
+ # Never matches by name alone — only the triggerCalls (sink calls) below
14
+ # select candidates.
15
+ nameRegex: "(?!)"
16
+ triggerCalls:
17
+ - exec
18
+ - execSync
19
+ - spawn
20
+ - spawnSync
21
+ - execFile
22
+ - execFileSync
23
+ requiresImport: false
24
+ check:
25
+ kind: taint-to-sink
26
+ params:
27
+ sources:
28
+ - name: request-input
29
+ # req.body / req.query / req.params / req.headers (any object named
30
+ # req/request) — the canonical "untrusted user input" surface for an
31
+ # HTTP handler.
32
+ pattern: "\\b(req|request)\\.(body|query|params|headers)\\b"
33
+ sinkCalls:
34
+ - exec
35
+ - execSync
36
+ - spawn
37
+ - spawnSync
38
+ - execFile
39
+ - execFileSync
40
+ maxHops: 2
41
+ test:
42
+ kind: none
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainblast",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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": [