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
|
|
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 (
|
|
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/
|
|
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
|
|
501
|
-
const
|
|
502
|
-
return
|
|
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
|
|
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
|
-
|
|
514
|
-
|
|
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}' (
|
|
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
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
if (!
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
544
|
-
if (
|
|
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
|
-
|
|
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
|
|
565
|
-
if (!
|
|
566
|
-
const
|
|
567
|
-
|
|
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
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
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
|
-
"
|
|
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
package/dist/index.js
CHANGED
|
@@ -25,8 +25,12 @@ detect:
|
|
|
25
25
|
- end
|
|
26
26
|
requiresImport: false
|
|
27
27
|
check:
|
|
28
|
-
kind:
|
|
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
|
-
#
|
|
41
|
-
|
|
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.
|
|
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": [
|