diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5f6b099..eeaedeb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,7 +41,7 @@ jobs:
- name: Run tests
env:
CGO_ENABLED: '1'
- run: go test -race -count=1 ./batou-core/... ./batou-rules/...
+ run: go test -race -count=1 -timeout 40m ./batou-core/... ./batou-rules/...
- name: Build
env:
diff --git a/.gitignore b/.gitignore
index 8c2c4ed..829a8e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ coverage.html
# Benchmarks (private)
bench/
+!testdata/bench/
# IDE
.idea/
@@ -29,5 +30,22 @@ Thumbs.db
# Test binaries
*.test
+# External benchmark clones
+testdata/external/
+
+# OWASP bench results
+testdata/owasp-bench/*/results.json
+
# Generated rule files
*_gen.go
+
+# Local MCP server config
+.mcp.json
+
+# stray build artifact (build to bin/batou via make)
+batou-core/batou
+
+# Claude Code working directory: ignore agent worktrees, caches, and scratch
+# artifacts (reports, ad-hoc workflow scripts), but keep committed skills.
+/.claude/*
+!/.claude/skills/
diff --git a/CLAUDE.md b/CLAUDE.md
index acd94c2..26066cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,4 +1,4 @@
-# Batou - Generation-time SAST for Claude Code
+# Batou - Code guard for your AI agents
## Project Overview
@@ -7,43 +7,52 @@ Batou is a security scanner that runs as a Claude Code hook, analyzing code for
## Architecture
```
-cmd/batou/main.go Entry point - reads hook JSON from stdin, runs scanner, outputs hints
-internal/scanner/ Core scan orchestrator (concurrent rule execution + preprocessing)
-internal/rules/ 45 rule categories (684 regex-based rules)
-internal/ast/ Tree-sitter AST parsing (parser, query, filter, context)
-internal/analyzer/ Language detection + 15 AST security analyzers
-internal/taint/ Taint analysis engine (source -> sink tracking with sanitizers)
-internal/taint/astflow/ Go-specific AST taint walker (uses go/ast, tracks channels/goroutines)
-internal/taint/tsflow/ Tree-sitter taint walker for 16 languages (Python, JS/TS, Java, Perl, Zig, etc.)
-internal/taint/languages/ Language-specific taint catalogs (17 languages, 65 files)
-internal/hints/ Hint generation for Claude feedback (language-specific fix examples)
-internal/graph/ Persistent call graph + interprocedural analysis
-internal/hook/ Hook I/O (JSON stdin/stdout, exit codes)
-internal/reporter/ Result formatting (block messages with CWE/OWASP refs)
-internal/ledger/ Session audit logging
-internal/testutil/ Test framework helpers
+batou-core/cmd/batou/main.go Entry point — default: reads a Claude Code hook event from stdin, runs the scanner, outputs hints. Also: `batou scan
` (repo scanner) and `batou findings` (findings-cache report)
+batou-core/scanner/ Core scan orchestrator (concurrent rule execution + preprocessing)
+batou-core/scanner/dirscan/ `batou scan DIR` subcommand: parallel directory walk over a shared in-memory call graph, JSONL or `--sarif` output, `--fail-on` CI gate, cross-file finalize pass
+batou-rules/rules/ 45 rule categories (751 regex-based rules)
+batou-core/ast/ Tree-sitter AST parsing (parser, query, filter, context)
+batou-core/analyzer/ Language detection + 16 AST security analyzer packages
+batou-core/taint/ Taint types + regex fallback engine (source -> sink tracking with sanitizers)
+batou-core/taint/astflow/ Go-specific AST taint walker (uses go/ast, tracks channels/goroutines)
+batou-core/taint/ssaflow/ Go SSA taint engine (golang.org/x/tools/go/ssa): def-use chains, cross-function fixpoint summaries, module-local cross-package builds. ON by default; opt out with BATOU_SSAFLOW=0
+batou-core/taint/tsflow/ Tree-sitter taint walker (17 language configs; Zig's config is inert — no grammar)
+batou-core/taint/languages/ Language-specific taint catalogs (17 languages, 73 files)
+batou-core/hints/ Hint generation for Claude feedback (language-specific fix examples)
+batou-core/graph/ Persistent call graph + interprocedural analysis
+batou-core/hook/ Hook I/O (JSON stdin/stdout, exit codes)
+batou-core/reporter/ Result formatting (block messages with CWE/OWASP refs)
+batou-core/suppress/ Inline suppression parsing and matching (batou:ignore directives)
+batou-core/findings/ Findings persistence + lifecycle tracking (new/recurring/fixed/suppressed)
+batou-core/ledger/ Session audit logging
+batou-core/testutil/ Test framework helpers
```
## Key Concepts
- **Four-layer analysis** (layers share parsed trees and taint flows — no redundant re-parsing):
- - Layer 1: Regex rules (684 pattern-matching rules across 45 categories)
- - Layer 2: AST analysis (tree-sitter structural analysis for 15 languages). Tree-sitter tree is cached and shared with Layer 3's tsflow engine. Zig uses tsflow config but does not yet have a dedicated AST analyzer.
- - Layer 3: Taint analysis (source-to-sink dataflow with 1,123 entries across three engines). TaintFlow objects are cached and passed to Layer 4 for precise interprocedural signatures.
- - Layer 4: Call graph (persistent interprocedural taint tracking across function boundaries, cross-file caller loading from disk)
-- **Confidence scoring**: Each finding gets a computed `ConfidenceScore` (0.0–1.0) reflecting which analysis layers confirmed it. Blocking requires both `Severity >= Critical` AND `ConfidenceScore >= 0.7`. This means regex-only Critical findings (score 0.3–0.5) become hints instead of blocks, while multi-layer-confirmed findings still block.
+ - Layer 1: Regex rules (751 pattern-matching rules across 45 categories — count via `python3 batou-rules/tools/check_rules.py --coverage`)
+ - Layer 2: AST analysis (16 analyzer packages covering 18 languages — `cast` handles C+C++, `jsast` handles JS+TS). All are tree-sitter-based except `zigast` (no Zig grammar exists; it is a lexical/structural analyzer, external-origin-gated, emitting `BATOU-ZIG-AST-###` Layer-2 findings). Tree-sitter tree is cached and shared with Layer 3's tsflow engine.
+ - Layer 3: Taint analysis (source-to-sink dataflow drawing on 10,651 catalog entries across 17 languages — count via `check_rules.py --taint`) run by four engines (see below). TaintFlow objects are cached and passed to Layer 4 for precise interprocedural signatures.
+ - Layer 4: Call graph (persistent interprocedural taint tracking across function boundaries, cross-file caller loading from disk). Behavior differs by mode — see "Layer 4: hook mode vs scan mode" below.
+- **Confidence scoring**: Each finding gets a computed `ConfidenceScore` (0.0–1.0) reflecting which analysis layers confirmed it. Blocking uses `RiskScore = Severity.ImpactWeight × ConfidenceScore >= 0.7`. Impact weights: Critical=1.0, High=0.8, Medium=0.5, Low=0.25. This means regex-only Critical findings (score 0.3–0.5) become hints instead of blocks, while multi-layer-confirmed findings still block.
- **Shared parse cache** (each file parsed once per parser type):
- tree-sitter tree: parsed in Layer 2 → reused by tsflow (Layer 3) via `AnalyzeWithTree()`
- go/ast parse: parsed once → shared between astflow (Layer 3) via `AnalyzeGoWithAST()` and call graph builder (Layer 4) via `UpdateFileWithAST()`. Cached in `ScanContext.GoASTFile`.
- Layer 3 `TaintFlow` objects → passed to Layer 4's `ComputeTaintSig()` for precise signature derivation (falls back to regex when flows are nil)
-- **Three taint engines** (scanner routes automatically by language):
+- **Four taint engines** (routing lives in `batou-core/taintrule/rule.go` and is mirrored in `scanner.go` Phase 3):
- `astflow`: Go-specific, uses `go/ast` for precise tracking through channels, select, goroutines, and Go idioms. Accepts pre-parsed `GoParseResult` via `AnalyzeGoWithAST()`.
- - `tsflow`: Generic tree-sitter walker for 16 languages (Python, JS, TS, Java, PHP, Ruby, C, C++, C#, Kotlin, Rust, Swift, Lua, Groovy, Perl, Zig) with per-language config tables. Accepts pre-parsed tree via `AnalyzeWithTree()`.
- - `taint.Analyze`: Regex-based fallback for languages without tree-sitter support
-- **Preprocessing**: CRLF normalization, multi-line continuation joining (backslash + implicit), unicode identifier support
+ - `ssaflow`: Go-specific second engine, runs **alongside** astflow (additive, never replaces it). Builds SSA via `golang.org/x/tools/go/ssa` and walks def-use chains: intra-procedural flows (confidence 0.9), cross-function fixpoint summaries (confidence 0.85, converges or caps at 10 iterations), and module-local cross-package summaries via `packages.Load` with a per-module program cache. **ON by default**; opt out with `BATOU_SSAFLOW=0` (or `off`/`false`/`no`). Duplicate astflow/ssaflow flows collapse in dedup by (line, CWE).
+ - `tsflow`: Generic tree-sitter walker with 17 per-language config tables (Python, JS, TS, Java, PHP, Ruby, C, C++, C#, Kotlin, Rust, Swift, Lua, Groovy, Perl, Shell, Zig). Accepts pre-parsed tree via `AnalyzeWithTree()`. A file routes here only when `tsflow.Supports(lang) && ast.SupportsLanguage(lang)` — Zig has a config but no tree-sitter grammar, so it never routes here.
+ - `taint.Analyze`: Regex-based fallback for languages without a registered tree-sitter grammar (today that means Zig), consuming the same catalog Patterns.
+- **Layer 4: hook mode vs scan mode** (they are NOT the same):
+ - **Hook mode** (per-write): loads the persistent `.batou/callgraph.json` for the project root via `graph.LoadGraphForHook()` — which **adopts a scan-built project graph** when one exists (marker: non-nil `PackageIndex`, only populated by dirscan finalize) — updates the graph with the written file, and runs `graph.PropagateInterprocTyped()` on the changed functions. When a scan-built graph is present, the hook also runs the **incremental cross-file lane**: `graph.ResolveCrossFileEdgesForFile()` re-resolves edges for the edited file only (bounded: 64 inbound callers, one hop) and `graph.WalkCrossFileTaintFlowsForCaller()` walks the file's outbound one-hop pairs (cap 200) against persisted callee signatures — cross-file Critical sinks can block the write at confidence 0.8. Kill switch: `BATOU_HOOK_CROSSFILE=0`. Graphs over 32MB (`BATOU_HOOK_CROSSFILE_MAX_MB`) are declined with `SkipPersist` set so a hook save can never clobber a large scan-built graph. Graceful no-op when no scan graph exists (plain session graph, same as before). Cross-file callers not in the current file are still loaded from disk on demand (`loadCallerFile`, 2MB cap via `maxCallerFileSize` in `graph/interprocedural.go`).
+ - **Scan mode** (`batou scan`, `scanner/dirscan/`): workers share one in-memory `SharedCallGraph` (per-file saves are no-ops; the hook cross-file lane is bypassed in this mode); after the walk, `finalizeCrossFileEdges` runs the full `graph.ResolveCrossFileEdges()` — multi-hop interprocedural taint and cross-language service-boundary routes — emits its extra findings, and persists the graph once. Running `batou scan` once is what arms the hook's cross-file lane for the project.
+- **`batou scan` output filtering**: by default only data-flow-confirmed findings are emitted (regex-tier dropped). Secrets/crypto/misconfig findings are legitimately regex-only, so they are invisible unless you pass `--with-regex` (or `--regex-only`). `--sarif` emits one SARIF 2.1.0 document (taint paths as codeFlows, `partialFingerprints`, srcroot-relative artifact URIs) instead of JSONL. `--fail-on none|any|blocking|critical|high` exits 3 when an *emitted* finding matches (output filters apply first).
+- **Preprocessing**: CRLF normalization, multi-line continuation joining (backslash + implicit), unicode identifier support. **Important**: suppress.Parse and rules both operate on preprocessed content so line numbers stay aligned.
- **AST false-positive filter**: Suppresses regex findings inside comment AST nodes (not strings — SQL/XSS patterns in strings are intentional)
- **Hook I/O**: JSON on stdin, exit code 0 (allow), 2 (block). JSON stdout with `additionalContext` for Claude
-- **One dependency**: `github.com/smacker/go-tree-sitter` (compiled into binary via CGo). Core is pure Go stdlib.
+- **Dependencies** (batou-core go.mod): `github.com/smacker/go-tree-sitter` (grammars compiled in via CGo), `golang.org/x/tools` (ssaflow's SSA + packages loading), `github.com/gofrs/flock` (file locking for the persisted call graph and findings store).
- **Taint catalogs**: Each language has sources (user input), sinks (dangerous functions), and sanitizers
- **AI feedback loop**: Hints include language-specific fix examples, CWE/OWASP references, and architectural advice
@@ -68,7 +77,7 @@ When dedup groups findings by (line, CWE), the winner gets `+0.1` per additional
### Blocking Threshold
-`Severity >= Critical AND ConfidenceScore >= 0.7` (`ConfBlockThreshold`)
+`RiskScore >= 0.7` (`RiskBlockThreshold`) where `RiskScore = Severity.ImpactWeight × ConfidenceScore`
### Pipeline Order
@@ -81,10 +90,10 @@ When dedup groups findings by (line, CWE), the winner gets `+0.1` per additional
### Key Files
-- `internal/scanner/confidence.go` — constants, `AssignBaseConfidenceScore()`, `BoostConfidenceForMultiLayer()`
-- `internal/scanner/dedup.go` — `countDistinctTiers()`, multi-layer boost during dedup
-- `internal/rules/rule.go` — `Finding.ConfidenceScore`, `Finding.ShouldBlock()`, `Finding.SyncConfidenceString()`
-- `internal/reporter/reporter.go` — `ScanResult.ShouldBlock()` iterates findings using `f.ShouldBlock()`
+- `batou-core/scanner/confidence.go` — constants, `AssignBaseConfidenceScore()`, `BoostConfidenceForMultiLayer()`
+- `batou-core/scanner/dedup.go` — `countDistinctTiers()`, multi-layer boost during dedup
+- `batou-rules/rules/rule.go` — `Finding.ConfidenceScore`, `Finding.ShouldBlock()`, `Finding.SyncConfidenceString()`
+- `batou-core/reporter/reporter.go` — `ScanResult.ShouldBlock()` iterates findings using `f.ShouldBlock()`
## False Positive Suppression (`batou:ignore`)
@@ -94,7 +103,8 @@ Developers and Claude can suppress findings with inline directives in code comme
- **Block**: `// batou:ignore-start ` ... `// batou:ignore-end` — suppresses all lines in between
- **Targets**: exact rule ID (`BATOU-INJ-001`), category name (`injection`), or `all`
- **Comment styles**: `//`, `#`, `--`, `/*`, ` BLOCK (write rejected)
+ +-- RiskScore < 0.7 --> HINT (agent sees advice, write allowed)
+```
+
+Each file is parsed once. Trees, taint flows, and AST results are cached and shared across layers.
+
+## Languages
+
+Go, Python, JavaScript/TypeScript, Java, PHP, Ruby, C, C++, Kotlin, Swift, Rust, C#, Perl, Lua, Groovy, Zig
+
+**AST analysis:** All of the above via tree-sitter
+**Taint analysis:** Go (go/ast), 16 languages (tree-sitter), regex fallback for others
+
## What It Detects
-Injection, XSS, path traversal, crypto weaknesses, hardcoded secrets, SSRF, auth issues, XXE, deserialization, CORS, SSTI, JWT flaws, session issues, file upload, race conditions, log injection, input validation, memory safety, and framework-specific misconfigs (Spring, Express, Django, Flask, Rails, Laravel, React, Tauri).
+**684 rules across 45 categories:**
+
+Injection (SQL, command, code), XSS, path traversal, weak crypto, hardcoded secrets, SSRF, auth/authz, XXE, deserialization, CORS, SSTI, JWT, session management, file upload, race conditions, log injection, input validation, memory safety, OAuth, WebSocket, header injection, encoding issues.
-**Languages:** Go, Python, JavaScript/TypeScript, Java, PHP, Ruby, C, C++, Kotlin, Swift, Rust, C#, Perl, Lua, Groovy, Zig
+**Framework-specific rules:** FastAPI, Django, Flask, Express, Spring, Rails, Laravel, React, Tauri, NestJS.
-## False Positive Suppression
+**ORM taint tracking:** SQLAlchemy, Django ORM, Prisma, Sequelize, TypeORM, Drizzle, Hibernate, MyBatis, JOOQ, GORM, sqlx, ent, bun, ActiveRecord, Eloquent, Doctrine, Entity Framework, Dapper, Diesel, SQLx (Rust), Peewee, Tortoise ORM.
-Suppress findings with inline directives when you know the code is safe:
+## Risk Scoring
-```go
-// batou:ignore BATOU-INJ-001 -- query uses parameterized input
-db.Query("SELECT * FROM users WHERE id = " + id)
+Findings carry a `RiskScore` computed from severity and confidence:
+
+```
+RiskScore = Severity.ImpactWeight * ConfidenceScore
+
+Impact weights: Critical=1.0, High=0.8, Medium=0.5, Low=0.25
+Confidence: 0.0-1.0, computed by which layers confirmed the finding
```
+| Scenario | Confidence | RiskScore | Result |
+|----------|-----------|-----------|--------|
+| Regex-only Critical | 0.3-0.5 | 0.3-0.5 | Hint |
+| AST-confirmed High | 0.7 | 0.56 | Hint |
+| Taint-confirmed Critical | 0.85 | 0.85 | Block |
+| Multi-layer agreement | 0.95+ | 0.95+ | Block |
+
+The key insight: a Critical regex-only finding (low confidence) becomes a hint, while a High-severity finding confirmed by taint analysis can block. Confidence matters more than severity alone.
+
+## Suppression
+
+Suppress findings with inline directives:
+
```python
-# batou:ignore secrets -- test fixture, not a real credential
-password = "test-password-for-ci"
+# batou:ignore BATOU-INJ-001 -- parameterized query
+db.execute(query, params)
```
Block suppression for multiple lines:
-```go
-// batou:ignore-start injection
-rows := db.Query(dynamicSQL)
-process(rows)
-// batou:ignore-end
+```python
+# batou:ignore-start injection -- all queries in this block use ORM
+results = User.objects.filter(name=name)
+orders = Order.objects.filter(user=user)
+# batou:ignore-end
+```
+
+**Targets:** exact rule ID (`BATOU-INJ-001`), category (`injection`, `framework`, `jwt`, etc.), or `all`.
+
+**All 45 rule categories are suppressible by name.** Adding a new category requires one entry in the `CategoryForRule()` map in `batou-rules/rules/rule.go`.
+
+### Agent behavior with suppression
+
+Batou detects when an AI agent adds `batou:ignore` directives and emits a `BATOU-SUPPRESS-REVIEW` finding telling the agent to fix the code instead of suppressing. The hint output says:
+
+> "Fix the underlying issue. Only suppress as a last resort."
+
+This prevents agents from carpet-bombing files with suppress comments instead of writing secure code.
+
+## Findings Lifecycle
+
+Findings are tracked across scans in `.batou/findings.json`:
+
+- **new** -- first time this finding appears
+- **recurring** -- seen in a previous scan, still present
+- **fixed** -- was active, no longer appears in scan
+- **suppressed** -- active finding transitioned via `batou:ignore`
+
+The findings store uses file locking (`flock`) to prevent corruption from concurrent hook invocations. Corrupted JSON files self-heal on next open.
+
+Lifecycle events are included in the hook output so external dashboards and metrics sinks can track them.
+
+## Project Structure
+
+```
+batou-rules/rules/ 45 rule categories (684 regex rules)
+batou-core/scanner/ Scan orchestrator, confidence scoring, dedup
+batou-core/suppress/ Inline suppression parsing and matching
+batou-core/taint/ Taint analysis (3 engines: astflow, tsflow, regex)
+batou-core/taint/languages/ Per-language taint catalogs (17 languages)
+batou-core/analyzer/ Tree-sitter AST analyzers (15 languages)
+batou-core/graph/ Persistent call graph + interprocedural analysis
+batou-core/hints/ Hint generation for agent feedback
+batou-core/reporter/ Result formatting (block messages, risk labels)
+batou-core/findings/ Findings persistence + lifecycle tracking
+batou-core/hook/ Hook I/O (JSON stdin/stdout, exit codes)
+batou-core/ledger/ Session audit logging
+batou-core/cmd/batou/ Entry point (hook mode + `scan`/`findings` subcommands)
```
-**Targets:** specific rule ID (`BATOU-INJ-001`), category (`injection`), or `all`. Always include a reason after `--`.
+## Building
-## Adding Batou to Your Project's CLAUDE.md
+Requires Go 1.25+, CGo enabled (for tree-sitter).
-Copy the snippet from [`docs/claude-md-snippet.md`](docs/claude-md-snippet.md) into your project's `CLAUDE.md` so Claude understands how Batou works and can respond to its findings correctly.
+```bash
+make build # Build binary to bin/batou
+make test # Run all tests with race detector
+```
## Testing
```bash
-make test # Run all tests with race detector
-make test-cover # Run with coverage
+go test ./batou-core/... ./batou-rules/... -race # Full test suite
+go test ./batou-core/scanner/ -run TestCategorySuppress_Pipeline # Suppress lifecycle
+go test ./batou-core/findings/ -run TestConcurrent # File locking
+go test ./batou-core/suppress/ -v # All category mappings
+```
+
+### OWASP Benchmark
+
+Batou is benchmarked against the [OWASP Benchmark](https://owasp.org/www-project-benchmark) for Java and Python:
+
+```bash
+make bench-owasp-clone # Clone test data
+make bench-owasp # Run benchmarks (~3,970 test cases)
```
+Current scores (all emitted findings, minConf=0): Java 94.5% TPR / 16.8% FPR (Youden +77.7%), Python 98.0% TPR / 5.9% FPR (Youden +92.1%).
+
## License
MIT
diff --git a/batou-core/analyzer/analyzer.go b/batou-core/analyzer/analyzer.go
index 5aa2637..03c7d80 100644
--- a/batou-core/analyzer/analyzer.go
+++ b/batou-core/analyzer/analyzer.go
@@ -51,6 +51,7 @@ var extToLanguage = map[string]rules.Language{
".pl": rules.LangPerl,
".pm": rules.LangPerl,
".cgi": rules.LangPerl,
+ ".t": rules.LangPerl, // Perl test scripts (Test::More etc.)
".lua": rules.LangLua,
".zig": rules.LangZig,
".sh": rules.LangShell,
diff --git a/batou-core/analyzer/analyzer_test.go b/batou-core/analyzer/analyzer_test.go
index fe396ac..c12821f 100644
--- a/batou-core/analyzer/analyzer_test.go
+++ b/batou-core/analyzer/analyzer_test.go
@@ -2,7 +2,6 @@ package analyzer_test
import (
"testing"
-
"github.com/turenlabs/batou-core/analyzer"
"github.com/turenlabs/batou-rules/rules"
)
diff --git a/batou-core/analyzer/cast/cast.go b/batou-core/analyzer/cast/cast.go
index 00b70c2..52db866 100644
--- a/batou-core/analyzer/cast/cast.go
+++ b/batou-core/analyzer/cast/cast.go
@@ -102,6 +102,30 @@ var formatFuncs = map[string]int{
"syslog": 1,
}
+// memWriteSinks maps the C buffer-write intrinsics to the index of their
+// size/length argument. These write `size` bytes into the destination
+// (argument 0); when `size` is attacker-controlled and the destination is a
+// fixed-size buffer, this is an out-of-bounds write (CWE-787).
+var memWriteSinks = map[string]int{
+ "memcpy": 2, // memcpy(dst, src, n)
+ "memmove": 2, // memmove(dst, src, n)
+ "memset": 2, // memset(dst, c, n)
+ "strncpy": 2, // strncpy(dst, src, n) — n larger than dst overflows
+ "strncat": 2, // strncat(dst, src, n)
+ "bcopy": 2, // bcopy(src, dst, n)
+}
+
+// allocSinks maps the C allocation intrinsics to the index of their size
+// argument. A size computed as `a * b` (or `a << b`) of two non-constant
+// operands can wrap around (integer overflow), under-allocating the buffer
+// (CWE-190 -> CWE-787 heap overflow on the subsequent write).
+var allocSinks = map[string]int{
+ "malloc": 0, // malloc(n)
+ "alloca": 0, // alloca(n)
+ "calloc": 1, // calloc(nmemb, size) — size is the per-element width
+ "realloc": 1, // realloc(ptr, n)
+}
+
func (c *cChecker) walk() {
root := c.tree.Root()
if root == nil {
@@ -110,9 +134,177 @@ func (c *cChecker) walk() {
root.Walk(func(n *ast.Node) bool {
if n.Type() == "call_expression" {
c.checkCallExpression(n)
+ c.checkTaintedSizeWrite(n)
+ c.checkAllocOverflow(n)
+ c.checkUncheckedPrivDrop(n)
+ // TLS cert-verification disabled via explicit flag
+ // (CAST-010/011/012: OpenSSL/libcurl/GnuTLS).
+ c.checkTLSVerifyDisabled(n)
+ // libc misuse hardening (CAST-013 strtok / CAST-014 temp-file /
+ // CAST-015 unbounded scanf / CAST-016 secret-scrub memset).
+ c.checkLibcHardening(n)
+ }
+ return true
+ })
+ // Per-function flow pass: intraprocedural UAF / double-free (CAST-006/007).
+ // Needs per-function ordered state, so it's a separate traversal from the
+ // stateless call-site pass above. Don't recurse into nested function_-
+ // definitions twice (checkFunctionFlow scans the whole body subtree).
+ root.Walk(func(n *ast.Node) bool {
+ if n.Type() == "function_definition" {
+ c.checkFunctionFlow(n)
+ // Supplementary-group drop ordering (CAST-017). Per-function: needs
+ // to see every set*id call in the body together.
+ c.checkPrivDropGroupOrder(n)
+ return false
}
return true
})
+ // OpenSSL always-accept verify-callback detection (CAST-009). Two-pass over
+ // the whole tree: collect SSL_CTX_set_verify callbacks, then flag trivial
+ // always-return-1 definitions.
+ c.checkTLSVerifyCallbacks()
+ // /dev/random fd-exhaustion loop (CAST-018). Scans loop bodies, so it runs
+ // as its own whole-tree pass.
+ c.checkDevRandomLoop()
+}
+
+// checkTaintedSizeWrite detects CWE-787 out-of-bounds writes where the length
+// argument of a memory-copy intrinsic is an attacker-controllable function
+// parameter and the destination is a fixed-size stack buffer. This is the
+// dominant C memory-corruption shape (e.g. `void f(int n, char *s){ char
+// b[64]; memcpy(b, s, n); }`) — regex layers cannot connect the parameter to
+// the size argument or know the destination is bounded.
+func (c *cChecker) checkTaintedSizeWrite(n *ast.Node) {
+ funcName := cCallName(n)
+ sizeIdx, ok := memWriteSinks[funcName]
+ if !ok {
+ return
+ }
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return
+ }
+ named := args.NamedChildren()
+ if sizeIdx >= len(named) || len(named) == 0 {
+ return
+ }
+
+ // The size argument must reduce to a bare identifier (or a simple
+ // arithmetic expression) that names a function parameter. A literal size
+ // (memcpy(b, s, 64)) or a sizeof() expression is safe and must not fire.
+ sizeArg := named[sizeIdx]
+ sizeNames := identifiersIn(sizeArg)
+ if len(sizeNames) == 0 {
+ return // literal / sizeof-only size — bounded, no taint
+ }
+ params := c.enclosingParams(n)
+ if len(params) == 0 {
+ return
+ }
+ if !anyIn(sizeNames, params) {
+ return // size is a local, not a caller-controlled parameter
+ }
+
+ // The destination (argument 0) must be a fixed-size buffer declared in the
+ // enclosing function. Copying a parameter-controlled length into a bounded
+ // buffer is the out-of-bounds write; copying into a heap buffer of unknown
+ // size is a weaker signal we leave to the size-allocation checks.
+ dst := named[0]
+ dstName := baseIdentifier(dst)
+ if dstName == "" || !c.isFixedSizeBuffer(n, dstName) {
+ return
+ }
+
+ // Bounds-guard suppression: a preceding length/bounds check in the same
+ // function that rejects the over-long case (`if (sdslen(o) > sizeof(buf)-1)
+ // goto invalid;`) makes this copy bounded — the out-of-bounds-write finding
+ // is a false positive. Mirrors the Python eval-guard idea for the C memory
+ // sink class. Only suppresses when the guard is an early-exit size
+ // comparison referencing this copy (see hasPrecedingBoundsGuard).
+ if c.hasPrecedingBoundsGuard(n, named) {
+ return
+ }
+
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-004",
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: "Out-of-bounds write: attacker-controlled size in " + funcName + "()",
+ Description: funcName + "() copies a caller-controlled length into the fixed-size buffer '" + dstName +
+ "'. When the length exceeds the buffer's capacity, memory past the buffer is overwritten (stack/heap buffer overflow).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Bound the copy length to the destination size: " + funcName + "(" + dstName + ", src, sizeof(" + dstName + ")). Validate the length against the buffer capacity before copying.",
+ CWEID: "CWE-787",
+ OWASPCategory: "A06:2021-Vulnerable and Outdated Components",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"buffer-overflow", "out-of-bounds-write", "memory-safety", "taint", "ast"},
+ })
+}
+
+// checkAllocOverflow detects CWE-190 integer-overflow-to-allocation-size:
+// an allocation whose size is a multiplication or left-shift of two
+// non-constant operands (e.g. `malloc(a * b)`). On a 32/64-bit `size_t` this
+// product can wrap, under-allocating the buffer; the subsequent fill then
+// overflows it. A constant operand (`a * sizeof(T)`, `n * 16`) is the common
+// safe shape and is excluded.
+func (c *cChecker) checkAllocOverflow(n *ast.Node) {
+ funcName := cCallName(n)
+ sizeIdx, ok := allocSinks[funcName]
+ if !ok {
+ return
+ }
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return
+ }
+ named := args.NamedChildren()
+ if sizeIdx >= len(named) {
+ return
+ }
+ sizeArg := unwrapParens(named[sizeIdx])
+ if sizeArg == nil || sizeArg.Type() != "binary_expression" {
+ return
+ }
+ op := sizeArg.ChildByFieldName("operator")
+ if op == nil {
+ return
+ }
+ opTxt := op.Text()
+ if opTxt != "*" && opTxt != "<<" {
+ return
+ }
+ left := sizeArg.ChildByFieldName("left")
+ right := sizeArg.ChildByFieldName("right")
+ // Both operands must be non-constant for the product to be capable of
+ // wrapping under attacker influence. `a * 16` or `a * sizeof(int)` is
+ // bounded by the constant factor and is the dominant safe pattern.
+ if isConstOperand(left) || isConstOperand(right) {
+ return
+ }
+
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-005",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Integer overflow in allocation size for " + funcName + "()",
+ Description: funcName + "() computes its allocation size as '" + truncate(sizeArg.Text(), 80) +
+ "'. Multiplying two unchecked values can overflow size_t and wrap to a small allocation; writing the intended number of bytes then overflows the undersized buffer (CWE-190 -> heap overflow).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Check for multiplication overflow before allocating (e.g. if (b != 0 && a > SIZE_MAX / b) fail), or use calloc()/reallocarray() which detect the overflow internally.",
+ CWEID: "CWE-190",
+ OWASPCategory: "A06:2021-Vulnerable and Outdated Components",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"integer-overflow", "allocation", "memory-safety", "ast"},
+ })
}
func (c *cChecker) checkCallExpression(n *ast.Node) {
@@ -169,7 +361,7 @@ func (c *cChecker) checkFormatString(n *ast.Node, funcName string, fmtArgIdx int
return
}
fmtArg := named[fmtArgIdx]
- if isCLiteral(fmtArg) {
+ if isLikelyConstFormat(fmtArg) {
return
}
line := int(n.StartRow()) + 1
@@ -277,6 +469,284 @@ func isCLiteral(n *ast.Node) bool {
return false
}
+// isLikelyConstFormat reports whether a node is something we treat as a
+// compile-time-constant format string for BATOU-CAST-002 purposes.
+//
+// This is intentionally permissive — the rule's only signal is "format arg is
+// not a literal", which produces heavy false positives on real C code that
+// uses #defines, const char* tables, and pre-validated format strings (e.g.
+// Lua's scanformat). We suppress when the format arg is:
+//
+// 1. A literal (string/char/number).
+// 2. A parenthesized literal or const-format expression.
+// 3. A ternary where both branches are themselves const-format expressions
+// (e.g. cond ? "%s=%llu" : ",%s=%llu").
+// 4. An identifier whose name follows the macro convention (ALL_CAPS, with
+// digits/underscores) — e.g. LUA_NUMBER_FMT, CLUSTER_MANAGER_INVALID_HOST_ARG,
+// LOG_COLOR_BOLD, CLASSIC_FOOTER.
+// 5. An identifier whose name strongly suggests a constant format string —
+// "fmt", "format", "*_fmt", "*_format", "*format*", "*_str", "branch",
+// "ascii_logo". This catches Redis/Lua/hdr_histogram local const tables
+// (format_str, head_format, line_format, ascii_logo).
+//
+// We accept a small risk of missing a true positive where a developer passes
+// user input through a variable named "fmt" — the rule has no dataflow to
+// disambiguate, and the regex/AST-only signal is too weak to justify shouting
+// about every const-format call site.
+func isLikelyConstFormat(n *ast.Node) bool {
+ if n == nil {
+ return true // be conservative on missing nodes
+ }
+ if isCLiteral(n) {
+ return true
+ }
+ switch n.Type() {
+ case "parenthesized_expression":
+ // Unwrap (...) and recurse on the inner expression.
+ for _, child := range n.NamedChildren() {
+ if isLikelyConstFormat(child) {
+ return true
+ }
+ }
+ return false
+ case "conditional_expression":
+ // cond ? a : b — suppress when both branches look const.
+ named := n.NamedChildren()
+ if len(named) < 3 {
+ return false
+ }
+ // tree-sitter-c lays out conditional_expression as
+ // [condition, consequence, alternative].
+ then, els := named[1], named[2]
+ return isLikelyConstFormat(then) && isLikelyConstFormat(els)
+ case "identifier":
+ return isConstLikeIdentifier(n.Text())
+ }
+ return false
+}
+
+// isConstLikeIdentifier reports whether an identifier looks like it refers to
+// a macro or local const format string. Pattern matches:
+// - ALL_CAPS / digits / underscores (macro convention) — at least one letter.
+// - Common format-name suffixes/contains: fmt, format, _str.
+// - A small named allowlist of patterns seen across the wild (ascii_logo,
+// branch — Redis-specific but harmless).
+func isConstLikeIdentifier(name string) bool {
+ if name == "" {
+ return false
+ }
+
+ // Allowlist of exact / substring matches for common const-format names.
+ lower := strings.ToLower(name)
+ if lower == "fmt" || lower == "format" ||
+ strings.HasSuffix(lower, "_fmt") || strings.HasSuffix(lower, "_format") ||
+ strings.HasPrefix(lower, "fmt_") || strings.HasPrefix(lower, "format_") ||
+ strings.HasSuffix(lower, "_str") || strings.HasSuffix(lower, "_string") ||
+ strings.Contains(lower, "format") {
+ return true
+ }
+ // Specific Redis/Lua local-const names that recur across our FP corpus.
+ switch lower {
+ case "ascii_logo", "branch", "form", "head_format", "line_format",
+ "classic_footer", "classic_header":
+ return true
+ }
+
+ // Macro convention: ALL_CAPS [+ digits/underscores], must have at least
+ // one ASCII letter and contain no lowercase letters.
+ hasLetter := false
+ for _, r := range name {
+ switch {
+ case r >= 'A' && r <= 'Z':
+ hasLetter = true
+ case r >= '0' && r <= '9':
+ // allowed
+ case r == '_':
+ // allowed
+ default:
+ // Any lowercase letter or other char disqualifies.
+ return false
+ }
+ }
+ return hasLetter
+}
+
+// enclosingParams returns the parameter names of the function_definition that
+// contains node n. Parameters are the trust boundary in C: a value flowing in
+// from a caller is, in the general (interprocedural) case, attacker-reachable.
+func (c *cChecker) enclosingParams(n *ast.Node) []string {
+ for _, anc := range n.Ancestors() {
+ if anc.Type() == "function_definition" {
+ return paramNames(anc)
+ }
+ }
+ return nil
+}
+
+// paramNames extracts the declared parameter identifier names from a
+// function_definition node (handles plain and pointer-declared parameters).
+func paramNames(fnDef *ast.Node) []string {
+ decl := fnDef.ChildByFieldName("declarator")
+ // Unwrap pointer_declarator (e.g. `void *h(...)`).
+ for decl != nil && decl.Type() == "pointer_declarator" {
+ decl = decl.ChildByFieldName("declarator")
+ }
+ if decl == nil || decl.Type() != "function_declarator" {
+ return nil
+ }
+ plist := decl.ChildByFieldName("parameters")
+ if plist == nil {
+ return nil
+ }
+ var names []string
+ for _, p := range plist.NamedChildren() {
+ if p.Type() != "parameter_declaration" {
+ continue
+ }
+ if id := declaratorIdentifier(p.ChildByFieldName("declarator")); id != "" {
+ names = append(names, id)
+ }
+ }
+ return names
+}
+
+// declaratorIdentifier walks a (possibly pointer/array-wrapped) declarator down
+// to the underlying identifier name.
+func declaratorIdentifier(d *ast.Node) string {
+ for d != nil {
+ switch d.Type() {
+ case "identifier":
+ return d.Text()
+ case "pointer_declarator", "array_declarator":
+ d = d.ChildByFieldName("declarator")
+ default:
+ return ""
+ }
+ }
+ return ""
+}
+
+// identifiersIn returns every plain identifier appearing in an expression
+// subtree. Used to test whether a size expression references a parameter.
+// A pure literal / sizeof expression yields no identifiers.
+func identifiersIn(n *ast.Node) []string {
+ if n == nil {
+ return nil
+ }
+ var ids []string
+ n.Walk(func(c *ast.Node) bool {
+ // Do not descend into sizeof(type) — that "identifier" is a type name,
+ // not a runtime value, and never carries taint.
+ if c.Type() == "sizeof_expression" {
+ return false
+ }
+ if c.Type() == "identifier" {
+ ids = append(ids, c.Text())
+ }
+ return true
+ })
+ return ids
+}
+
+// baseIdentifier returns the underlying object name of a destination argument,
+// unwrapping a leading `&` (address-of) and casts so `&buf` and `(char*)buf`
+// both resolve to `buf`.
+func baseIdentifier(n *ast.Node) string {
+ for n != nil {
+ switch n.Type() {
+ case "identifier":
+ return n.Text()
+ case "pointer_expression", "parenthesized_expression", "cast_expression":
+ named := n.NamedChildren()
+ if len(named) == 0 {
+ return ""
+ }
+ n = named[len(named)-1]
+ default:
+ return ""
+ }
+ }
+ return ""
+}
+
+// isFixedSizeBuffer reports whether `name` is declared as a fixed-size array
+// (`char name[N]`) somewhere in the enclosing function of node n. A bounded
+// stack buffer is what turns a tainted-length copy into an out-of-bounds write.
+func (c *cChecker) isFixedSizeBuffer(n *ast.Node, name string) bool {
+ var fnDef *ast.Node
+ for _, anc := range n.Ancestors() {
+ if anc.Type() == "function_definition" {
+ fnDef = anc
+ break
+ }
+ }
+ if fnDef == nil {
+ return false
+ }
+ found := false
+ fnDef.Walk(func(d *ast.Node) bool {
+ if found {
+ return false
+ }
+ if d.Type() == "array_declarator" {
+ id := d.ChildByFieldName("declarator")
+ size := d.ChildByFieldName("size")
+ if id != nil && id.Type() == "identifier" && id.Text() == name &&
+ size != nil && size.Type() == "number_literal" {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// unwrapParens strips a parenthesized_expression wrapper.
+func unwrapParens(n *ast.Node) *ast.Node {
+ for n != nil && n.Type() == "parenthesized_expression" {
+ named := n.NamedChildren()
+ if len(named) == 0 {
+ return nil
+ }
+ n = named[0]
+ }
+ return n
+}
+
+// isConstOperand reports whether an arithmetic operand is a compile-time
+// constant: a numeric literal, a sizeof expression, or a constant-folded
+// product/shift thereof. Such an operand bounds the multiplication and removes
+// the integer-overflow concern.
+func isConstOperand(n *ast.Node) bool {
+ n = unwrapParens(n)
+ if n == nil {
+ return false
+ }
+ switch n.Type() {
+ case "number_literal", "sizeof_expression", "char_literal":
+ return true
+ case "binary_expression":
+ return isConstOperand(n.ChildByFieldName("left")) &&
+ isConstOperand(n.ChildByFieldName("right"))
+ }
+ return false
+}
+
+// anyIn reports whether any element of needles appears in haystack.
+func anyIn(needles, haystack []string) bool {
+ set := make(map[string]bool, len(haystack))
+ for _, h := range haystack {
+ set[h] = true
+ }
+ for _, nd := range needles {
+ if set[nd] {
+ return true
+ }
+ }
+ return false
+}
+
func truncate(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
diff --git a/batou-core/analyzer/cast/cast_bounds_guard.go b/batou-core/analyzer/cast/cast_bounds_guard.go
new file mode 100644
index 0000000..cbdbabb
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_bounds_guard.go
@@ -0,0 +1,243 @@
+package cast
+
+import (
+ "strings"
+
+ "github.com/turenlabs/batou-core/ast"
+)
+
+// cast_bounds_guard.go recognises a LENGTH/BOUNDS GUARD preceding a memory-copy
+// sink (BATOU-CAST-004). When a copy whose size is a parameter-controlled length
+// is preceded by an early-exit size check that constrains that copy — e.g.
+//
+// char buf[128];
+// if (sdslen(o->ptr) > sizeof(buf)-1) goto invalid; // bounds guard
+// memcpy(buf, o->ptr, sdslen(o->ptr)+1); // bounded — not an OOB write
+//
+// the out-of-bounds-write finding is a false positive. This mirrors the Python
+// eval-guard concept (rules.PyHasEvalGuard) for the C memory sink class, and is
+// the AST analyzer twin of the tsflow taint-engine recogniser in
+// batou-core/taint/tsflow/c_bounds_guard.go.
+//
+// Conservative by construction (recall preservation is paramount):
+// - The guard must be a SIZE/LENGTH COMPARISON (sizeof / *_MAX-style macro /
+// length call, plus a relational operator). A NULL-check or flag test does
+// not qualify, so a copy preceded only by `if (p == NULL) goto` still fires.
+// - The guard must have an EARLY-EXIT body (return / goto / break / continue).
+// - The guard condition must REFERENCE THIS COPY (share an identifier with the
+// copy's dst / src / size arguments), tying the bound to this memcpy.
+
+// hasPrecedingBoundsGuard reports whether the copy call `sink` (with its named
+// arguments `args`) is preceded in its enclosing function by a length/bounds
+// guard that constrains this copy.
+func (c *cChecker) hasPrecedingBoundsGuard(sink *ast.Node, args []*ast.Node) bool {
+ if sink == nil {
+ return false
+ }
+ // Build the guard-token set from the SOURCE (arg 1) and SIZE (last) arguments
+ // only — NOT the destination (arg 0). A guard's `sizeof(dst)` term naturally
+ // mentions the destination buffer, so keying off the destination would let an
+ // unrelated check (`if (other > sizeof(dst)-1) return;`) masquerade as a
+ // guard on this copy. Every genuine guarded shape constrains the source or
+ // the copy length.
+ copyTokens := make(map[string]bool)
+ for i, a := range args {
+ if i == 0 {
+ continue // skip destination buffer
+ }
+ for _, id := range identifiersIn(a) {
+ copyTokens[id] = true
+ }
+ }
+ if len(copyTokens) == 0 {
+ return false
+ }
+
+ var fnDef *ast.Node
+ for _, anc := range sink.Ancestors() {
+ if anc.Type() == "function_definition" {
+ fnDef = anc
+ break
+ }
+ }
+ if fnDef == nil {
+ return false
+ }
+ sinkRow := sink.StartRow()
+
+ guarded := false
+ fnDef.Walk(func(w *ast.Node) bool {
+ if guarded {
+ return false
+ }
+ if w.Type() != "if_statement" {
+ return true
+ }
+ // Bounded lookback: the guard must sit in the small window of lines
+ // immediately above the copy (mirrors PyHasEvalGuard). This prevents an
+ // unrelated length-check elsewhere in a large function — one that merely
+ // shares a variable name with the copy — from suppressing this finding.
+ if w.StartRow() >= sinkRow || sinkRow-w.StartRow() > castMaxGuardLookback {
+ return true
+ }
+ if castIfIsBoundsGuard(w, copyTokens) {
+ guarded = true
+ return false
+ }
+ return true
+ })
+ return guarded
+}
+
+// castMaxGuardLookback bounds how many source lines above a copy a bounds guard
+// may appear and still gate that copy. Genuine guarded cases sit within ~6
+// lines; 8 covers them with margin while excluding far, unrelated matches.
+const castMaxGuardLookback = 8
+
+// castIfIsBoundsGuard reports whether an if_statement is a length/bounds guard
+// referencing one of copyTokens with an early-exit body.
+func castIfIsBoundsGuard(ifStmt *ast.Node, copyTokens map[string]bool) bool {
+ cond := ifStmt.ChildByFieldName("condition")
+ if cond == nil {
+ named := ifStmt.NamedChildren()
+ if len(named) > 0 {
+ cond = named[0]
+ }
+ }
+ if cond == nil {
+ return false
+ }
+ if !castCondIsSizeComparison(cond) {
+ return false
+ }
+ if !castCondReferencesTokens(cond, copyTokens) {
+ return false
+ }
+ conseq := ifStmt.ChildByFieldName("consequence")
+ if conseq == nil {
+ named := ifStmt.NamedChildren()
+ if len(named) >= 2 {
+ conseq = named[1]
+ }
+ }
+ if conseq == nil {
+ return false
+ }
+ return castBranchHasEarlyExit(conseq)
+}
+
+// castCondIsSizeComparison reports whether a condition is a relational
+// comparison involving a bounding term (sizeof / length call / *_MAX-style
+// macro).
+func castCondIsSizeComparison(cond *ast.Node) bool {
+ hasRelational := false
+ hasBound := false
+ cond.Walk(func(w *ast.Node) bool {
+ switch w.Type() {
+ case "binary_expression":
+ if op := w.ChildByFieldName("operator"); op != nil {
+ switch op.Text() {
+ case ">", ">=", "<", "<=", "==", "!=":
+ hasRelational = true
+ }
+ }
+ case "sizeof_expression":
+ hasBound = true
+ case "call_expression":
+ if castIsLengthCall(w) {
+ hasBound = true
+ }
+ case "identifier":
+ if castIsSizeConstName(w.Text()) {
+ hasBound = true
+ }
+ }
+ return true
+ })
+ return hasRelational && hasBound
+}
+
+func castIsLengthCall(call *ast.Node) bool {
+ fn := call.ChildByFieldName("function")
+ if fn == nil {
+ return false
+ }
+ if fn.Type() == "identifier" {
+ return castIsLengthName(fn.Text())
+ }
+ if fn.Type() == "field_expression" {
+ if f := fn.ChildByFieldName("field"); f != nil {
+ return castIsLengthName(f.Text())
+ }
+ }
+ return false
+}
+
+func castIsLengthName(name string) bool {
+ lower := strings.ToLower(name)
+ switch lower {
+ case "strlen", "strnlen", "sdslen", "wcslen", "wcsnlen":
+ return true
+ }
+ return strings.HasSuffix(lower, "len") || strings.HasSuffix(lower, "length") ||
+ strings.HasSuffix(lower, "_size")
+}
+
+func castIsSizeConstName(name string) bool {
+ if name == "" {
+ return false
+ }
+ switch name {
+ case "PATH_MAX", "NAME_MAX", "BUFSIZ", "SIZE_MAX", "INT_MAX", "UINT_MAX", "LINE_MAX":
+ return true
+ }
+ if name != strings.ToUpper(name) {
+ return false
+ }
+ for _, suf := range []string{"_MAX", "_LEN", "_SIZE", "_LIMIT", "_NAMELEN", "_CAP", "_BYTES", "_WIDTH"} {
+ if strings.HasSuffix(name, suf) {
+ return true
+ }
+ }
+ return false
+}
+
+func castCondReferencesTokens(cond *ast.Node, copyTokens map[string]bool) bool {
+ found := false
+ cond.Walk(func(w *ast.Node) bool {
+ if found {
+ return false
+ }
+ if w.Type() == "identifier" && copyTokens[w.Text()] {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+func castBranchHasEarlyExit(body *ast.Node) bool {
+ found := false
+ body.Walk(func(w *ast.Node) bool {
+ if found {
+ return false
+ }
+ switch w.Type() {
+ case "return_statement", "goto_statement", "break_statement", "continue_statement":
+ found = true
+ return false
+ case "call_expression":
+ fn := w.ChildByFieldName("function")
+ if fn != nil && fn.Type() == "identifier" {
+ switch strings.ToLower(fn.Text()) {
+ case "longjmp", "siglongjmp", "abort", "exit", "_exit", "panic":
+ found = true
+ return false
+ }
+ }
+ }
+ return true
+ })
+ return found
+}
diff --git a/batou-core/analyzer/cast/cast_libc_hardening.go b/batou-core/analyzer/cast/cast_libc_hardening.go
new file mode 100644
index 0000000..898fa55
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_libc_hardening.go
@@ -0,0 +1,420 @@
+package cast
+
+import (
+ "strings"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// cast_libc_hardening.go gathers the framework-anchored libc misuse AST rules
+// commonly covered by mainstream C security rulesets. Each keys on a
+// UNIQUE reserved libc symbol, so there is no bare-name collision with
+// application code, and each fires only on the structurally dangerous shape so
+// the safe replacement (strtok_r, mkstemp, snprintf with a width, etc.) stays
+// clean. Implemented independently from the CWE definitions and the C standard
+// library specification.
+//
+// BATOU-CAST-013 strtok() obsolete non-reentrant tokenizer CWE-477
+// BATOU-CAST-014 mktemp/tmpnam/tempnam predictable temp-file name CWE-377
+// BATOU-CAST-015 scanf/fscanf/sscanf unbounded %s/%[ into fixed buf CWE-120
+// BATOU-CAST-016 memset() secret scrub the optimizer may elide CWE-14
+// BATOU-CAST-018 /dev/random read in a loop without close CWE-400
+
+// checkLibcHardening dispatches the per-call libc hardening rules. Called for
+// every call_expression node from walk().
+func (c *cChecker) checkLibcHardening(n *ast.Node) {
+ switch cCallName(n) {
+ case "strtok":
+ c.checkStrtok(n)
+ case "mktemp", "tmpnam", "tempnam":
+ c.checkInsecureTempFile(n)
+ case "scanf", "fscanf", "sscanf":
+ c.checkUnboundedScanf(n)
+ case "memset":
+ c.checkSecretScrub(n)
+ }
+}
+
+// --- BATOU-CAST-013: strtok (CWE-477) ---------------------------------------
+
+// checkStrtok flags use of strtok(), the non-reentrant obsolete tokenizer.
+// strtok stores parse state in a hidden static buffer, so it is not thread-safe
+// and breaks when two tokenizations interleave (e.g. a callee also using strtok
+// mid-loop). strtok_r / strsep are the reentrant replacements and are NOT
+// flagged because the switch above only matches the bare "strtok" identifier.
+func (c *cChecker) checkStrtok(n *ast.Node) {
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-013",
+ Severity: rules.Low,
+ SeverityLabel: rules.Low.String(),
+ Title: "Use of non-reentrant strtok()",
+ Description: "strtok() keeps its parsing position in a hidden static buffer, making it non-reentrant and " +
+ "unsafe across threads or when a nested call also tokenizes. Interleaved tokenization corrupts the " +
+ "shared state and yields wrong results (CWE-477, obsolete function).",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Use strtok_r(str, delim, &saveptr) (POSIX) or strsep(&str, delim), which keep the parse state in a caller-owned variable.",
+ CWEID: "CWE-477",
+ OWASPCategory: "A06:2021-Vulnerable and Outdated Components",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"obsolete", "reentrancy", "libc", "ast"},
+ })
+}
+
+// --- BATOU-CAST-014: mktemp/tmpnam/tempnam (CWE-377) ------------------------
+
+// insecureTempFns maps the predictable temp-name generators to their safe
+// replacement for the suggestion text.
+var insecureTempFns = map[string]string{
+ "mktemp": "mkstemp",
+ "tmpnam": "mkstemp",
+ "tempnam": "mkstemp",
+}
+
+// checkInsecureTempFile flags mktemp/tmpnam/tempnam, which only generate a
+// file NAME (no atomic create-and-open). Between the name being generated and
+// the program opening it, an attacker can create the path (often as a symlink),
+// causing a time-of-check/time-of-use race that lets them control or read the
+// target file (CWE-377). mkstemp/mkdtemp atomically create the file and are the
+// safe replacement; they are NOT matched here.
+func (c *cChecker) checkInsecureTempFile(n *ast.Node) {
+ fn := cCallName(n)
+ safe := insecureTempFns[fn]
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-014",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "Insecure temporary file via " + fn + "()",
+ Description: fn + "() returns a predictable temporary-file NAME without atomically creating the file. " +
+ "An attacker who pre-creates that path (e.g. as a symlink) between the name generation and the open " +
+ "wins a TOCTOU race and can redirect or read the file (CWE-377).",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Use " + safe + "(template) (or mkdtemp for directories), which atomically creates the file with O_EXCL and returns an open fd, eliminating the race.",
+ CWEID: "CWE-377",
+ OWASPCategory: "A01:2021-Broken Access Control",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"temp-file", "toctou", "libc", "ast"},
+ })
+}
+
+// --- BATOU-CAST-015: unbounded scanf %s (CWE-120) ---------------------------
+
+// scanfFmtArgIdx maps each scanf-family function to the index of its format
+// string argument. scanf reads the format first; fscanf/sscanf read a
+// stream/string first, then the format.
+var scanfFmtArgIdx = map[string]int{
+ "scanf": 0,
+ "fscanf": 1,
+ "sscanf": 1,
+}
+
+// checkUnboundedScanf flags scanf/fscanf/sscanf calls whose format string
+// contains an UNBOUNDED %s or %[ conversion (no field-width). Such a conversion
+// writes characters into the destination buffer until whitespace/EOF with no
+// length limit, overflowing any fixed buffer the caller supplied — the classic
+// scanf("%s", buf) overflow (CWE-120). A width-limited conversion ("%63s",
+// "%63[^\n]") is bounded and is NOT flagged.
+func (c *cChecker) checkUnboundedScanf(n *ast.Node) {
+ fn := cCallName(n)
+ fmtIdx, ok := scanfFmtArgIdx[fn]
+ if !ok {
+ return
+ }
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return
+ }
+ named := args.NamedChildren()
+ if fmtIdx >= len(named) {
+ return
+ }
+ fmtNode := named[fmtIdx]
+ // The format must be a string literal we can inspect. A runtime format is a
+ // different (format-string) class and not this check's concern.
+ if fmtNode.Type() != "string_literal" {
+ return
+ }
+ if !hasUnboundedStrConversion(fmtNode.Text()) {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-015",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Unbounded " + fn + "() string conversion (%s without field width)",
+ Description: fn + "() uses an unbounded %s or %[ conversion. The conversion writes input into the " +
+ "destination buffer with no length limit, so any input longer than that buffer overflows it " +
+ "(CWE-120). This is exploitable whenever the buffer is a fixed-size stack array.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Add an explicit field width matching the buffer size minus one, e.g. " + fn + "(..., \"%63s\", buf) for char buf[64]. Prefer fgets() for line input.",
+ CWEID: "CWE-120",
+ OWASPCategory: "A06:2021-Vulnerable and Outdated Components",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"buffer-overflow", "memory-safety", "libc", "ast"},
+ })
+}
+
+// hasUnboundedStrConversion reports whether a scanf format string literal (with
+// its surrounding quotes) contains a %s or %[ conversion lacking a numeric field
+// width. `%*s` (assignment-suppressed) does not write and is ignored; `%%` is a
+// literal percent and is skipped.
+func hasUnboundedStrConversion(quoted string) bool {
+ s := quoted
+ if len(s) >= 2 && (s[0] == '"') {
+ s = s[1 : len(s)-1]
+ }
+ for i := 0; i < len(s); i++ {
+ if s[i] != '%' {
+ continue
+ }
+ j := i + 1
+ if j >= len(s) {
+ break
+ }
+ if s[j] == '%' { // literal "%%"
+ i = j
+ continue
+ }
+ // Skip assignment-suppression '*' — those conversions discard input.
+ suppressed := false
+ if s[j] == '*' {
+ suppressed = true
+ j++
+ }
+ // A field width is one or more digits. If present, the conversion is
+ // bounded and safe.
+ width := false
+ for j < len(s) && s[j] >= '0' && s[j] <= '9' {
+ width = true
+ j++
+ }
+ if j >= len(s) {
+ break
+ }
+ if s[j] == 's' || s[j] == '[' {
+ if !suppressed && !width {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// --- BATOU-CAST-016: memset secret-scrub dead store (CWE-14) ----------------
+
+// checkSecretScrub flags memset(ptr, 0, n) used to wipe a secret buffer when the
+// buffer is NOT read again afterward in the same function. A compiler is free to
+// remove such a "dead store" under the as-if rule, leaving the secret (key,
+// password) resident in memory (CWE-14). The check is deliberately narrow:
+//
+// - the fill byte (arg 1) must be a literal 0 / '\0' — a zeroing wipe, the
+// shape used to scrub secrets (not memset(buf, 'A', n) initialization);
+// - the destination identifier must be a local whose name looks secret-bearing
+// (key/pass/secret/...) OR is never referenced again before the function
+// returns. Requiring one of these keeps initialization memsets (the buffer
+// is used afterward, non-secret name) from firing.
+func (c *cChecker) checkSecretScrub(n *ast.Node) {
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return
+ }
+ named := args.NamedChildren()
+ if len(named) < 3 {
+ return
+ }
+ // Fill byte must be a zero literal — the secret-wipe shape.
+ fill := unwrapParens(named[1])
+ if fill == nil || fill.Type() != "number_literal" || !isZeroLiteral(fill.Text()) {
+ return
+ }
+ dstName := baseIdentifier(named[0])
+ if dstName == "" {
+ return
+ }
+ // Only flag when the destination looks like it holds a secret AND it is not
+ // read again after this memset. Both conditions reduce noise to genuine
+ // scrub-of-secret dead stores.
+ if !looksSecretBuffer(dstName) {
+ return
+ }
+ if c.identifierReadAfter(n, dstName) {
+ return // buffer is used again — not a dead store
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-016",
+ Severity: rules.Low,
+ SeverityLabel: rules.Low.String(),
+ Title: "Secret-scrubbing memset() may be optimized away",
+ Description: "memset(" + dstName + ", 0, ...) zeroes a secret-bearing buffer that is never read again. " +
+ "Under the as-if rule the compiler may delete this dead store, leaving the secret (key, password, " +
+ "token) resident in memory where a later disclosure can expose it (CWE-14).",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Use a wipe the compiler cannot elide: explicit_bzero() (BSD/glibc), memset_s() (C11 Annex K), or SecureZeroMemory() (Windows).",
+ CWEID: "CWE-14",
+ OWASPCategory: "A04:2021-Insecure Design",
+ Language: c.language,
+ Confidence: "medium",
+ Tags: []string{"secret-scrub", "dead-store", "libc", "ast"},
+ })
+}
+
+// isZeroLiteral reports whether a number_literal text represents zero
+// (0, 0x0, 0L, '\0' is a char_literal handled elsewhere).
+func isZeroLiteral(t string) bool {
+ switch strings.TrimRight(t, "lLuU") {
+ case "0", "0x0", "0X0", "00":
+ return true
+ }
+ return false
+}
+
+// looksSecretBuffer reports whether an identifier name suggests it holds
+// cryptographic or credential material that must be scrubbed.
+func looksSecretBuffer(name string) bool {
+ l := strings.ToLower(name)
+ for _, kw := range []string{"key", "pass", "secret", "passwd", "cred", "token", "priv", "nonce", "seed", "plaintext", "session"} {
+ if strings.Contains(l, kw) {
+ return true
+ }
+ }
+ return false
+}
+
+// identifierReadAfter reports whether the identifier `name` is referenced
+// anywhere in the enclosing function AFTER the call node `n` (by source row).
+// Used to decide whether a scrub memset is a dead store.
+func (c *cChecker) identifierReadAfter(n *ast.Node, name string) bool {
+ var fnDef *ast.Node
+ for _, anc := range n.Ancestors() {
+ if anc.Type() == "function_definition" {
+ fnDef = anc
+ break
+ }
+ }
+ if fnDef == nil {
+ // No enclosing function (file scope) — treat as read to stay safe.
+ return true
+ }
+ memsetRow := n.StartRow()
+ read := false
+ fnDef.Walk(func(w *ast.Node) bool {
+ if read {
+ return false
+ }
+ if w.Type() == "identifier" && w.Text() == name && w.StartRow() > memsetRow {
+ // Ignore the memset's own argument occurrence (same row already
+ // excluded by the strict-greater comparison).
+ read = true
+ return false
+ }
+ return true
+ })
+ return read
+}
+
+// --- BATOU-CAST-018: /dev/random read in a loop without close (CWE-400) -----
+
+// checkDevRandomLoop flags an open()/fopen() of the "/dev/random" path that sits
+// inside a loop without a matching close in that loop body. Repeatedly opening
+// /dev/random without closing exhausts file descriptors (and blocks on entropy),
+// a denial-of-service (CWE-400). Called once per file from walk(); it scans loop
+// bodies rather than individual calls.
+func (c *cChecker) checkDevRandomLoop() {
+ root := c.tree.Root()
+ if root == nil {
+ return
+ }
+ root.Walk(func(n *ast.Node) bool {
+ switch n.Type() {
+ case "for_statement", "while_statement", "do_statement":
+ default:
+ return true
+ }
+ body := n.ChildByFieldName("body")
+ if body == nil {
+ return true
+ }
+ openCall := devRandomOpenInBody(body)
+ if openCall == nil {
+ return true
+ }
+ if bodyHasClose(body) {
+ return true // a close in the loop body means the fd is released
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-018",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "/dev/random opened in a loop without close (fd exhaustion)",
+ Description: "A loop opens \"/dev/random\" on every iteration but never closes the descriptor in the " +
+ "loop body. Each iteration leaks a file descriptor; the process eventually hits its fd limit and " +
+ "can no longer open files or sockets — a denial of service (CWE-400). Reading /dev/random in a " +
+ "loop also blocks on entropy.",
+ FilePath: c.filePath,
+ LineNumber: int(openCall.StartRow()) + 1,
+ MatchedText: truncate(openCall.Text(), 200),
+ Suggestion: "Open the random source once before the loop (or use getrandom()/getentropy()), and close any descriptor inside the loop before the next iteration.",
+ CWEID: "CWE-400",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: c.language,
+ Confidence: "medium",
+ Tags: []string{"fd-exhaustion", "dos", "resource-leak", "ast"},
+ })
+ return true
+ })
+}
+
+// devRandomOpenInBody returns the first open()/fopen() call in `body` whose path
+// argument is the "/dev/random" string literal, or nil.
+func devRandomOpenInBody(body *ast.Node) *ast.Node {
+ var found *ast.Node
+ body.Walk(func(w *ast.Node) bool {
+ if found != nil {
+ return false
+ }
+ if w.Type() != "call_expression" {
+ return true
+ }
+ fn := cCallName(w)
+ if fn != "open" && fn != "fopen" && fn != "open64" {
+ return true
+ }
+ // Path is the first argument for both open and fopen.
+ if arg0 := callArgText(w, 0); strings.Contains(arg0, "/dev/random") {
+ found = w
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// bodyHasClose reports whether a loop body contains a close()/fclose() call.
+func bodyHasClose(body *ast.Node) bool {
+ found := false
+ body.Walk(func(w *ast.Node) bool {
+ if found {
+ return false
+ }
+ if w.Type() == "call_expression" {
+ switch cCallName(w) {
+ case "close", "fclose":
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
diff --git a/batou-core/analyzer/cast/cast_libc_hardening_test.go b/batou-core/analyzer/cast/cast_libc_hardening_test.go
new file mode 100644
index 0000000..ec67b18
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_libc_hardening_test.go
@@ -0,0 +1,371 @@
+package cast
+
+import "testing"
+
+// NOTE: the OpenSSL SSL_VERIFY_NONE shape (a once-planned BATOU-CAST-010) was
+// evaluated against curl/openssl.c and redis/tls.c and deliberately NOT shipped
+// — both legitimately set SSL_VERIFY_NONE with a compensating control, so a
+// single-file check false-positives on them. See the rationale block in
+// cast_tls.go. The FP-free sibling (always-accept callback) is CAST-009.
+
+// --- BATOU-CAST-011: libcurl CURLOPT_SSL_VERIFY* = 0 (CWE-295) -------------
+
+func TestCurlVerifyPeerOff_TP(t *testing.T) {
+ code := `
+void fetch(CURL *h) {
+ curl_easy_setopt(h, CURLOPT_SSL_VERIFYPEER, 0L);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-011") {
+ t.Error("expected BATOU-CAST-011 for CURLOPT_SSL_VERIFYPEER, 0L")
+ }
+}
+
+func TestCurlVerifyHostOff_TP(t *testing.T) {
+ code := `
+void fetch(CURL *h) {
+ curl_easy_setopt(h, CURLOPT_SSL_VERIFYHOST, 0);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-011") {
+ t.Error("expected BATOU-CAST-011 for CURLOPT_SSL_VERIFYHOST, 0")
+ }
+}
+
+func TestCurlVerifyPeerOn_Safe(t *testing.T) {
+ code := `
+void fetch(CURL *h) {
+ curl_easy_setopt(h, CURLOPT_SSL_VERIFYPEER, 1L);
+ curl_easy_setopt(h, CURLOPT_SSL_VERIFYHOST, 2L);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-011") {
+ t.Error("BATOU-CAST-011 false positive on enabled verification")
+ }
+}
+
+func TestCurlVerifyPeerVar_Safe(t *testing.T) {
+ // A runtime-computed value must not fire — only literal 0/false.
+ code := `
+void fetch(CURL *h, long want) {
+ curl_easy_setopt(h, CURLOPT_SSL_VERIFYPEER, want);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-011") {
+ t.Error("BATOU-CAST-011 false positive on variable verify value")
+ }
+}
+
+func TestCurlOtherOpt_Safe(t *testing.T) {
+ // A different option set to 0 must not fire.
+ code := `
+void fetch(CURL *h) {
+ curl_easy_setopt(h, CURLOPT_VERBOSE, 0);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-011") {
+ t.Error("BATOU-CAST-011 false positive on unrelated CURLOPT_VERBOSE")
+ }
+}
+
+// --- BATOU-CAST-012: GnuTLS verify-flags disable (CWE-295) -----------------
+
+func TestGnuTLSVerifyDisable_TP(t *testing.T) {
+ code := `
+void setup(gnutls_certificate_credentials_t cred) {
+ gnutls_certificate_set_verify_flags(cred, GNUTLS_VERIFY_DISABLE_CA_SIGN);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-012") {
+ t.Error("expected BATOU-CAST-012 for GNUTLS_VERIFY_DISABLE_CA_SIGN")
+ }
+}
+
+func TestGnuTLSVerifyDefault_Safe(t *testing.T) {
+ code := `
+void setup(gnutls_certificate_credentials_t cred) {
+ gnutls_certificate_set_verify_flags(cred, 0);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-012") {
+ t.Error("BATOU-CAST-012 false positive on default (0) verify flags")
+ }
+}
+
+// --- BATOU-CAST-013: strtok (CWE-477) --------------------------------------
+
+func TestStrtok_TP(t *testing.T) {
+ code := `
+void parse(char *line) {
+ char *tok = strtok(line, ",");
+ while (tok) { tok = strtok(NULL, ","); }
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-013") {
+ t.Error("expected BATOU-CAST-013 for strtok()")
+ }
+}
+
+func TestStrtokR_Safe(t *testing.T) {
+ code := `
+void parse(char *line) {
+ char *sp;
+ char *tok = strtok_r(line, ",", &sp);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-013") {
+ t.Error("BATOU-CAST-013 false positive on strtok_r()")
+ }
+}
+
+func TestStrsep_Safe(t *testing.T) {
+ code := `
+void parse(char *line) {
+ char *tok = strsep(&line, ",");
+}`
+ if hasRule(scanC(code), "BATOU-CAST-013") {
+ t.Error("BATOU-CAST-013 false positive on strsep()")
+ }
+}
+
+// --- BATOU-CAST-014: insecure temp file (CWE-377) --------------------------
+
+func TestMktemp_TP(t *testing.T) {
+ code := `
+void work() {
+ char tmpl[] = "/tmp/fooXXXXXX";
+ char *p = mktemp(tmpl);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-014") {
+ t.Error("expected BATOU-CAST-014 for mktemp()")
+ }
+}
+
+func TestTmpnam_TP(t *testing.T) {
+ code := `
+void work() {
+ char *name = tmpnam(NULL);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-014") {
+ t.Error("expected BATOU-CAST-014 for tmpnam()")
+ }
+}
+
+func TestMkstemp_Safe(t *testing.T) {
+ code := `
+void work() {
+ char tmpl[] = "/tmp/fooXXXXXX";
+ int fd = mkstemp(tmpl);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-014") {
+ t.Error("BATOU-CAST-014 false positive on mkstemp()")
+ }
+}
+
+// --- BATOU-CAST-015: unbounded scanf %s (CWE-120) --------------------------
+
+func TestScanfUnbounded_TP(t *testing.T) {
+ code := `
+void read_name() {
+ char buf[64];
+ scanf("%s", buf);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-015") {
+ t.Error("expected BATOU-CAST-015 for scanf with unbounded string conversion")
+ }
+}
+
+func TestSscanfUnbounded_TP(t *testing.T) {
+ code := `
+void parse(const char *src) {
+ char buf[32];
+ sscanf(src, "key=%s", buf);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-015") {
+ t.Error("expected BATOU-CAST-015 for sscanf with unbounded string conversion")
+ }
+}
+
+func TestScanfBracketUnbounded_TP(t *testing.T) {
+ code := `
+void read_line() {
+ char buf[64];
+ scanf("%[^\n]", buf);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-015") {
+ t.Error("expected BATOU-CAST-015 for scanf with unbounded bracket conversion")
+ }
+}
+
+func TestScanfWidthLimited_Safe(t *testing.T) {
+ code := `
+void read_name() {
+ char buf[64];
+ scanf("%63s", buf);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-015") {
+ t.Error("BATOU-CAST-015 false positive on width-limited conversion")
+ }
+}
+
+func TestScanfNumeric_Safe(t *testing.T) {
+ // %d and friends do not write a string — no overflow.
+ code := `
+void read_num() {
+ int n;
+ scanf("%d", &n);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-015") {
+ t.Error("BATOU-CAST-015 false positive on numeric conversion")
+ }
+}
+
+func TestScanfSuppressed_Safe(t *testing.T) {
+ // %*s discards input (no destination) — not an overflow.
+ code := `
+void skip() {
+ scanf("%*s");
+}`
+ if hasRule(scanC(code), "BATOU-CAST-015") {
+ t.Error("BATOU-CAST-015 false positive on assignment-suppressed conversion")
+ }
+}
+
+// --- BATOU-CAST-016: secret-scrub memset dead store (CWE-14) ---------------
+
+func TestSecretScrubDeadStore_TP(t *testing.T) {
+ code := `
+void use_key() {
+ char key[32];
+ derive(key);
+ encrypt(key);
+ memset(key, 0, sizeof(key));
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-016") {
+ t.Error("expected BATOU-CAST-016 for secret-scrub memset never read again")
+ }
+}
+
+func TestSecretScrubExplicitBzero_Safe(t *testing.T) {
+ // explicit_bzero is the recommended replacement and is not a memset.
+ code := `
+void use_key() {
+ char key[32];
+ derive(key);
+ explicit_bzero(key, sizeof(key));
+}`
+ if hasRule(scanC(code), "BATOU-CAST-016") {
+ t.Error("BATOU-CAST-016 false positive on explicit_bzero")
+ }
+}
+
+func TestMemsetInit_Safe(t *testing.T) {
+ // Initialization memset on a non-secret buffer that IS read afterward.
+ code := `
+void build() {
+ char buf[128];
+ memset(buf, 0, sizeof(buf));
+ fill(buf);
+ send(buf);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-016") {
+ t.Error("BATOU-CAST-016 false positive on initialization memset of non-secret buffer")
+ }
+}
+
+func TestSecretScrubThenRead_Safe(t *testing.T) {
+ // A "key" that is read after the memset is not a dead store.
+ code := `
+void f() {
+ char key[32];
+ memset(key, 0, sizeof(key));
+ derive(key);
+ encrypt(key);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-016") {
+ t.Error("BATOU-CAST-016 false positive when secret is read after memset")
+ }
+}
+
+// --- BATOU-CAST-017: privilege drop omits setgroups (CWE-252) --------------
+
+func TestPrivDropNoSetgroups_TP(t *testing.T) {
+ code := `
+void drop(uid_t uid, gid_t gid) {
+ setgid(gid);
+ setuid(uid);
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-017") {
+ t.Error("expected BATOU-CAST-017 for setgid+setuid without setgroups")
+ }
+}
+
+func TestPrivDropWithSetgroups_Safe(t *testing.T) {
+ code := `
+void drop(uid_t uid, gid_t gid) {
+ setgroups(0, NULL);
+ setgid(gid);
+ setuid(uid);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-017") {
+ t.Error("BATOU-CAST-017 false positive when setgroups present")
+ }
+}
+
+func TestPrivDropWithInitgroups_Safe(t *testing.T) {
+ code := `
+void drop(const char *user, uid_t uid, gid_t gid) {
+ initgroups(user, gid);
+ setgid(gid);
+ setuid(uid);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-017") {
+ t.Error("BATOU-CAST-017 false positive when initgroups present")
+ }
+}
+
+func TestSetuidOnly_Safe(t *testing.T) {
+ // No setgid — not a full identity drop; do not flag.
+ code := `
+void drop(uid_t uid) {
+ setuid(uid);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-017") {
+ t.Error("BATOU-CAST-017 false positive on setuid-only function")
+ }
+}
+
+// --- BATOU-CAST-018: /dev/random loop fd exhaustion (CWE-400) --------------
+
+func TestDevRandomLoopNoClose_TP(t *testing.T) {
+ code := `
+void seed_all(int n) {
+ for (int i = 0; i < n; i++) {
+ int fd = open("/dev/random", 0);
+ read(fd, &buf[i], 1);
+ }
+}`
+ if !hasRule(scanC(code), "BATOU-CAST-018") {
+ t.Error("expected BATOU-CAST-018 for /dev/random open in loop without close")
+ }
+}
+
+func TestDevRandomLoopWithClose_Safe(t *testing.T) {
+ code := `
+void seed_all(int n) {
+ for (int i = 0; i < n; i++) {
+ int fd = open("/dev/random", 0);
+ read(fd, &buf[i], 1);
+ close(fd);
+ }
+}`
+ if hasRule(scanC(code), "BATOU-CAST-018") {
+ t.Error("BATOU-CAST-018 false positive when fd is closed in the loop")
+ }
+}
+
+func TestDevRandomOpenOnce_Safe(t *testing.T) {
+ // Opened once outside the loop — no exhaustion.
+ code := `
+void seed_all(int n) {
+ int fd = open("/dev/random", 0);
+ for (int i = 0; i < n; i++) {
+ read(fd, &buf[i], 1);
+ }
+ close(fd);
+}`
+ if hasRule(scanC(code), "BATOU-CAST-018") {
+ t.Error("BATOU-CAST-018 false positive on open-once-outside-loop")
+ }
+}
diff --git a/batou-core/analyzer/cast/cast_priv_return.go b/batou-core/analyzer/cast/cast_priv_return.go
new file mode 100644
index 0000000..680edcc
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_priv_return.go
@@ -0,0 +1,182 @@
+package cast
+
+import (
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// Stage-2 structural check: unchecked return value of a privilege-drop call
+// (BATOU-CAST-008, CWE-252 — Unchecked Return Value).
+//
+// The vulnerability class is narrow and well-defined: a setuid/setgid-family
+// call that DROPS privileges (root -> unprivileged) can FAIL — for example with
+// EAGAIN when RLIMIT_NPROC is hit, or EPERM in a restricted environment. If the
+// program ignores the failure it keeps running with the original (root)
+// privileges it intended to shed. This is the root cause of multiple real CVEs
+// (e.g. the sendmail / Postfix setuid-failure class). The actionable, low-noise
+// signal is exactly: the drop call's RETURN VALUE is discarded.
+//
+// Scope discipline (why this does not false-positive):
+// - Only the privilege-changing set* family is matched — not arbitrary libc
+// calls. memcpy/printf/etc. are never considered.
+// - The match requires the call to be the SOLE expression of a bare
+// expression_statement (`setuid(u);`). That structurally excludes every
+// pattern where the result IS consumed:
+// * `if (setuid(u) != 0) ...` -> call lives under the if condition
+// * `r = setuid(u);` -> call lives under assignment_expression
+// * `return setuid(u);` -> call lives under return_statement
+// * `while (setresuid(...))` -> call lives under the loop condition
+// so this fires only when the return is genuinely thrown away.
+//
+// These are unique reserved POSIX symbols, so there is no bare-name collision
+// with application code (no app method is named setresuid).
+
+// privDropFuncs is the set of POSIX privilege-changing syscalls whose failure
+// must be checked. A discarded return value here is CWE-252.
+var privDropFuncs = map[string]string{
+ "setuid": "real user ID",
+ "setgid": "real group ID",
+ "seteuid": "effective user ID",
+ "setegid": "effective group ID",
+ "setreuid": "real and effective user IDs",
+ "setregid": "real and effective group IDs",
+ "setresuid": "real, effective, and saved user IDs",
+ "setresgid": "real, effective, and saved group IDs",
+}
+
+// checkUncheckedPrivDrop emits BATOU-CAST-008 when a privilege-drop call's
+// return value is discarded (the call is the entire expression of a bare
+// expression_statement). n is a call_expression node.
+func (c *cChecker) checkUncheckedPrivDrop(n *ast.Node) {
+ funcName := cCallName(n)
+ which, ok := privDropFuncs[funcName]
+ if !ok {
+ return
+ }
+
+ // The call must be the SOLE child expression of a bare expression_statement
+ // for the return to count as discarded. tree-sitter-c wraps a standalone
+ // `setuid(u);` as expression_statement -> call_expression. Any consuming
+ // context (assignment, if/while condition, return, cast, larger expression)
+ // puts a different node between the call and its statement, so this check is
+ // false precisely when the result is used.
+ parent := n.Parent()
+ if parent == nil || parent.Type() != "expression_statement" {
+ return
+ }
+ // Defend against `setuid(u), other();` (comma expression smuggled into one
+ // statement) by requiring the call to be the statement's only named child.
+ if named := parent.NamedChildren(); len(named) != 1 || named[0] != n {
+ return
+ }
+
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-008",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Unchecked return value of privilege-drop call " + funcName + "()",
+ Description: funcName + "() changes the " + which + " but its return value is discarded. " +
+ "Privilege-drop syscalls can fail (e.g. EAGAIN under RLIMIT_NPROC, EPERM in a restricted " +
+ "environment); if the failure is ignored the process keeps its original elevated privileges, " +
+ "defeating the privilege separation (CWE-252).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Check the return value and abort on failure: if (" + funcName + "(...) != 0) { perror(\"" +
+ funcName + "\"); _exit(1); }. Never continue execution when a privilege drop fails.",
+ CWEID: "CWE-252",
+ OWASPCategory: "A04:2021-Insecure Design",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"unchecked-return", "privilege-drop", "privilege-management", "ast"},
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Supplementary-group drop ordering (BATOU-CAST-017, CWE-252 / CWE-271).
+//
+// When a privileged process drops to an unprivileged user it must drop its
+// SUPPLEMENTARY groups (setgroups/initgroups) BEFORE the final setuid(). A
+// function that calls setgid() AND setuid() — signalling a deliberate
+// privilege drop — but never setgroups()/initgroups() leaves root's
+// supplementary groups attached to the now-"unprivileged" process, so it can
+// still access group-restricted resources. This is the canonical
+// privilege-separation ordering bug (e.g. the historical ping / wu-ftpd class).
+//
+// Precision / why this does not false-positive:
+// - Only the PERMANENT-drop forms setuid()/setgid() are matched. When a
+// privileged process calls these they change ALL of the real, effective,
+// and saved IDs — the irreversible "shed root" shape. The EFFECTIVE-only
+// setters seteuid()/setegid() are deliberately EXCLUDED: those are used for
+// a TEMPORARY privilege swap that is later restored (e.g. dropbear's
+// svr-agentfwd seteuid()/restore), where dropping supplementary groups
+// would be wrong. The partial setresuid(-1,...) form is likewise excluded.
+// - Both setgid AND setuid must appear in the same function. A program that
+// only changes the uid (no setgid) is not signalling a full identity drop
+// and is not flagged.
+// - If setgroups OR initgroups appears anywhere in the function, the function
+// is doing the right thing and nothing fires.
+// - These are unique reserved POSIX symbols, so there is no bare-name
+// collision with application code.
+//
+// Implemented independently from the POSIX privilege API and the CWE-252/CWE-271
+// definitions.
+// ---------------------------------------------------------------------------
+
+// checkPrivDropGroupOrder emits BATOU-CAST-017 for a function that permanently
+// drops both uid and gid but never drops supplementary groups. n is a
+// function_definition.
+func (c *cChecker) checkPrivDropGroupOrder(n *ast.Node) {
+ body := n.ChildByFieldName("body")
+ if body == nil {
+ return
+ }
+ var sawSetgid, sawSetuid, sawGroupDrop bool
+ var setuidCall *ast.Node
+ body.Walk(func(w *ast.Node) bool {
+ if w.Type() != "call_expression" {
+ return true
+ }
+ switch cCallName(w) {
+ // Only the permanent all-ID drop forms — NOT seteuid/setegid (temporary
+ // swaps) or setresuid/setresgid (often partial -1 changes).
+ case "setgid":
+ sawSetgid = true
+ case "setuid":
+ sawSetuid = true
+ if setuidCall == nil {
+ setuidCall = w
+ }
+ case "setgroups", "initgroups":
+ sawGroupDrop = true
+ }
+ return true
+ })
+ if !sawSetgid || !sawSetuid || sawGroupDrop {
+ return
+ }
+ anchor := setuidCall
+ if anchor == nil {
+ anchor = n
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-017",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Privilege drop omits setgroups()/initgroups()",
+ Description: "This function drops the user ID (setuid family) and group ID (setgid family) but never " +
+ "calls setgroups() or initgroups(). Without dropping the supplementary groups, the process keeps " +
+ "root's group memberships after the uid/gid change and can still reach group-restricted resources, " +
+ "defeating the privilege separation (CWE-252 / CWE-271).",
+ FilePath: c.filePath,
+ LineNumber: int(anchor.StartRow()) + 1,
+ MatchedText: truncate(anchor.Text(), 200),
+ Suggestion: "Drop supplementary groups before the final setuid(): call setgroups(0, NULL) (or initgroups(user, gid)), then setgid(gid), then setuid(uid), checking each return value.",
+ CWEID: "CWE-252",
+ OWASPCategory: "A04:2021-Insecure Design",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"privilege-drop", "supplementary-groups", "privilege-management", "ast"},
+ })
+}
diff --git a/batou-core/analyzer/cast/cast_test.go b/batou-core/analyzer/cast/cast_test.go
index 3424f7a..530af0e 100644
--- a/batou-core/analyzer/cast/cast_test.go
+++ b/batou-core/analyzer/cast/cast_test.go
@@ -1,10 +1,9 @@
package cast
import (
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "testing"
)
func scanC(code string) []rules.Finding {
@@ -197,6 +196,343 @@ func TestWrongLanguage(t *testing.T) {
}
}
+// TestFormatStringConstIdentifier covers the common false-positive shapes seen
+// in real C codebases (Redis, vendored Lua, hdr_histogram): the format
+// argument is a macro identifier, a const local pointing at a literal, or a
+// ternary between two literals. None of these are CWE-134.
+func TestFormatStringConstIdentifier(t *testing.T) {
+ // Hardcoded literal — must NOT fire.
+ literalCode := `
+void handler(int x) {
+ printf("hello %d\n", x);
+}
+`
+ if got := countByRule(scanC(literalCode), "BATOU-CAST-002"); got != 0 {
+ t.Errorf("printf with literal format should not fire, got %d findings", got)
+ }
+
+ // User-controlled format — must fire. We deliberately use a name that
+ // does not match the const-format heuristics (no fmt/format/_str/ALL_CAPS).
+ taintedCode := `
+void handler(char *input, int x) {
+ printf(input, x);
+}
+`
+ if got := countByRule(scanC(taintedCode), "BATOU-CAST-002"); got != 1 {
+ t.Errorf("printf with non-const variable format should fire once, got %d", got)
+ }
+
+ // ALL_CAPS macro identifier (e.g. LUA_NUMBER_FMT, CLUSTER_MANAGER_INVALID_HOST_ARG).
+ macroCode := `
+#define LUA_NUMBER_FMT "%.14g"
+void handler(double d) {
+ printf(LUA_NUMBER_FMT, d);
+ fprintf(stderr, CLUSTER_MANAGER_INVALID_HOST_ARG);
+}
+`
+ if got := countByRule(scanC(macroCode), "BATOU-CAST-002"); got != 0 {
+ t.Errorf("printf/fprintf with ALL_CAPS macro format should not fire, got %d findings", got)
+ }
+
+ // Lowercase const-format names (format_str, head_format, line_format,
+ // ascii_logo, branch, fmt) — Redis/Lua/hdr_histogram FPs.
+ constNameCode := `
+void handler(char *str, int len, int significant_figures, FILE *stream,
+ double value, double percentile, long total_count,
+ double inverted_percentile, char *buf, char *ascii_logo) {
+ const char *format_str = "%s%d%s";
+ const char *head_format = "%s\n";
+ const char *line_format = "%f\n";
+ const char *fmt = "%g";
+ const char *branch = " (%c) ";
+ snprintf(str, len, format_str, "x", significant_figures, "y");
+ fprintf(stream, head_format, "Value");
+ fprintf(stream, line_format, value, percentile, total_count, inverted_percentile);
+ printf(fmt, value);
+ printf(branch, 'a');
+ snprintf(buf, 1024, ascii_logo, "v");
+}
+`
+ if got := countByRule(scanC(constNameCode), "BATOU-CAST-002"); got != 0 {
+ t.Errorf("printf-family with const-named format identifier should not fire, got %d findings", got)
+ }
+
+ // Ternary between two string literals (server.c:6290 pattern).
+ ternaryCode := `
+void handler(int cnt, char *buf, int buflen) {
+ snprintf(buf + buflen, 1024, (cnt == 0) ? "%s=%llu" : ",%s=%llu", "k", 1ULL);
+}
+`
+ if got := countByRule(scanC(ternaryCode), "BATOU-CAST-002"); got != 0 {
+ t.Errorf("snprintf with ternary-of-literals format should not fire, got %d findings", got)
+ }
+
+ // Non-const variable name that is NOT in the allowlist — should still fire.
+ nonConstCode := `
+void handler(char *attacker_controlled) {
+ printf(attacker_controlled);
+}
+`
+ if got := countByRule(scanC(nonConstCode), "BATOU-CAST-002"); got != 1 {
+ t.Errorf("printf with attacker_controlled variable should fire once, got %d", got)
+ }
+}
+
+// TestTaintedSizeWrite covers CWE-787: a memory-copy intrinsic whose length
+// argument is a caller-controlled function parameter writing into a fixed-size
+// stack buffer.
+func TestTaintedSizeWrite(t *testing.T) {
+ // Canonical probe: n is a parameter, b is a fixed buffer -> CWE-787.
+ vuln := `
+#include
+void f(int n, char *src) {
+ char b[64];
+ memcpy(b, src, n);
+}
+`
+ f := findByRule(scanC(vuln), "BATOU-CAST-004")
+ if f == nil {
+ t.Fatal("expected CWE-787 finding for memcpy with parameter-controlled size")
+ }
+ if f.CWEID != "CWE-787" {
+ t.Errorf("expected CWE-787, got %s", f.CWEID)
+ }
+
+ // memmove variant.
+ if findByRule(scanC(`
+#include
+void g(unsigned len, char *p) {
+ char dst[128];
+ memmove(dst, p, len);
+}
+`), "BATOU-CAST-004") == nil {
+ t.Error("expected CWE-787 finding for memmove with parameter-controlled size")
+ }
+}
+
+// TestTaintedSizeWriteSafe covers the FP boundaries: a literal/sizeof size, a
+// non-parameter local size, or a non-fixed destination must NOT fire.
+func TestTaintedSizeWriteSafe(t *testing.T) {
+ cases := map[string]string{
+ "literal size is bounded": `
+#include
+void f(char *src) {
+ char b[64];
+ memcpy(b, src, 64);
+}
+`,
+ "sizeof size is bounded": `
+#include
+void f(char *src) {
+ char b[64];
+ memcpy(b, src, sizeof(b));
+}
+`,
+ "local non-parameter size": `
+#include
+#include
+void f(char *src) {
+ char b[64];
+ int n = atoi("10");
+ memcpy(b, src, n);
+}
+`,
+ "destination is not a fixed buffer": `
+#include
+void f(int n, char *src, char *heapdst) {
+ memcpy(heapdst, src, n);
+}
+`,
+ }
+ for name, code := range cases {
+ if f := findByRule(scanC(code), "BATOU-CAST-004"); f != nil {
+ t.Errorf("%s: should NOT fire CWE-787, got %q", name, f.Title)
+ }
+ }
+}
+
+// TestTaintedSizeWriteBoundsGuardSuppressed reproduces the real-world Redis FP
+// shape (t_stream.c:2383): a memcpy whose parameter-controlled length is
+// constrained by an early-exit size check ONE LINE ABOVE. The copy is bounded,
+// so CWE-787 must NOT fire. This is the regression test for the bounds-guard
+// recogniser (cast_bounds_guard.go).
+func TestTaintedSizeWriteBoundsGuardSuppressed(t *testing.T) {
+ cases := map[string]string{
+ // Redis t_stream.c streamGenericParseIDOrReply shape.
+ "sizeof-1 guard above memcpy": `
+#include
+int parse(int strict, const char *o, int olen) {
+ char buf[128];
+ if (olen > sizeof(buf)-1) return -1;
+ memcpy(buf, o, olen);
+ return 0;
+}
+`,
+ // Redis redis-check-aof.c PATH_MAX shape (guard a few lines above).
+ "PATH_MAX guard above memcpy": `
+#include
+#include
+int check(int len, const char *filepath) {
+ char temp[PATH_MAX];
+ if (len > PATH_MAX) {
+ return -1;
+ }
+ /* glibc dirname may modify its argument. */
+ memcpy(temp, filepath, len);
+ return 0;
+}
+`,
+ // Redis cluster_asm.c CLUSTER_NAMELEN equality guard.
+ "!= NAMELEN const guard above memcpy": `
+#include
+#define CLUSTER_NAMELEN 40
+int loadtask(int plen, const char *parts, char *dest) {
+ char source[CLUSTER_NAMELEN];
+ if (plen != CLUSTER_NAMELEN) return -1;
+ memcpy(source, parts, plen);
+ return 0;
+}
+`,
+ // Guard with a goto rejection (Redis idiom).
+ "goto-rejection size guard above memcpy": `
+#include
+int f(int n, const char *src) {
+ char b[64];
+ if (n >= sizeof(b)) goto fail;
+ memcpy(b, src, n);
+ return 0;
+fail:
+ return -1;
+}
+`,
+ }
+ for name, code := range cases {
+ if f := findByRule(scanC(code), "BATOU-CAST-004"); f != nil {
+ t.Errorf("%s: bounds-guarded copy should NOT fire CWE-787, got %q at line %d",
+ name, f.Title, f.LineNumber)
+ }
+ }
+}
+
+// TestTaintedSizeWriteUnguardedStillFires proves the bounds-guard recogniser
+// TIGHTENS rather than DISABLES: a memcpy with no size guard, or one whose only
+// preceding check is unrelated (a NULL-check, or a length check on a DIFFERENT
+// variable, or a guard too far above), must STILL fire CWE-787.
+func TestTaintedSizeWriteUnguardedStillFires(t *testing.T) {
+ cases := map[string]string{
+ // No guard at all — the canonical TP.
+ "no guard": `
+#include
+void f(int n, char *src) {
+ char b[64];
+ memcpy(b, src, n);
+}
+`,
+ // Preceding check is a NULL-check, not a size comparison (Redis
+ // cluster_legacy.c:488 shape — this must keep firing).
+ "null-check is not a size guard": `
+#include
+#include
+void f(int n, char *src) {
+ char b[64];
+ if (src == NULL) return;
+ memcpy(b, src, n);
+}
+`,
+ // Size guard constrains a DIFFERENT variable than the copy size.
+ "size guard on unrelated variable": `
+#include
+void f(int n, int other, char *src) {
+ char b[64];
+ if (other > sizeof(b)-1) return;
+ memcpy(b, src, n);
+}
+`,
+ // Guard exists but is far above the copy (outside the lookback window) —
+ // an unrelated earlier check that must not silence this copy.
+ "size guard too far above": `
+#include
+void f(int n, char *src, char *src2) {
+ char b[64];
+ if (n > sizeof(b)-1) return;
+ int a1 = 1;
+ int a2 = 2;
+ int a3 = 3;
+ int a4 = 4;
+ int a5 = 5;
+ int a6 = 6;
+ int a7 = 7;
+ int a8 = 8;
+ int a9 = 9;
+ memcpy(b, src2, n);
+}
+`,
+ }
+ for name, code := range cases {
+ if findByRule(scanC(code), "BATOU-CAST-004") == nil {
+ t.Errorf("%s: should STILL fire CWE-787 (recogniser must tighten, not disable)", name)
+ }
+ }
+}
+
+// TestAllocOverflow covers CWE-190: an allocation whose size multiplies two
+// non-constant operands and can wrap.
+func TestAllocOverflow(t *testing.T) {
+ vuln := `
+#include
+void *h(int a, int b) {
+ return malloc(a * b);
+}
+`
+ f := findByRule(scanC(vuln), "BATOU-CAST-005")
+ if f == nil {
+ t.Fatal("expected CWE-190 finding for malloc(a * b)")
+ }
+ if f.CWEID != "CWE-190" {
+ t.Errorf("expected CWE-190, got %s", f.CWEID)
+ }
+
+ // calloc per-element size and realloc size are also covered. left-shift too.
+ for _, code := range []string{
+ `#include
+void *h(int a, int b) { return alloca(a * b); }`,
+ `#include
+void *h(int a, int b) { return malloc(a << b); }`,
+ } {
+ if findByRule(scanC(code), "BATOU-CAST-005") == nil {
+ t.Errorf("expected CWE-190 finding for: %s", code)
+ }
+ }
+}
+
+// TestAllocOverflowSafe covers the FP boundaries: a constant factor
+// (var * sizeof(T), var * 16) bounds the product and must NOT fire.
+func TestAllocOverflowSafe(t *testing.T) {
+ cases := map[string]string{
+ "var times sizeof": `
+#include
+void *h(int n) { return malloc(n * sizeof(int)); }
+`,
+ "var times constant": `
+#include
+void *h(int n) { return malloc(n * 16); }
+`,
+ "single variable size": `
+#include
+void *h(int n) { return malloc(n); }
+`,
+ "constant times constant": `
+#include
+void *h(void) { return malloc(8 * 16); }
+`,
+ }
+ for name, code := range cases {
+ if f := findByRule(scanC(code), "BATOU-CAST-005"); f != nil {
+ t.Errorf("%s: should NOT fire CWE-190, got %q", name, f.Title)
+ }
+ }
+}
+
func TestLineNumbers(t *testing.T) {
code := `
/* comment */
@@ -213,3 +549,135 @@ void handler(char *input) {
t.Errorf("expected line 4, got %d", f.LineNumber)
}
}
+
+// ---- Stage-1 UAF / double-free (CAST-006/007) ----
+
+func scanCpp(code string) []rules.Finding {
+ tree := ast.Parse([]byte(code), rules.LangCPP)
+ ctx := &rules.ScanContext{FilePath: "/app/handler.cpp", Content: code, Language: rules.LangCPP, Tree: tree}
+ return (&CASTAnalyzer{}).Scan(ctx)
+}
+
+func hasRule(fs []rules.Finding, id string) bool { return findByRule(fs, id) != nil }
+
+func TestCAST_DoubleFree_Fires(t *testing.T) {
+ code := "void f() {\n\tchar *p = malloc(8);\n\tfree(p);\n\tfree(p);\n}\n"
+ if !hasRule(scanC(code), "BATOU-CAST-006") {
+ t.Error("expected BATOU-CAST-006 (double free)")
+ }
+}
+
+func TestCAST_UAF_Deref_Fires(t *testing.T) {
+ // field deref after free. (Returning a freed pointer is a bare identifier,
+ // not a deref, so the return-value form is intentionally not asserted here;
+ // the call-arg form is covered by TestCAST_UAF_CallArg_Fires.)
+ if !hasRule(scanC("void f(struct s *p) {\n\tfree(p);\n\tp->n = 1;\n}\n"), "BATOU-CAST-007") {
+ t.Error("expected BATOU-CAST-007 on p->n after free")
+ }
+}
+
+func TestCAST_UAF_CallArg_Fires(t *testing.T) {
+ if !hasRule(scanC("void f(char *p, char *s) {\n\tfree(p);\n\tstrcpy(p, s);\n}\n"), "BATOU-CAST-007") {
+ t.Error("expected BATOU-CAST-007 on strcpy(p,...) after free")
+ }
+}
+
+func TestCAST_UAF_Cpp_Delete_Fires(t *testing.T) {
+ if !hasRule(scanCpp("void f(T *p) {\n\tdelete p;\n\tp->run();\n}\n"), "BATOU-CAST-007") {
+ t.Error("expected BATOU-CAST-007 on p->run() after delete")
+ }
+ if !hasRule(scanCpp("void f(int *a) {\n\tdelete[] a;\n\ta[0] = 1;\n}\n"), "BATOU-CAST-007") {
+ t.Error("expected BATOU-CAST-007 on a[0] after delete[]")
+ }
+}
+
+// FP must-not-fire cases (the gate proof).
+func TestCAST_UAF_NoFP_ConditionalFreeReturn(t *testing.T) {
+ code := "void f(char *p, int err) {\n\tif (err) {\n\t\tfree(p);\n\t\treturn;\n\t}\n\tuse(p);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-007") || hasRule(scanC(code), "BATOU-CAST-006") {
+ t.Error("conditional free+return then use must NOT fire (branch rule)")
+ }
+}
+
+func TestCAST_UAF_NoFP_ReassignClears(t *testing.T) {
+ code := "void f(char *p) {\n\tfree(p);\n\tp = malloc(16);\n\tuse(p);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-007") {
+ t.Error("free then reassign then use must NOT fire (reassign clears)")
+ }
+}
+
+func TestCAST_UAF_NoFP_DistinctObjects(t *testing.T) {
+ code := "void f(char *a, char *b) {\n\tfree(a);\n\tfree(b);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-006") {
+ t.Error("freeing two distinct objects must NOT be a double free")
+ }
+}
+
+func TestCAST_UAF_NoFP_FreeWrapperArg(t *testing.T) {
+ // Passing the freed pointer to a cleanup helper / freeReplyObject is not a UAF.
+ code := "void f(char *p) {\n\tfree(p);\n\tfreeReplyObject(p);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-007") {
+ t.Error("passing freed pointer to a cleanup call must NOT fire")
+ }
+}
+
+// --- BATOU-CAST-008: unchecked privilege-drop return value (CWE-252) ---
+
+func TestCAST_PrivDrop_UncheckedFires(t *testing.T) {
+ // A bare setuid()/setgid() statement discards the return — the drop may
+ // silently fail and leave the process running as root (CWE-252).
+ code := "void drop(uid_t u, gid_t g) {\n\tsetgid(g);\n\tsetuid(u);\n}\n"
+ findings := scanC(code)
+ if countByRule(findings, "BATOU-CAST-008") != 2 {
+ t.Errorf("expected 2 unchecked priv-drop findings, got %d", countByRule(findings, "BATOU-CAST-008"))
+ for _, f := range findings {
+ t.Logf(" %s: %s (line %d)", f.RuleID, f.Title, f.LineNumber)
+ }
+ }
+ if f := findByRule(findings, "BATOU-CAST-008"); f != nil && f.CWEID != "CWE-252" {
+ t.Errorf("expected CWE-252, got %s", f.CWEID)
+ }
+}
+
+func TestCAST_PrivDrop_AllVariantsFire(t *testing.T) {
+ // Every member of the set*id family is in scope when its return is discarded.
+ code := "void d(void) {\n" +
+ "\tsetuid(0);\n\tsetgid(0);\n\tseteuid(0);\n\tsetegid(0);\n" +
+ "\tsetreuid(0,0);\n\tsetregid(0,0);\n\tsetresuid(0,0,0);\n\tsetresgid(0,0,0);\n}\n"
+ if got := countByRule(scanC(code), "BATOU-CAST-008"); got != 8 {
+ t.Errorf("expected 8 priv-drop findings (one per set*id), got %d", got)
+ }
+}
+
+func TestCAST_PrivDrop_NoFP_IfGuarded(t *testing.T) {
+ // `if (setuid(u) != 0)` consumes the return — the failure IS handled.
+ code := "void drop(uid_t u) {\n\tif (setuid(u) != 0) {\n\t\t_exit(1);\n\t}\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-008") {
+ t.Error("if-guarded setuid() return must NOT fire (return is checked)")
+ }
+}
+
+func TestCAST_PrivDrop_NoFP_Assigned(t *testing.T) {
+ // `int r = setuid(u);` captures the return for a later check — not discarded.
+ code := "void drop(uid_t u) {\n\tint r = setuid(u);\n\tif (r) _exit(1);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-008") {
+ t.Error("assigned setuid() return must NOT fire (return is captured)")
+ }
+}
+
+func TestCAST_PrivDrop_NoFP_Returned(t *testing.T) {
+ // `return setuid(u);` propagates the status to the caller — not discarded.
+ code := "int drop(uid_t u) {\n\treturn setuid(u);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-008") {
+ t.Error("returned setuid() result must NOT fire (status propagated)")
+ }
+}
+
+func TestCAST_PrivDrop_NoFP_UnrelatedSetCall(t *testing.T) {
+ // A bare call to an unrelated set* / app function must never be confused
+ // with a privilege-drop syscall — the family list is exact.
+ code := "void f(int x) {\n\tsetsockopt(x, 0, 0, 0, 0);\n\tsettings_apply(x);\n}\n"
+ if hasRule(scanC(code), "BATOU-CAST-008") {
+ t.Error("non-priv-drop set* call must NOT fire (exact family match)")
+ }
+}
diff --git a/batou-core/analyzer/cast/cast_tls.go b/batou-core/analyzer/cast/cast_tls.go
new file mode 100644
index 0000000..0949ad9
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_tls.go
@@ -0,0 +1,321 @@
+package cast
+
+import (
+ "strings"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// ---------------------------------------------------------------------------
+// OpenSSL always-accept verify-callback detection (CWE-295).
+//
+// Shape detected (BATOU-CAST-009):
+//
+// int my_cb(int preverify_ok, X509_STORE_CTX *ctx) {
+// return 1; // unconditionally accept every certificate
+// }
+// ...
+// SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, my_cb);
+//
+// Wiring a verify callback that always returns 1 silently re-enables the
+// "accept anything" behaviour that SSL_VERIFY_PEER was supposed to enforce —
+// the cert chain result (preverify_ok) is discarded.
+//
+// Precision: the finding fires ONLY when BOTH conditions hold:
+// 1. the callback identifier is the 3rd argument of an
+// SSL_CTX_set_verify / SSL_set_verify call, AND
+// 2. that callback's definition has a single trivial `return 1;` body
+// (a `return `) and contains no conditional / loop / other
+// call that could inspect the certificate.
+//
+// A real callback that examines the chain (any `if`, loop, or function call in
+// its body) is left untouched. Implemented independently from the CWE-295
+// definition and the OpenSSL public API docs.
+// ---------------------------------------------------------------------------
+
+// checkTLSVerifyCallbacks runs the two-pass always-accept callback analysis and
+// appends any findings. Called once per file from walk().
+func (c *cChecker) checkTLSVerifyCallbacks() {
+ root := c.tree.Root()
+ if root == nil {
+ return
+ }
+
+ // Pass 1: collect callback identifier names wired into SSL(_CTX)_set_verify
+ // as the 3rd argument (index 2).
+ registered := map[string]bool{}
+ root.Walk(func(n *ast.Node) bool {
+ if n.Type() != "call_expression" {
+ return true
+ }
+ name := cCallName(n)
+ if name != "SSL_CTX_set_verify" && name != "SSL_set_verify" {
+ return true
+ }
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return true
+ }
+ named := args.NamedChildren()
+ if len(named) < 3 {
+ return true
+ }
+ // 3rd arg must be a bare identifier (the callback function name).
+ if cb := identName(named[2]); cb != "" {
+ registered[cb] = true
+ }
+ return true
+ })
+ if len(registered) == 0 {
+ return
+ }
+
+ // Pass 2: find each registered callback's definition and check whether its
+ // body is the trivial always-accept stub.
+ root.Walk(func(n *ast.Node) bool {
+ if n.Type() != "function_definition" {
+ return true
+ }
+ fnName := functionDefName(n)
+ if fnName == "" || !registered[fnName] {
+ return true
+ }
+ body := n.ChildByFieldName("body")
+ if body == nil {
+ return true
+ }
+ if !bodyIsAlwaysAccept(body) {
+ return true
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-009",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "TLS verify callback always accepts (OpenSSL '" + fnName + "' returns 1 unconditionally)",
+ Description: "The certificate-verification callback '" + fnName + "' is wired into SSL_CTX_set_verify/SSL_set_verify but its body unconditionally returns 1, accepting every certificate regardless of the chain-validation result (preverify_ok). This disables peer verification and allows man-in-the-middle attacks.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Return preverify_ok (or perform real checks and return 0 on failure). A verify callback must propagate the validation result, not hardcode acceptance.",
+ CWEID: "CWE-295",
+ OWASPCategory: "A07:2021-Identification and Authentication Failures",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"tls", "cert-validation", "openssl", "ast"},
+ })
+ return false
+ })
+}
+
+// functionDefName extracts the function name from a function_definition node,
+// unwrapping pointer declarators (e.g. `int *f(...)`).
+func functionDefName(fnDef *ast.Node) string {
+ decl := fnDef.ChildByFieldName("declarator")
+ for decl != nil && decl.Type() == "pointer_declarator" {
+ decl = decl.ChildByFieldName("declarator")
+ }
+ if decl == nil || decl.Type() != "function_declarator" {
+ return ""
+ }
+ return declaratorIdentifier(decl.ChildByFieldName("declarator"))
+}
+
+// bodyIsAlwaysAccept reports whether a compound_statement body is the trivial
+// always-accept stub: it contains at least one `return 1;` and NO control flow
+// (if/for/while/switch), NO nested calls, and every return it does contain
+// returns the literal 1. Any cert-inspecting logic (a conditional or a call)
+// disqualifies it, so real callbacks never match.
+func bodyIsAlwaysAccept(body *ast.Node) bool {
+ sawReturn1 := false
+ disqualified := false
+
+ body.Walk(func(n *ast.Node) bool {
+ switch n.Type() {
+ case "if_statement", "for_statement", "while_statement",
+ "do_statement", "switch_statement", "conditional_expression",
+ "call_expression":
+ // Any branching or call could inspect the certificate — not a stub.
+ disqualified = true
+ return false
+ case "return_statement":
+ if returnsLiteralOne(n) {
+ sawReturn1 = true
+ } else {
+ // `return preverify_ok;` or `return 0;` etc. — not always-accept.
+ disqualified = true
+ }
+ return false
+ }
+ return true
+ })
+
+ return sawReturn1 && !disqualified
+}
+
+// returnsLiteralOne reports whether a return_statement returns the integer
+// literal 1 (the OpenSSL "accept" value). Handles `return 1;` and `return (1);`.
+func returnsLiteralOne(ret *ast.Node) bool {
+ for _, ch := range ret.NamedChildren() {
+ v := unwrapParens(ch)
+ if v == nil {
+ continue
+ }
+ if v.Type() == "number_literal" && v.Text() == "1" {
+ return true
+ }
+ }
+ return false
+}
+
+// ---------------------------------------------------------------------------
+// TLS certificate-verification DISABLED via explicit flag (CWE-295).
+//
+// Two independent, framework-anchored shapes are detected here, each keyed on a
+// UNIQUE library symbol so there is no bare-name collision with application
+// code:
+//
+// BATOU-CAST-011 libcurl: curl_easy_setopt(h, CURLOPT_SSL_VERIFYPEER, 0)
+// or CURLOPT_SSL_VERIFYHOST with 0/false. Disabling either
+// leaves the client open to a man-in-the-middle.
+//
+// BATOU-CAST-012 GnuTLS: gnutls_certificate_set_verify_flags(creds,
+// GNUTLS_VERIFY_DISABLE_*) — the DISABLE_CA_SIGN /
+// DISABLE_TIME_CHECKS / DISABLE_CRL_CHECKS family TURNS OFF a
+// validation step. (The GNUTLS_VERIFY_ALLOW_* legacy-cert
+// compatibility members are NOT matched: real CA-bundle setup
+// code such as curl's gtls.c uses ALLOW_X509_V1_CA_CRT
+// deliberately, so matching ALLOW_* would false-positive on
+// it. Only an explicit DISABLE of a check is unambiguous.)
+//
+// NOTE on the OpenSSL SSL_VERIFY_NONE shape: it was evaluated and deliberately
+// NOT shipped here. TLS-stack wrappers legitimately set SSL_VERIFY_NONE and then
+// verify the chain manually post-handshake (curl's openssl.c) or select the mode
+// per a client-auth configuration option (redis's tls.c, where the adjacent case
+// uses SSL_VERIFY_PEER). A single-file AST check cannot tell that compensated use
+// apart from a blanket disable, so flagging it produces false positives on
+// exactly the well-engineered code we scan. CAST-009 already covers the precise,
+// FP-free sibling shape: a verify CALLBACK that unconditionally returns 1.
+//
+// Each rule below fires only on the literal "off"/DISABLE value, so a
+// runtime-computed verify mode (CURLOPT_SSL_VERIFYPEER, want_verify) is left
+// alone. Implemented independently from the libcurl / GnuTLS public API docs and
+// the CWE-295 definition.
+// ---------------------------------------------------------------------------
+
+// checkTLSVerifyDisabled walks every call_expression once and dispatches to the
+// two flag-based verify-disable shapes. Called once per file from walk().
+func (c *cChecker) checkTLSVerifyDisabled(n *ast.Node) {
+ switch cCallName(n) {
+ case "curl_easy_setopt":
+ c.checkCurlVerifyDisabled(n)
+ case "gnutls_certificate_set_verify_flags":
+ c.checkGnuTLSVerifyDisabled(n)
+ }
+}
+
+// argText returns the trimmed source text of the i-th named argument of a
+// call_expression, or "" if absent. Parentheses are unwrapped so `(0)` reads as
+// `0`.
+func callArgText(n *ast.Node, i int) string {
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return ""
+ }
+ named := args.NamedChildren()
+ if i < 0 || i >= len(named) {
+ return ""
+ }
+ return unwrapParens(named[i]).Text()
+}
+
+// checkCurlVerifyDisabled flags curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER,
+// 0) and CURLOPT_SSL_VERIFYHOST set to 0/false (option in arg 1, value in arg 2).
+func (c *cChecker) checkCurlVerifyDisabled(n *ast.Node) {
+ opt := callArgText(n, 1)
+ if opt != "CURLOPT_SSL_VERIFYPEER" && opt != "CURLOPT_SSL_VERIFYHOST" {
+ return
+ }
+ val := callArgText(n, 2)
+ // Only the literal "off" values disable verification. A variable
+ // (CURLOPT_SSL_VERIFYPEER, want_verify) is left to runtime and not flagged.
+ if val != "0" && val != "0L" && val != "false" && val != "FALSE" {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-011",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "libcurl TLS verification disabled (" + opt + " = " + val + ")",
+ Description: "curl_easy_setopt sets " + opt + " to " + val + ", disabling " +
+ "certificate-chain (CURLOPT_SSL_VERIFYPEER) or hostname (CURLOPT_SSL_VERIFYHOST) validation. " +
+ "The client will then trust any certificate, allowing a man-in-the-middle to impersonate the " +
+ "server (CWE-295).",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Leave CURLOPT_SSL_VERIFYPEER at 1 and CURLOPT_SSL_VERIFYHOST at 2 (the secure defaults). Pin or supply a CA bundle (CURLOPT_CAINFO/CURLOPT_CAPATH) instead of disabling verification.",
+ CWEID: "CWE-295",
+ OWASPCategory: "A07:2021-Identification and Authentication Failures",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"tls", "cert-validation", "libcurl", "ast"},
+ })
+}
+
+// checkGnuTLSVerifyDisabled flags gnutls_certificate_set_verify_flags whose flag
+// argument (index 1) contains a GNUTLS_VERIFY_DISABLE_* / ALLOW_* token that
+// relaxes chain validation.
+func (c *cChecker) checkGnuTLSVerifyDisabled(n *ast.Node) {
+ flags := callArgText(n, 1)
+ if flags == "" {
+ return
+ }
+ if !gnutlsFlagsDisableVerify(flags) {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CAST-012",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "GnuTLS certificate verification weakened (gnutls_certificate_set_verify_flags)",
+ Description: "gnutls_certificate_set_verify_flags is configured with a GNUTLS_VERIFY_DISABLE_* / " +
+ "GNUTLS_VERIFY_ALLOW_* flag that relaxes X.509 chain validation (e.g. disabling CA-signature, " +
+ "time, or any-X509-V1-CA checks). This weakens or removes certificate validation and exposes the " +
+ "connection to man-in-the-middle attacks (CWE-295).",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Do not set GNUTLS_VERIFY_DISABLE_*/ALLOW_* flags in production. Keep the default strict verification and let gnutls_certificate_verify_peers* enforce the full chain.",
+ CWEID: "CWE-295",
+ OWASPCategory: "A07:2021-Identification and Authentication Failures",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"tls", "cert-validation", "gnutls", "ast"},
+ })
+}
+
+// gnutlsFlagsDisableVerify reports whether a GnuTLS verify-flags expression
+// contains a token that explicitly DISABLES a validation step. Only the
+// GNUTLS_VERIFY_DISABLE_* members are matched — each one turns a check off,
+// which is unambiguous. The GNUTLS_VERIFY_ALLOW_* members (legacy-cert/
+// broken-signature compatibility allowances) are intentionally NOT matched:
+// real CA-trust setup code (e.g. curl's gtls.c uses ALLOW_X509_V1_CA_CRT)
+// legitimately sets them, so matching ALLOW_* would false-positive on it.
+// GNUTLS_VERIFY_DO_NOT_ALLOW_* members strengthen checks and are likewise
+// excluded by requiring the "DISABLE_" infix.
+func gnutlsFlagsDisableVerify(flags string) bool {
+ disabling := []string{
+ "GNUTLS_VERIFY_DISABLE_CA_SIGN",
+ "GNUTLS_VERIFY_DISABLE_TRUSTED_TIME_CHECKS",
+ "GNUTLS_VERIFY_DISABLE_TIME_CHECKS",
+ "GNUTLS_VERIFY_DISABLE_CRL_CHECKS",
+ }
+ for _, w := range disabling {
+ if strings.Contains(flags, w) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/batou-core/analyzer/cast/cast_tls_test.go b/batou-core/analyzer/cast/cast_tls_test.go
new file mode 100644
index 0000000..2109b8d
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_tls_test.go
@@ -0,0 +1,84 @@
+package cast
+
+import "testing"
+
+// --- BATOU-CAST-009: OpenSSL always-accept verify callback (CWE-295) ---
+
+// TP: callback wired into SSL_CTX_set_verify that unconditionally returns 1.
+func TestCAST008_AlwaysAcceptCallback(t *testing.T) {
+ code := `
+#include
+static int accept_all(int preverify_ok, X509_STORE_CTX *ctx) {
+ return 1;
+}
+void setup(SSL_CTX *ctx) {
+ SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, accept_all);
+}
+`
+ if !hasRule(scanCpp(code), "BATOU-CAST-009") {
+ t.Error("expected BATOU-CAST-009 for always-accept verify callback")
+ }
+}
+
+// TP: SSL_set_verify (per-connection) variant.
+func TestCAST008_AlwaysAcceptCallback_SSLSetVerify(t *testing.T) {
+ code := `
+static int cb(int ok, X509_STORE_CTX *c) {
+ return (1);
+}
+void f(SSL *ssl) {
+ SSL_set_verify(ssl, SSL_VERIFY_PEER, cb);
+}
+`
+ if !hasRule(scanCpp(code), "BATOU-CAST-009") {
+ t.Error("expected BATOU-CAST-009 for SSL_set_verify always-accept callback")
+ }
+}
+
+// Safe: callback that actually propagates preverify_ok — must NOT fire.
+func TestCAST008_Safe_PropagatesResult(t *testing.T) {
+ code := `
+static int real_cb(int preverify_ok, X509_STORE_CTX *ctx) {
+ return preverify_ok;
+}
+void setup(SSL_CTX *ctx) {
+ SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, real_cb);
+}
+`
+ if hasRule(scanCpp(code), "BATOU-CAST-009") {
+ t.Error("BATOU-CAST-009 false positive on callback that returns preverify_ok")
+ }
+}
+
+// Safe: callback that inspects the chain (has a conditional) — must NOT fire,
+// even though one branch returns 1.
+func TestCAST008_Safe_ConditionalCallback(t *testing.T) {
+ code := `
+static int cb(int preverify_ok, X509_STORE_CTX *ctx) {
+ if (!preverify_ok) {
+ return 0;
+ }
+ return 1;
+}
+void setup(SSL_CTX *ctx) {
+ SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, cb);
+}
+`
+ if hasRule(scanCpp(code), "BATOU-CAST-009") {
+ t.Error("BATOU-CAST-009 false positive on callback with chain inspection")
+ }
+}
+
+// Safe: a function that returns 1 but is NOT wired into set_verify — must NOT
+// fire (no registration anchor).
+func TestCAST008_Safe_UnregisteredReturnsOne(t *testing.T) {
+ code := `
+static int helper(int a, int b) {
+ return 1;
+}
+int main() { return helper(1, 2); }
+`
+ if hasRule(scanCpp(code), "BATOU-CAST-009") {
+ t.Error("BATOU-CAST-009 false positive on unregistered function returning 1")
+ }
+}
diff --git a/batou-core/analyzer/cast/cast_uaf.go b/batou-core/analyzer/cast/cast_uaf.go
new file mode 100644
index 0000000..7480c49
--- /dev/null
+++ b/batou-core/analyzer/cast/cast_uaf.go
@@ -0,0 +1,299 @@
+package cast
+
+import (
+ "strconv"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// Stage-1 intraprocedural use-after-free (BATOU-CAST-007, CWE-416) and
+// double-free (BATOU-CAST-006, CWE-415) detection.
+//
+// This replaces the regex layer's brace-reset heuristic (MEM-004/007/008) with
+// real AST scoping. A per-function recursive block scan tracks freed pointers
+// in source order. The single most important FP-suppression rule is the BRANCH
+// RULE: a free inside a conditional/loop block is path-conditional and must NOT
+// taint the straight-line code after the branch (otherwise the classic
+// `if (err) { free(p); return; } use(p);` cleanup pattern false-positives).
+// We implement that for free by SCOPE: frees in an enclosing block taint uses
+// in nested blocks (a real UAF — the free definitely happened first on that
+// path), but frees that occur inside a block never leak back to the enclosing
+// block's later statements.
+//
+// Only plain-identifier pointers are tracked (free(p), not free(s->buf)) and a
+// reassignment / NULL-set / realloc clears the tracked state — both conservative
+// choices that under-fire rather than over-fire. Verified by cast_test.go
+// fixtures (TP + must-not-fire FP) and a real-repo A/B; the c/cpp bench is
+// CWE-disjoint (CWE-120 only) so these CWE-415/416 checks are bench-neutral.
+
+type freedPtr struct {
+ line int
+ sameBlock bool // freed in the current block (straight-line) vs inherited (conditional)
+}
+
+// derefingCalls are libc functions that DEREFERENCE their pointer argument
+// (read/write the pointed-to memory). Passing a freed pointer to one of these is
+// a genuine use-after-free. We use an allowlist rather than a denylist so that
+// passing a freed pointer's *value* to a comparison / logger / free-wrapper
+// (expect_ptr_eq(p), printf("%p", p), freeReplyObject(p)) does NOT false-positive
+// — only a real dereference does.
+var derefingCalls = map[string]bool{
+ "strcpy": true, "strncpy": true, "strcat": true, "strncat": true,
+ "strlcpy": true, "strlcat": true, "strlen": true, "strnlen": true,
+ "strdup": true, "strndup": true, "strcmp": true, "strncmp": true,
+ "strcasecmp": true, "strncasecmp": true, "strchr": true, "strrchr": true,
+ "strstr": true, "strtok": true, "strspn": true, "strcspn": true,
+ "memcpy": true, "memmove": true, "memcmp": true, "memchr": true, "memset": true,
+ "sprintf": true, "snprintf": true, "sscanf": true, "fputs": true, "puts": true,
+}
+
+func (c *cChecker) checkFunctionFlow(fnDef *ast.Node) {
+ body := fnDef.ChildByFieldName("body")
+ if body == nil || body.Type() != "compound_statement" {
+ return
+ }
+ c.scanFlowBlock(body, map[string]freedPtr{})
+}
+
+// scanFlowBlock processes the direct child statements of a compound_statement in
+// source order. `enclosing` carries pointers freed in ENCLOSING blocks.
+func (c *cChecker) scanFlowBlock(block *ast.Node, enclosing map[string]freedPtr) {
+ freed := map[string]freedPtr{}
+ for k, v := range enclosing {
+ freed[k] = freedPtr{line: v.line, sameBlock: false} // inherited => conditional
+ }
+ for i := 0; i < block.ChildCount(); i++ {
+ stmt := block.Child(i)
+ if stmt == nil || !stmt.IsNamed() {
+ continue
+ }
+ switch stmt.Type() {
+ case "if_statement", "for_statement", "while_statement", "do_statement", "switch_statement":
+ if cond := stmt.ChildByFieldName("condition"); cond != nil {
+ c.flagUses(cond, freed)
+ }
+ for _, b := range nestedBodies(stmt) {
+ if b.Type() == "compound_statement" {
+ c.scanFlowBlock(b, freed)
+ } else {
+ c.scanFlowSingle(b, freed)
+ }
+ }
+ case "return_statement", "break_statement", "continue_statement", "goto_statement":
+ c.flagUses(stmt, freed)
+ return // straight-line path ends; remaining siblings are dead/other-path
+ case "compound_statement":
+ c.scanFlowBlock(stmt, freed)
+ default:
+ c.scanFlowSingle(stmt, freed)
+ }
+ }
+}
+
+// scanFlowSingle handles one straight-line statement: a free (track/double-free),
+// an assignment (clears the LHS), or uses of freed pointers.
+func (c *cChecker) scanFlowSingle(stmt *ast.Node, freed map[string]freedPtr) {
+ if name := freedTarget(stmt, c.language); name != "" {
+ if info, ok := freed[name]; ok {
+ conf := "high"
+ if !info.sameBlock {
+ conf = "medium"
+ }
+ c.emitMemFlow("BATOU-CAST-006", rules.Critical, "CWE-415",
+ "Double free of '"+name+"'",
+ "'"+name+"' is freed again here after being freed on line "+strconv.Itoa(info.line)+
+ " with no intervening reassignment. Freeing the same allocation twice corrupts allocator metadata (double-free) and is exploitable for arbitrary writes.",
+ "Set the pointer to NULL immediately after freeing it, or restructure so each allocation is freed exactly once.",
+ stmt, conf,
+ []string{"double-free", "memory-safety", "use-after-free", "ast"})
+ } else {
+ freed[name] = freedPtr{line: int(stmt.StartRow()) + 1, sameBlock: true}
+ }
+ return
+ }
+ if lhs := assignTarget(stmt); lhs != "" {
+ delete(freed, lhs) // realloc / reassign / = NULL clears the freed state
+ }
+ c.flagUses(stmt, freed)
+}
+
+// flagUses scans `node` for dereference / subscript / field / call-arg uses of a
+// freed pointer and emits a use-after-free for each, dropping the pointer from
+// the freed-set (one finding per pointer per free).
+func (c *cChecker) flagUses(node *ast.Node, freed map[string]freedPtr) {
+ if node == nil || len(freed) == 0 {
+ return
+ }
+ node.Walk(func(n *ast.Node) bool {
+ var used string
+ switch n.Type() {
+ case "field_expression": // p->x / p.x
+ used = identName(n.ChildByFieldName("argument"))
+ case "pointer_expression": // *p (dereference) — but NOT &p (address-of)
+ if op := n.ChildByFieldName("operator"); op != nil && op.Text() == "*" {
+ used = identNameFromChildren(n)
+ }
+ case "subscript_expression": // p[i]
+ used = identName(n.ChildByFieldName("argument"))
+ case "call_expression":
+ // Only a call that DEREFERENCES its pointer arg is a UAF use; passing
+ // the freed pointer's value to a comparison/logger/free-wrapper is not.
+ if derefingCalls[cCallName(n)] {
+ if al := findChild(n, "argument_list"); al != nil {
+ for _, a := range al.NamedChildren() {
+ if name := identName(a); name != "" {
+ c.emitUAF(name, n, freed)
+ }
+ }
+ }
+ }
+ return true // keep descending (nested expressions)
+ }
+ if used != "" {
+ c.emitUAF(used, n, freed)
+ }
+ return true
+ })
+}
+
+func (c *cChecker) emitUAF(name string, n *ast.Node, freed map[string]freedPtr) {
+ info, ok := freed[name]
+ if !ok {
+ return
+ }
+ conf := "high"
+ if !info.sameBlock {
+ conf = "medium"
+ }
+ c.emitMemFlow("BATOU-CAST-007", rules.Critical, "CWE-416",
+ "Use after free of '"+name+"'",
+ "'"+name+"' is used here after being freed on line "+strconv.Itoa(info.line)+
+ ". Dereferencing or passing a freed pointer reads/writes reclaimed memory (use-after-free), a critical, often-exploitable memory-safety bug.",
+ "Do not use the pointer after free. Set it to NULL after freeing and re-allocate before the next use, or restructure the lifetime so the value is read before it is freed.",
+ n, conf,
+ []string{"use-after-free", "memory-safety", "dangling-pointer", "ast"})
+ delete(freed, name) // one finding per pointer per free
+}
+
+func (c *cChecker) emitMemFlow(ruleID string, sev rules.Severity, cwe, title, desc, suggestion string, n *ast.Node, conf string, tags []string) {
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: ruleID,
+ Severity: sev,
+ SeverityLabel: sev.String(),
+ Title: title,
+ Description: desc,
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: suggestion,
+ CWEID: cwe,
+ OWASPCategory: "A06:2021-Vulnerable and Outdated Components",
+ Language: c.language,
+ Confidence: conf,
+ Tags: tags,
+ })
+}
+
+// nestedBodies returns the body block(s) of a control-flow statement.
+func nestedBodies(stmt *ast.Node) []*ast.Node {
+ var out []*ast.Node
+ switch stmt.Type() {
+ case "if_statement":
+ if b := stmt.ChildByFieldName("consequence"); b != nil {
+ out = append(out, b)
+ }
+ if b := stmt.ChildByFieldName("alternative"); b != nil {
+ // `else` field wraps the alternative; unwrap one level if needed.
+ if b.Type() == "else_clause" {
+ if nb := firstNamedChild(b); nb != nil {
+ out = append(out, nb)
+ }
+ } else {
+ out = append(out, b)
+ }
+ }
+ default: // for / while / do / switch
+ if b := stmt.ChildByFieldName("body"); b != nil {
+ out = append(out, b)
+ }
+ }
+ return out
+}
+
+// freedTarget returns the plain-identifier name freed by `stmt` (free(p) in C,
+// also delete/delete[] in C++), or "" if the statement is not a free of a
+// simple identifier.
+func freedTarget(stmt *ast.Node, lang rules.Language) string {
+ var name string
+ stmt.Walk(func(n *ast.Node) bool {
+ if name != "" {
+ return false
+ }
+ switch n.Type() {
+ case "call_expression":
+ if cCallName(n) == "free" {
+ if al := findChild(n, "argument_list"); al != nil {
+ nc := al.NamedChildren()
+ if len(nc) == 1 {
+ name = identName(nc[0])
+ }
+ }
+ }
+ case "delete_expression": // C++ delete p / delete[] p
+ name = identNameFromChildren(n)
+ }
+ return true
+ })
+ return name
+}
+
+// assignTarget returns the identifier being (re)bound by `stmt` — an
+// assignment `p = ...`, a declaration `T *p = ...`, or a C++ `p.reset()` —
+// which clears p's freed state. Returns "" otherwise.
+func assignTarget(stmt *ast.Node) string {
+ var target string
+ stmt.Walk(func(n *ast.Node) bool {
+ if target != "" {
+ return false
+ }
+ switch n.Type() {
+ case "assignment_expression":
+ target = identName(n.ChildByFieldName("left"))
+ case "init_declarator":
+ target = declaratorIdentifier(n.ChildByFieldName("declarator"))
+ case "call_expression":
+ // C++ smart-pointer reset: p.reset() / p.reset(q)
+ fn := n.NamedChildren()
+ if len(fn) > 0 && fn[0].Type() == "field_expression" {
+ if fn[0].ChildByFieldName("field") != nil && fn[0].ChildByFieldName("field").Text() == "reset" {
+ target = identName(fn[0].ChildByFieldName("argument"))
+ }
+ }
+ }
+ return true
+ })
+ return target
+}
+
+// identName returns n's text if n is (or unwraps to) a plain identifier, else "".
+func identName(n *ast.Node) string {
+ if n == nil {
+ return ""
+ }
+ if n.Type() == "identifier" {
+ return n.Text()
+ }
+ return ""
+}
+
+// identNameFromChildren returns the plain-identifier operand of a unary
+// expression (pointer_expression `*p`, delete_expression `delete p`).
+func identNameFromChildren(n *ast.Node) string {
+ for _, ch := range n.NamedChildren() {
+ if ch.Type() == "identifier" {
+ return ch.Text()
+ }
+ }
+ return ""
+}
diff --git a/batou-core/analyzer/csast/csast.go b/batou-core/analyzer/csast/csast.go
index 84320c3..d3269fb 100644
--- a/batou-core/analyzer/csast/csast.go
+++ b/batou-core/analyzer/csast/csast.go
@@ -14,12 +14,12 @@ func init() {
rules.Register(&CSharpASTAnalyzer{})
}
-func (a *CSharpASTAnalyzer) ID() string { return "BATOU-CS-AST" }
-func (a *CSharpASTAnalyzer) Name() string { return "C# AST Security Analyzer" }
-func (a *CSharpASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
-func (a *CSharpASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangCSharp} }
+func (a *CSharpASTAnalyzer) ID() string { return "BATOU-CS-AST" }
+func (a *CSharpASTAnalyzer) Name() string { return "C# AST Security Analyzer" }
+func (a *CSharpASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
+func (a *CSharpASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangCSharp} }
func (a *CSharpASTAnalyzer) Description() string {
- return "AST-based analysis of C# code for SQL injection, insecure deserialization, command injection, ReDoS, and raw SQL in Entity Framework."
+ return "AST-based analysis of C# code for SQL injection, insecure deserialization, command injection, ReDoS, raw SQL in Entity Framework, reflected XSS (Razor/Response.Write), and open redirect."
}
func (a *CSharpASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
@@ -57,9 +57,13 @@ func (c *csChecker) walk() {
c.checkSqlCommandConcat(n)
c.checkInsecureDeserializer(n)
c.checkRegexWithoutTimeout(n)
+ c.checkHtmlStringXSS(n)
case "invocation_expression":
c.checkProcessStart(n)
c.checkRawSQLEntityFramework(n)
+ c.checkRazorXSSInvocation(n)
+ c.checkOpenRedirect(n)
+ c.checkDeserializeInvocation(n)
}
return true
})
@@ -118,11 +122,11 @@ func (c *csChecker) checkInsecureDeserializer(n *ast.Node) {
}
insecureTypes := map[string]string{
- "BinaryFormatter": "BinaryFormatter is insecure and can lead to remote code execution via deserialization attacks.",
- "ObjectStateFormatter": "ObjectStateFormatter is insecure and vulnerable to deserialization attacks.",
- "SoapFormatter": "SoapFormatter is insecure and vulnerable to deserialization attacks.",
+ "BinaryFormatter": "BinaryFormatter is insecure and can lead to remote code execution via deserialization attacks.",
+ "ObjectStateFormatter": "ObjectStateFormatter is insecure and vulnerable to deserialization attacks.",
+ "SoapFormatter": "SoapFormatter is insecure and vulnerable to deserialization attacks.",
"NetDataContractSerializer": "NetDataContractSerializer is insecure when deserializing untrusted data.",
- "LosFormatter": "LosFormatter is insecure and vulnerable to deserialization attacks.",
+ "LosFormatter": "LosFormatter is insecure and vulnerable to deserialization attacks.",
}
if desc, ok := insecureTypes[typeName]; ok {
@@ -281,8 +285,8 @@ func (c *csChecker) checkRawSQLEntityFramework(n *ast.Node) {
efMethods := map[string]bool{
"ExecuteSqlRaw": true,
"ExecuteSqlRawAsync": true,
- "FromSqlRaw": true,
- "SqlQuery": true,
+ "FromSqlRaw": true,
+ "SqlQuery": true,
"ExecuteSqlInterpolated": false, // safe, but we check anyway if passed concat
}
@@ -322,6 +326,287 @@ func (c *csChecker) checkRawSQLEntityFramework(n *ast.Node) {
}
}
+// checkRazorXSSInvocation detects reflected XSS via Razor/WebForms write sinks that
+// emit unencoded output: @Html.Raw(var) and Response.Write(var) with a non-literal
+// argument. Pure string-literal arguments are ignored as safe.
+func (c *csChecker) checkRazorXSSInvocation(n *ast.Node) {
+ fn := n.ChildByFieldName("function")
+ if fn == nil || fn.Type() != "member_access_expression" {
+ return
+ }
+ receiver, method := memberReceiverAndName(fn)
+ if method == "" {
+ return
+ }
+
+ // Set of (receiver.method) XSS write sinks.
+ isXSSSink := false
+ switch {
+ case method == "Raw" && (receiver == "Html" || strings.HasSuffix(receiver, ".Html") || receiver == "@Html"):
+ isXSSSink = true // @Html.Raw(...)
+ case method == "Write" && (receiver == "Response" || strings.HasSuffix(receiver, ".Response")):
+ isXSSSink = true // Response.Write(...)
+ case method == "WriteLine" && (receiver == "Response" || strings.HasSuffix(receiver, ".Response")):
+ isXSSSink = true // Response.WriteLine(...)
+ case method == "Write" && receiver == "context.Response.Output":
+ isXSSSink = true
+ }
+ if !isXSSSink {
+ return
+ }
+
+ argList := n.ChildByFieldName("arguments")
+ if argList == nil {
+ return
+ }
+ if !argHasNonLiteral(argList) {
+ return // only string literals -> safe
+ }
+
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CS-AST-006",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Reflected XSS via " + receiver + "." + method,
+ Description: "Writing a non-constant value through " + receiver + "." + method + " emits unencoded output to the response/HTML, enabling reflected cross-site scripting (XSS) when the value is attacker-controlled.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Encode output before writing it: use @Html.Encode(...) / HttpUtility.HtmlEncode(...), or render via Razor's default-encoded @value instead of Html.Raw / Response.Write.",
+ CWEID: "CWE-79",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangCSharp,
+ Confidence: "high",
+ Tags: []string{"xss", "razor"},
+ })
+}
+
+// checkHtmlStringXSS detects new HtmlString(var) / new MvcHtmlString(var) /
+// new RawString(var) with a non-literal argument, which marks a value as
+// pre-encoded HTML and bypasses Razor auto-encoding (reflected XSS, CWE-79).
+func (c *csChecker) checkHtmlStringXSS(n *ast.Node) {
+ typeName := objectCreationType(n)
+ switch typeName {
+ case "HtmlString", "MvcHtmlString", "RawString", "HtmlText":
+ default:
+ return
+ }
+
+ argList := n.ChildByFieldName("arguments")
+ if argList == nil {
+ return
+ }
+ if !argHasNonLiteral(argList) {
+ return
+ }
+
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CS-AST-007",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Reflected XSS via new " + typeName,
+ Description: "Wrapping a non-constant value in " + typeName + " marks it as trusted, pre-encoded HTML and bypasses Razor's automatic output encoding, enabling reflected XSS if the value is attacker-controlled.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Do not wrap user-controlled data in " + typeName + ". HTML-encode it (HttpUtility.HtmlEncode) and let Razor render it through the default-encoded @value syntax.",
+ CWEID: "CWE-79",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangCSharp,
+ Confidence: "high",
+ Tags: []string{"xss", "razor"},
+ })
+}
+
+// checkOpenRedirect detects Redirect(var) / RedirectPermanent(var) with a
+// non-literal target, where the redirect destination may be attacker-controlled
+// (open redirect, CWE-601). Both bare-call (Redirect(x)) and member-call
+// (Response.Redirect(x)) forms are handled.
+func (c *csChecker) checkOpenRedirect(n *ast.Node) {
+ fn := n.ChildByFieldName("function")
+ if fn == nil {
+ return
+ }
+
+ method := ""
+ switch fn.Type() {
+ case "identifier":
+ method = fn.Text() // Redirect(x) inside a controller
+ case "member_access_expression":
+ _, method = memberReceiverAndName(fn)
+ default:
+ return
+ }
+
+ switch method {
+ case "Redirect", "RedirectPermanent", "RedirectPreserveMethod", "LocalRedirect":
+ default:
+ return
+ }
+
+ argList := n.ChildByFieldName("arguments")
+ if argList == nil {
+ return
+ }
+ args := argList.NamedChildren()
+ if len(args) == 0 {
+ return
+ }
+ if !argHasNonLiteral(argList) {
+ return // constant redirect target -> safe
+ }
+
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CS-AST-008",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "Open redirect via " + method,
+ Description: "Passing a non-constant value to " + method + " lets an attacker control the redirect destination, enabling open-redirect / phishing attacks (CWE-601).",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Validate the redirect target against an allow-list of known-safe local paths, or use Url.IsLocalUrl(target) / LocalRedirect(target) before redirecting.",
+ CWEID: "CWE-601",
+ OWASPCategory: "A01:2021-Broken Access Control",
+ Language: rules.LangCSharp,
+ Confidence: "high",
+ Tags: []string{"open-redirect", "redirect"},
+ })
+}
+
+// checkDeserializeInvocation detects x.Deserialize(stream) where x is a known
+// insecure formatter constructed inline, e.g. new BinaryFormatter().Deserialize(s).
+// The construction node is already flagged by checkInsecureDeserializer; this
+// catches the dataflow sink (.Deserialize(...)) directly for the call site.
+func (c *csChecker) checkDeserializeInvocation(n *ast.Node) {
+ fn := n.ChildByFieldName("function")
+ if fn == nil || fn.Type() != "member_access_expression" {
+ return
+ }
+ _, method := memberReceiverAndName(fn)
+ if method != "Deserialize" && method != "UnsafeDeserialize" && method != "DeserializeMethodResponse" {
+ return
+ }
+
+ // Only flag when the receiver is (or constructs) a known-insecure formatter.
+ recvExpr := fn.ChildByFieldName("expression")
+ if recvExpr == nil {
+ return
+ }
+ insecure := false
+ switch recvExpr.Type() {
+ case "object_creation_expression":
+ switch objectCreationType(recvExpr) {
+ case "BinaryFormatter", "ObjectStateFormatter", "SoapFormatter", "NetDataContractSerializer", "LosFormatter":
+ insecure = true
+ }
+ default:
+ // Receiver text references one of the insecure formatter type names.
+ rt := recvExpr.Text()
+ for _, t := range []string{"BinaryFormatter", "ObjectStateFormatter", "SoapFormatter", "NetDataContractSerializer", "LosFormatter"} {
+ if strings.Contains(rt, t) {
+ insecure = true
+ break
+ }
+ }
+ }
+ if !insecure {
+ return
+ }
+
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-CS-AST-009",
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: "Insecure deserialization via " + method,
+ Description: "Calling " + method + " on an insecure formatter (BinaryFormatter/SoapFormatter/LosFormatter/etc.) deserializes untrusted data and can lead to remote code execution.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Replace the insecure formatter with System.Text.Json or Newtonsoft.Json (TypeNameHandling.None). Never deserialize untrusted input with BinaryFormatter.",
+ CWEID: "CWE-502",
+ OWASPCategory: "A08:2021-Software and Data Integrity Failures",
+ Language: rules.LangCSharp,
+ Confidence: "high",
+ Tags: []string{"deserialization", "rce"},
+ })
+}
+
+// memberReceiverAndName returns the receiver text and the trailing member name
+// of a member_access_expression (e.g. "Response.Write" -> "Response", "Write").
+func memberReceiverAndName(ma *ast.Node) (receiver, name string) {
+ expr := ma.ChildByFieldName("expression")
+ nm := ma.ChildByFieldName("name")
+ if expr != nil {
+ receiver = expr.Text()
+ }
+ if nm != nil {
+ name = nm.Text()
+ }
+ if name == "" {
+ // Fallback: last named child is the member name.
+ kids := ma.NamedChildren()
+ if len(kids) > 0 {
+ name = kids[len(kids)-1].Text()
+ }
+ }
+ return receiver, name
+}
+
+// objectCreationType returns the type name of an object_creation_expression
+// (the node tagged with field "type"), falling back to the first identifier/
+// qualified_name child.
+func objectCreationType(n *ast.Node) string {
+ if t := n.ChildByFieldName("type"); t != nil {
+ return t.Text()
+ }
+ for _, child := range n.NamedChildren() {
+ if child.Type() == "identifier" || child.Type() == "qualified_name" {
+ return child.Text()
+ }
+ }
+ return ""
+}
+
+// argHasNonLiteral reports whether the argument_list contains at least one
+// argument that is not a pure string/numeric/boolean literal — i.e. a variable,
+// member access, invocation, concatenation, or interpolation. Used to suppress
+// the "constant argument" safe case for XSS/redirect sinks.
+func argHasNonLiteral(argList *ast.Node) bool {
+ args := argList.NamedChildren()
+ if len(args) == 0 {
+ return false
+ }
+ for _, arg := range args {
+ // Each "argument" node wraps the actual expression; unwrap it.
+ expr := arg
+ if arg.Type() == "argument" {
+ kids := arg.NamedChildren()
+ if len(kids) > 0 {
+ expr = kids[len(kids)-1]
+ }
+ }
+ switch expr.Type() {
+ case "string_literal", "verbatim_string_literal", "raw_string_literal",
+ "integer_literal", "real_literal", "boolean_literal",
+ "character_literal", "null_literal":
+ // pure literal -> safe, keep checking other args
+ continue
+ case "interpolated_string_expression":
+ // Interpolated string with an interpolation hole is non-constant.
+ for _, ic := range expr.NamedChildren() {
+ if ic.Type() == "interpolation" {
+ return true
+ }
+ }
+ continue
+ default:
+ return true
+ }
+ }
+ return false
+}
+
// containsConcatOrInterpolation checks if a node contains binary_expression with +
// or interpolated_string_expression with interpolation children.
func containsConcatOrInterpolation(n *ast.Node) bool {
diff --git a/batou-core/analyzer/csast/csast_test.go b/batou-core/analyzer/csast/csast_test.go
index 889c905..fb10595 100644
--- a/batou-core/analyzer/csast/csast_test.go
+++ b/batou-core/analyzer/csast/csast_test.go
@@ -1,11 +1,10 @@
package csast
import (
- "strings"
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "strings"
+ "testing"
)
func scanCS(t *testing.T, code string) []rules.Finding {
@@ -223,6 +222,128 @@ class Foo {
}
}
+func TestResponseWriteXSS(t *testing.T) {
+ code := `
+class C {
+ void M() {
+ Response.Write(Request.QueryString["x"]);
+ }
+}
+`
+ findings := scanCS(t, code)
+ if !hasRuleCWE(findings, "BATOU-CS-AST-006", "CWE-79") {
+ t.Error("expected reflected XSS finding for Response.Write")
+ }
+}
+
+func TestHtmlRawXSS(t *testing.T) {
+ code := `
+class C {
+ string M(string userInput) {
+ return Html.Raw(userInput);
+ }
+}
+`
+ findings := scanCS(t, code)
+ if !hasRuleCWE(findings, "BATOU-CS-AST-006", "CWE-79") {
+ t.Error("expected reflected XSS finding for Html.Raw")
+ }
+}
+
+func TestHtmlRawLiteralSafe(t *testing.T) {
+ code := `
+class C {
+ string M() {
+ return Html.Raw("static");
+ }
+}
+`
+ findings := scanCS(t, code)
+ if hasRuleCWE(findings, "BATOU-CS-AST-006", "CWE-79") {
+ t.Error("unexpected XSS finding for Html.Raw with string literal")
+ }
+}
+
+func TestHtmlStringXSS(t *testing.T) {
+ code := `
+class C {
+ object M(string userInput) {
+ return new HtmlString(userInput);
+ }
+}
+`
+ findings := scanCS(t, code)
+ if !hasRuleCWE(findings, "BATOU-CS-AST-007", "CWE-79") {
+ t.Error("expected reflected XSS finding for new HtmlString(var)")
+ }
+}
+
+func TestOpenRedirect(t *testing.T) {
+ code := `
+class C {
+ object M(string url) {
+ return Redirect(url);
+ }
+}
+`
+ findings := scanCS(t, code)
+ if !hasRuleCWE(findings, "BATOU-CS-AST-008", "CWE-601") {
+ t.Error("expected open redirect finding for Redirect(var)")
+ }
+}
+
+func TestRedirectLiteralSafe(t *testing.T) {
+ code := `
+class C {
+ object M() {
+ return Redirect("/home");
+ }
+}
+`
+ findings := scanCS(t, code)
+ if hasRuleCWE(findings, "BATOU-CS-AST-008", "CWE-601") {
+ t.Error("unexpected open redirect finding for Redirect with literal path")
+ }
+}
+
+func TestResponseRedirect(t *testing.T) {
+ code := `
+class C {
+ void M(string url) {
+ Response.Redirect(url);
+ }
+}
+`
+ findings := scanCS(t, code)
+ if !hasRuleCWE(findings, "BATOU-CS-AST-008", "CWE-601") {
+ t.Error("expected open redirect finding for Response.Redirect(var)")
+ }
+}
+
+func TestBinaryFormatterDeserializeInvocation(t *testing.T) {
+ code := `
+class C {
+ object M(System.IO.Stream stream) {
+ return new BinaryFormatter().Deserialize(stream);
+ }
+}
+`
+ findings := scanCS(t, code)
+ // The chained .Deserialize call site is flagged (CWE-502).
+ if !hasRuleCWE(findings, "BATOU-CS-AST-009", "CWE-502") {
+ t.Error("expected insecure deserialization finding for BinaryFormatter().Deserialize")
+ }
+}
+
+func hasRuleCWE(findings []rules.Finding, ruleID, cwe string) bool {
+ for _, f := range findings {
+ if f.RuleID == ruleID && f.CWEID == cwe {
+ return true
+ }
+ }
+ return false
+}
+
func TestNilTree(t *testing.T) {
ctx := &rules.ScanContext{
FilePath: "/app/Handler.cs",
diff --git a/batou-core/analyzer/goast/goast.go b/batou-core/analyzer/goast/goast.go
index 797299d..9fe5558 100644
--- a/batou-core/analyzer/goast/goast.go
+++ b/batou-core/analyzer/goast/goast.go
@@ -90,30 +90,78 @@ func (c *astChecker) collectImports() {
func (c *astChecker) walkAST() {
// Check import-level rules first.
c.checkUnsafeImport()
+ // batou:ignore BATOU-AST-004 -- void method, nothing to check; remove once isSecurityCriticalFunc fuzzy-match tightening ships
c.checkDeprecatedCryptoImports()
+ c.checkDebugAndCGIImports()
+
+ // Walk the full AST for statement/expression-level rules. Track the
+ // enclosing function name so AST-008 can suppress goroutines launched
+ // from lifecycle/shutdown handlers (where one-shot fire-and-forget is
+ // intentional).
+ var enclosingFunc []string // stack of enclosing function names
+ push := func(name string) { enclosingFunc = append(enclosingFunc, name) }
+ pop := func() {
+ if len(enclosingFunc) > 0 {
+ enclosingFunc = enclosingFunc[:len(enclosingFunc)-1]
+ }
+ }
+ current := func() string {
+ if len(enclosingFunc) == 0 {
+ return ""
+ }
+ return enclosingFunc[len(enclosingFunc)-1]
+ }
- // Walk the full AST for statement/expression-level rules.
ast.Inspect(c.file, func(n ast.Node) bool {
if n == nil {
+ // pop on the way back up (Inspect calls f(nil) at the end of a
+ // subtree when the previous call returned true).
+ pop()
return false
}
+ // Push the enclosing-function name for FuncDecl/FuncLit and pop
+ // when we leave the subtree. We use ast.Inspect's nil-callback
+ // convention to detect ascent. To make push/pop balanced we push
+ // for every node and pop on every nil — push a placeholder for
+ // non-func nodes so the stack stays in sync.
+ switch node := n.(type) {
+ case *ast.FuncDecl:
+ if node.Name != nil {
+ push(node.Name.Name)
+ } else {
+ push("")
+ }
+ case *ast.FuncLit:
+ // Function literals inherit the enclosing func name; keep the
+ // top of stack stable but push a duplicate so pop balances.
+ push(current())
+ default:
+ push(current())
+ }
switch node := n.(type) {
case *ast.CallExpr:
c.checkSQLStringConcat(node)
c.checkExecCommandInjection(node)
c.checkHTTPListenAndServe(node)
+ c.checkDecompressionBomb(node)
+ c.checkWeakCryptoAndFileServer(node)
case *ast.AssignStmt:
c.checkUncheckedError(node)
case *ast.ExprStmt:
c.checkDiscardedError(node)
case *ast.CompositeLit:
c.checkHTTPServerMisconfig(node)
+ c.checkTLSConfigMisconfig(node)
+ c.checkSSHInsecureHostKey(node)
+ c.checkInsecureCookie(node)
+ c.checkReverseProxyDirector(node)
+ c.checkServerAddrAllInterfaces(node)
case *ast.ForStmt:
c.checkDeferInLoop(node)
case *ast.RangeStmt:
c.checkDeferInLoop(node)
case *ast.GoStmt:
- c.checkGoroutineLeak(node)
+ c.checkGoroutineLeakIn(node, current())
case *ast.SelectorExpr:
c.checkUnsafePointerUsage(node)
}
@@ -121,6 +169,32 @@ func (c *astChecker) walkAST() {
})
}
+// isLifecycleFuncName returns true when name looks like a lifecycle /
+// shutdown handler. Goroutines launched from these functions are typically
+// one-shot fire-and-forget (run a single shutdown hook, kick a hammer
+// timer, etc.) and don't need a context for cancellation.
+func isLifecycleFuncName(name string) bool {
+ if name == "" {
+ return false
+ }
+ lower := strings.ToLower(name)
+ prefixes := []string{
+ "shutdown", "doshutdown", "onshutdown",
+ "atshutdown", "runatshutdown",
+ "stop", "dostop", "onstop", "cancel", "docancel", "oncancel",
+ "cleanup", "docleanup", "oncleanup",
+ "finalize", "dofinalize",
+ "close", "doclose", "onclose",
+ "terminate", "doterminate",
+ }
+ for _, p := range prefixes {
+ if lower == p || strings.HasPrefix(lower, p) {
+ return true
+ }
+ }
+ return false
+}
+
// --------------------------------------------------------------------
// BATOU-AST-001: UnsafePackageUsage
// --------------------------------------------------------------------
@@ -213,26 +287,199 @@ func (c *astChecker) checkSQLStringConcat(call *ast.CallExpr) {
if c.isStringConcat(queryArg) || c.isFmtSprintf(queryArg) {
pos := c.fset.Position(call.Pos())
matchText := c.nodeSource(call)
+ // DDL with identifier interpolation is the standard Go pattern —
+ // SQL doesn't let you parameterize table/column names, so migration
+ // and admin code uses Sprintf for the identifier and ? for the
+ // values. Without taint, we can't tell whether the interpolated
+ // identifier is user-controlled. Demote to Medium so this stops
+ // dominating Critical findings on schema-management code (gitea
+ // had 60+ Critical hits in models/db/*); a real exploitable case
+ // also surfaces via the taint layer.
+ severity := rules.Critical
+ conf := "high"
+ if isLikelyDDLQuery(c.staticStringPart(queryArg)) {
+ severity = rules.Medium
+ conf = "medium"
+ }
c.findings = append(c.findings, rules.Finding{
RuleID: "BATOU-AST-002",
- Severity: rules.Critical,
- SeverityLabel: rules.Critical.String(),
+ Severity: severity,
+ SeverityLabel: severity.String(),
Title: "SQL query built with string concatenation",
- Description: "Building SQL queries with string concatenation or fmt.Sprintf enables SQL injection attacks.",
+ Description: "Building SQL queries with string concatenation or fmt.Sprintf enables SQL injection attacks. DDL queries (CREATE/ALTER/DROP/MERGE) commonly interpolate identifiers because SQL doesn't allow parameterizing them — confirm the interpolated parts come from internal Go constants, not user input.",
FilePath: c.filePath,
LineNumber: pos.Line,
Column: pos.Column,
MatchedText: matchText,
- Suggestion: "Use parameterized queries with ? or $1 placeholders: db.Query(\"SELECT * FROM users WHERE id = ?\", id)",
+ Suggestion: "Use parameterized queries with ? or $1 placeholders: db.Query(\"SELECT * FROM users WHERE id = ?\", id). For DDL identifier interpolation, ensure the identifier comes from an allowlisted Go constant, not request input.",
CWEID: "CWE-89",
OWASPCategory: "A03:2021-Injection",
Language: rules.LangGo,
- Confidence: "high",
+ Confidence: conf,
Tags: []string{"sql-injection", "injection"},
})
}
}
+// isGoTestOrMigrationPath returns true if the file path is a Go test file
+// or a migration file. Both routinely discard errors on cleanup/setup
+// paths and shouldn't be flagged for unchecked errors the same way
+// production code is.
+func isGoTestOrMigrationPath(path string) bool {
+ low := strings.ToLower(path)
+ return strings.HasSuffix(low, "_test.go") ||
+ strings.Contains(low, "/migrations/") ||
+ strings.Contains(low, "/testutil/") ||
+ strings.Contains(low, "/testdb")
+}
+
+// isNonSecurityCryptoPath returns true if the file path strongly suggests
+// the file uses md5/sha1 for non-security purposes — Git protocol,
+// avatars, haveibeenpwned, package fingerprints, etc.
+func isNonSecurityCryptoPath(path string) bool {
+ low := strings.ToLower(path)
+ for _, marker := range []string{
+ "/git/", "/avatar", "/gravatar", "/etag", "/cache_key",
+ "/fingerprint", "/hibp/", "/pwn/", "/object_format",
+ "haveibeenpwned",
+ } {
+ if strings.Contains(low, marker) {
+ return true
+ }
+ }
+ return false
+}
+
+// isDaemonNamedCall returns true if the called function's name starts with
+// a daemon-style verb. Used to suppress AST-008 (goroutine leak) findings
+// on patterns like `go serveX()`, `go startWorker()`, `go runQueue()` —
+// these are almost always intentional process-lifetime workers.
+func isDaemonNamedCall(fun ast.Expr) bool {
+ var name string
+ switch fn := fun.(type) {
+ case *ast.Ident:
+ name = fn.Name
+ case *ast.SelectorExpr:
+ name = fn.Sel.Name
+ default:
+ return false
+ }
+ lower := strings.ToLower(name)
+ for _, prefix := range []string{"serve", "start", "run", "listen", "monitor", "watch", "process", "handle", "consume", "poll", "loop"} {
+ if strings.HasPrefix(lower, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+// isLikelyDDLQuery returns true if the static string portion of the query
+// contains DDL keywords OR has a format placeholder in an identifier slot
+// (table/column/database name). These are the operations that commonly
+// need identifier interpolation in Go because SQL doesn't let you
+// parameterize identifiers — the value-bearing positions still use ? / $N.
+func isLikelyDDLQuery(s string) bool {
+ if s == "" {
+ return false
+ }
+ upper := strings.ToUpper(s)
+
+ // Pure DDL / admin keywords — identifier interpolation is standard.
+ // SQL DDL forbids parameterising names; database-management code uses
+ // Sprintf for the identifier and ? for the values. The list also covers
+ // engine-specific session/sequence ops (Postgres SETVAL / ALTER SEQUENCE,
+ // MSSQL SET IDENTITY_INSERT) which take an identifier in the same slot
+ // as DDL and are routinely emitted by ORM internals.
+ for _, kw := range []string{
+ "ALTER ", "CREATE ", "DROP ", "RENAME ", "TRUNCATE ",
+ "MERGE INTO", "DELETE FROM ",
+ // Postgres sequence administration.
+ "ALTER SEQUENCE", "SETVAL(", "CURRVAL(", "NEXTVAL(",
+ // MSSQL session-mode identifier-targeted commands.
+ "SET IDENTITY_INSERT", "DBCC ", "EXEC SP_",
+ // Generic admin verbs that always target an identifier.
+ "GRANT ", "REVOKE ", "VACUUM ", "ANALYZE ", "REINDEX ",
+ "COMMENT ON",
+ } {
+ if strings.Contains(upper, kw) {
+ return true
+ }
+ }
+
+ // DML with a placeholder in an identifier slot. xorm/ent-style code
+ // frequently does `UPDATE %s SET col=?`, `INSERT INTO %s VALUES (?,?)`,
+ // or `SELECT * FROM %s WHERE id=?` — the %s is the table name from a
+ // typed Go constant, not user input. Detect " %s" patterns
+ // where the placeholder follows an identifier-bearing keyword and the
+ // statement also has value placeholders (? or $N), which is the
+ // telltale signature that values *are* being parameterized.
+ identKeywords := []string{
+ "INTO %S", "INTO `%S`", "INTO \"%S\"",
+ "FROM %S", "FROM `%S`", "FROM \"%S\"",
+ "UPDATE %S", "UPDATE `%S`", "UPDATE \"%S\"",
+ "JOIN %S",
+ "TABLE %S",
+ }
+ hasIdentPlaceholder := false
+ for _, kw := range identKeywords {
+ if strings.Contains(upper, kw) {
+ hasIdentPlaceholder = true
+ break
+ }
+ }
+ if hasIdentPlaceholder {
+ // Also require value placeholders (? or $N) — that proves the
+ // developer knows about parameterization and is using %s only for
+ // the identifier. If there's no value placeholder, this might be a
+ // fully-interpolated query, which is a real injection sink.
+ if strings.Contains(s, "?") || hasPostgresPlaceholder(s) {
+ return true
+ }
+ }
+ return false
+}
+
+// hasPostgresPlaceholder reports whether the string contains a $N
+// PostgreSQL-style parameter placeholder (e.g. $1, $2).
+func hasPostgresPlaceholder(s string) bool {
+ for i := 0; i < len(s)-1; i++ {
+ if s[i] == '$' && s[i+1] >= '1' && s[i+1] <= '9' {
+ return true
+ }
+ }
+ return false
+}
+
+// staticStringPart returns the literal-string portion of a query argument:
+// for `fmt.Sprintf("...", a, b)` the format string; for `"..." + x` the
+// concatenated literals. Returns "" when none can be extracted.
+//
+// Recursively unwraps the format-arg side so that
+//
+// fmt.Sprintf("INSERT INTO %s " + "VALUES (?,?)", t, ...)
+//
+// yields the joined static string (its identifier-slot heuristic can then
+// see both the keyword and the `?` placeholder).
+func (c *astChecker) staticStringPart(expr ast.Expr) string {
+ switch e := expr.(type) {
+ case *ast.CallExpr:
+ // fmt.Sprintf — first arg is the format string. Recurse into it
+ // so concat/literal/parenthesized forms are all handled.
+ if len(e.Args) > 0 {
+ return c.staticStringPart(e.Args[0])
+ }
+ case *ast.BinaryExpr:
+ if e.Op == token.ADD {
+ return c.staticStringPart(e.X) + c.staticStringPart(e.Y)
+ }
+ case *ast.ParenExpr:
+ return c.staticStringPart(e.X)
+ case *ast.BasicLit:
+ return strings.Trim(e.Value, "`\"")
+ }
+ return ""
+}
+
// isStringConcat returns true if the expression is a binary + involving a non-literal.
func (c *astChecker) isStringConcat(expr ast.Expr) bool {
bin, ok := expr.(*ast.BinaryExpr)
@@ -419,6 +666,13 @@ var securityCriticalFuncs = map[string]bool{
}
func (c *astChecker) checkUncheckedError(assign *ast.AssignStmt) {
+ // Skip test files and migrations — both routinely discard errors on
+ // cleanup/setup paths (test fixtures, schema-version state) which are
+ // not security-critical the same way production code is.
+ if isGoTestOrMigrationPath(c.filePath) {
+ return
+ }
+
// Look for assignments where the error value is discarded with _.
// Pattern: _, _ = someFunc() or result, _ := securityFunc()
if len(assign.Rhs) != 1 {
@@ -526,13 +780,124 @@ func (c *astChecker) isSecurityCriticalFunc(name string) bool {
if securityCriticalFuncs[name] {
return true
}
- lower := strings.ToLower(name)
- // Check for functions with "auth" or "crypt" in the name.
- parts := strings.Split(lower, ".")
- funcPart := parts[len(parts)-1]
+ // Fuzzy match for names containing "auth" or "crypt" is too broad when
+ // applied to arbitrary calls — a helper method like
+ // c.checkDeprecatedCryptoImports() shares the "crypt" substring but is
+ // not security-critical. Restrict fuzzy matching to calls whose qualifier
+ // matches an imported package, so bcrypt.Compare still matches but a
+ // method on a local struct does not.
+ parts := strings.Split(name, ".")
+ if len(parts) != 2 {
+ return false
+ }
+ if !c.isImportedPackage(parts[0]) {
+ return false
+ }
+ funcPart := strings.ToLower(parts[1])
return strings.Contains(funcPart, "auth") || strings.Contains(funcPart, "crypt")
}
+// isImportedPackage returns true if name matches the (possibly aliased) name
+// of a package imported by the file under analysis.
+func (c *astChecker) isImportedPackage(name string) bool {
+ if c.file == nil {
+ return false
+ }
+ for _, imp := range c.file.Imports {
+ if imp.Name != nil {
+ if imp.Name.Name == name {
+ return true
+ }
+ continue
+ }
+ path := strings.Trim(imp.Path.Value, `"`)
+ pkg := path
+ if idx := strings.LastIndex(path, "/"); idx >= 0 {
+ pkg = path[idx+1:]
+ }
+ if pkg == name {
+ return true
+ }
+ }
+ return false
+}
+
+// hasRawHashUse reports whether the file calls the named hash package
+// (e.g. "sha1" or "md5") in a way that produces a raw digest:
+//
+// pkg.Sum(...) // one-shot digest
+// pkg.New().Write(...).Sum(...) // streaming digest
+//
+// It does NOT count usage as the inner constructor of hmac.New
+// (e.g. hmac.New(sha1.New, key)) because RFC 6151 considers HMAC-SHA1
+// and HMAC-MD5 acceptable MAC constructions even when the underlying
+// hash is broken for collision resistance.
+//
+// The walk treats every CallExpr.Fun and any non-call SelectorExpr that
+// references `pkg.New` as a use UNLESS it is the first argument of an
+// hmac.New (or hmac.NewEqual) call. The first arg of hmac.New is the
+// hash constructor (a `func() hash.Hash`).
+func (c *astChecker) hasRawHashUse(pkg string) bool {
+ if c.file == nil {
+ return false
+ }
+
+ // hmacArgs collects the *ast.Expr nodes that appear as the constructor
+ // arg of an hmac.New(...) call so the walker can skip them.
+ hmacArgs := map[ast.Expr]struct{}{}
+ ast.Inspect(c.file, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return true
+ }
+ if ident.Name == "hmac" && (sel.Sel.Name == "New" || sel.Sel.Name == "NewEqual") && len(call.Args) > 0 {
+ hmacArgs[call.Args[0]] = struct{}{}
+ }
+ return true
+ })
+
+ rawUse := false
+ ast.Inspect(c.file, func(n ast.Node) bool {
+ if rawUse {
+ return false
+ }
+ sel, ok := n.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return true
+ }
+ if ident.Name != pkg {
+ return true
+ }
+ // Sum / Sum224 / Sum256 / etc — raw digest.
+ if strings.HasPrefix(sel.Sel.Name, "Sum") {
+ rawUse = true
+ return false
+ }
+ // New / New224 / etc — could be HMAC (skip) or raw (flag).
+ if strings.HasPrefix(sel.Sel.Name, "New") {
+ if _, skip := hmacArgs[ast.Expr(sel)]; skip {
+ return true
+ }
+ rawUse = true
+ return false
+ }
+ return true
+ })
+ return rawUse
+}
+
// --------------------------------------------------------------------
// BATOU-AST-005: DeprecatedCrypto
// --------------------------------------------------------------------
@@ -545,28 +910,53 @@ var weakCryptoPackages = map[string]string{
}
func (c *astChecker) checkDeprecatedCryptoImports() {
+ // Skip when the file path indicates a non-security use of weak hashes:
+ // Git protocol (sha1 for object IDs), avatars (md5 fingerprints),
+ // haveibeenpwned API (sha1 hashprefix). CRY-001 already flags actual
+ // uses with full context — flagging the import here is just noise.
+ if isNonSecurityCryptoPath(c.filePath) {
+ return
+ }
+
for _, imp := range c.file.Imports {
path := strings.Trim(imp.Path.Value, `"`)
- if reason, ok := weakCryptoPackages[path]; ok {
- pos := c.fset.Position(imp.Pos())
- c.findings = append(c.findings, rules.Finding{
- RuleID: "BATOU-AST-005",
- Severity: rules.High,
- SeverityLabel: rules.High.String(),
- Title: "Weak/deprecated cryptographic package imported",
- Description: "Import of " + path + ": " + reason,
- FilePath: c.filePath,
- LineNumber: pos.Line,
- Column: pos.Column,
- MatchedText: imp.Path.Value,
- Suggestion: "Use crypto/aes for encryption, crypto/sha256 or crypto/sha512 for hashing, and golang.org/x/crypto for modern algorithms.",
- CWEID: "CWE-327",
- OWASPCategory: "A02:2021-Cryptographic Failures",
- Language: rules.LangGo,
- Confidence: "high",
- Tags: []string{"crypto", "weak-cipher"},
- })
+ reason, ok := weakCryptoPackages[path]
+ if !ok {
+ continue
+ }
+ // For crypto/sha1 and crypto/md5, distinguish between RAW-hash use
+ // (sha1.Sum, sha1.New().Write().Sum, md5.Sum, md5.New().Write...) —
+ // which is the broken pattern — and HMAC use (hmac.New(sha1.New, ...)
+ // or hmac.New(md5.New, ...)) which is still secure per RFC 6151.
+ // If the file only ever passes sha1.New / md5.New into hmac.New
+ // (never calls .Sum directly), suppress the import-level finding.
+ if path == "crypto/sha1" || path == "crypto/md5" {
+ pkg := "sha1"
+ if path == "crypto/md5" {
+ pkg = "md5"
+ }
+ if !c.hasRawHashUse(pkg) {
+ continue
+ }
}
+ pos := c.fset.Position(imp.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-005",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "Weak/deprecated cryptographic package imported",
+ Description: "Import of " + path + ": " + reason + " (CRY-001 will flag actual security-critical uses; importing alone is informational).",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: imp.Path.Value,
+ Suggestion: "Use crypto/aes for encryption, crypto/sha256 or crypto/sha512 for hashing, and golang.org/x/crypto for modern algorithms.",
+ CWEID: "CWE-327",
+ OWASPCategory: "A02:2021-Cryptographic Failures",
+ Language: rules.LangGo,
+ Confidence: "low",
+ Tags: []string{"crypto", "weak-cipher"},
+ })
}
// Check for math/rand without crypto/rand.
@@ -681,15 +1071,21 @@ func (c *astChecker) checkHTTPServerMisconfig(lit *ast.CompositeLit) {
}
}
+ // The threat this rule guards against (CWE-400 / Slowloris) is mitigated
+ // once ANY request-phase timeout bounds how long a connection can tie up
+ // the server. ReadHeaderTimeout is the specific, Go-documented Slowloris
+ // defense; ReadTimeout subsumes it; WriteTimeout/IdleTimeout cap the other
+ // phases. Real services routinely set just one or two of these on purpose
+ // (e.g. ReadHeaderTimeout against Slowloris while a streaming body handler
+ // deliberately omits WriteTimeout). Demanding all three flooded well-
+ // defended servers with false positives (Grafana sets ReadHeaderTimeout/
+ // ReadTimeout). Only the genuinely unbounded server — no timeout field set
+ // at all — is the real, exploitable misconfiguration worth flagging.
+ hasAnyTimeout := hasReadTimeout || hasReadHeaderTimeout || hasWriteTimeout || hasIdleTimeout
+
var missing []string
- if !hasReadTimeout && !hasReadHeaderTimeout {
- missing = append(missing, "ReadTimeout (or ReadHeaderTimeout)")
- }
- if !hasWriteTimeout {
- missing = append(missing, "WriteTimeout")
- }
- if !hasIdleTimeout {
- missing = append(missing, "IdleTimeout")
+ if !hasAnyTimeout {
+ missing = append(missing, "ReadTimeout (or ReadHeaderTimeout)", "WriteTimeout", "IdleTimeout")
}
if len(missing) > 0 {
@@ -714,6 +1110,417 @@ func (c *astChecker) checkHTTPServerMisconfig(lit *ast.CompositeLit) {
}
}
+// --------------------------------------------------------------------
+// BATOU-AST-009: Insecure TLS configuration (CWE-295 / CWE-327)
+// --------------------------------------------------------------------
+
+// checkTLSConfigMisconfig flags a crypto/tls.Config struct literal that
+// disables certificate verification (InsecureSkipVerify: true) or pins a
+// downgraded minimum protocol version (MinVersion: tls.VersionTLS10/11).
+//
+// This is a constant-misconfiguration with zero source/sink ambiguity, so
+// it is an AST detector (blocks) rather than a taint sink. The regex rule in
+// crypto_ext.go fires the same shapes only as a low-confidence HINT; resolving
+// the literal's static type to crypto/tls.Config here removes the regex FP
+// where `InsecureSkipVerify` appears as a field name on an unrelated config
+// struct, in a comment, or as `: false` (verification ENABLED). It also only
+// fires when the value is the literal `true` ident — a value computed from a
+// variable (e.g. `InsecureSkipVerify: cfg.SkipTLSVerify`) is out of scope for
+// a constant-misconfig rule, so operator-configurable transports do not FP.
+func (c *astChecker) checkTLSConfigMisconfig(lit *ast.CompositeLit) {
+ if !c.litTypeIs(lit, "crypto/tls", "Config") {
+ return
+ }
+
+ for _, elt := range lit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+ key, ok := kv.Key.(*ast.Ident)
+ if !ok {
+ continue
+ }
+ switch key.Name {
+ case "InsecureSkipVerify":
+ // Only `: true` (a literal true ident) is the misconfiguration.
+ // `: false` means verification is ENABLED; a variable means the
+ // value is dynamic and out of scope for a constant-misconfig rule.
+ if id, ok := kv.Value.(*ast.Ident); ok && id.Name == "true" {
+ c.addTLSFinding(kv, "InsecureSkipVerify: true disables TLS certificate verification, allowing man-in-the-middle attacks.",
+ "Remove InsecureSkipVerify (or set it to false). If you must pin a self-signed cert, set RootCAs/VerifyPeerCertificate instead.",
+ "CWE-295")
+ }
+ case "MinVersion":
+ if c.isOldTLSVersion(kv.Value) {
+ c.addTLSFinding(kv, "TLS MinVersion is set to a deprecated protocol (TLS 1.0/1.1) which has known weaknesses (BEAST, POODLE).",
+ "Set MinVersion to tls.VersionTLS12 or tls.VersionTLS13.",
+ "CWE-327")
+ }
+ }
+ }
+}
+
+// isOldTLSVersion returns true when the expression is a TLS 1.0 / 1.1 version
+// constant — either `tls.VersionTLS10`/`tls.VersionTLS11` or the raw wire
+// values 0x0301 (769) / 0x0302 (770).
+func (c *astChecker) isOldTLSVersion(expr ast.Expr) bool {
+ switch v := expr.(type) {
+ case *ast.SelectorExpr:
+ if ident, ok := v.X.(*ast.Ident); ok {
+ tlsName := c.localNameFor("crypto/tls")
+ if ident.Name == tlsName && (v.Sel.Name == "VersionTLS10" || v.Sel.Name == "VersionTLS11") {
+ return true
+ }
+ }
+ case *ast.BasicLit:
+ if v.Kind == token.INT {
+ // TLS 1.0 = 0x0301 (769), TLS 1.1 = 0x0302 (770).
+ return v.Value == "0x0301" || v.Value == "0x0302" || v.Value == "769" || v.Value == "770"
+ }
+ }
+ return false
+}
+
+func (c *astChecker) addTLSFinding(node ast.Node, desc, suggestion, cwe string) {
+ pos := c.fset.Position(node.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-009",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Insecure TLS configuration",
+ Description: desc,
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(node),
+ Suggestion: suggestion,
+ CWEID: cwe,
+ OWASPCategory: "A02:2021-Cryptographic Failures",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"tls", "crypto", "mitm"},
+ })
+}
+
+// --------------------------------------------------------------------
+// BATOU-AST-010: SSH InsecureIgnoreHostKey / missing HostKeyCallback (CWE-322)
+// --------------------------------------------------------------------
+
+// checkSSHInsecureHostKey flags an golang.org/x/crypto/ssh.ClientConfig struct
+// literal whose HostKeyCallback accepts any host key — either by assigning
+// ssh.InsecureIgnoreHostKey() or by omitting the field on an otherwise
+// populated config. Accepting any host key defeats the protection against
+// man-in-the-middle attacks (CWE-322: key exchange without entity
+// authentication).
+//
+// Anchored on the exact package-qualified type ssh.ClientConfig + field
+// HostKeyCallback, so it cannot match an unrelated HostKeyCallback field on a
+// different type.
+func (c *astChecker) checkSSHInsecureHostKey(lit *ast.CompositeLit) {
+ if !c.litTypeIs(lit, "golang.org/x/crypto/ssh", "ClientConfig") {
+ return
+ }
+ sshName := c.localNameFor("golang.org/x/crypto/ssh")
+
+ var hostKeyVal ast.Expr
+ hasHostKeyField := false
+ for _, elt := range lit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+ key, ok := kv.Key.(*ast.Ident)
+ if !ok || key.Name != "HostKeyCallback" {
+ continue
+ }
+ hasHostKeyField = true
+ hostKeyVal = kv.Value
+ }
+
+ // Field present and assigned ssh.InsecureIgnoreHostKey(...) — the
+ // unambiguous accept-any-host-key misconfiguration.
+ if hasHostKeyField {
+ if call, ok := hostKeyVal.(*ast.CallExpr); ok {
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
+ if ident, ok := sel.X.(*ast.Ident); ok &&
+ ident.Name == sshName && sel.Sel.Name == "InsecureIgnoreHostKey" {
+ c.addSSHFinding(call, "ssh.ClientConfig uses InsecureIgnoreHostKey() — the SSH host key is accepted unconditionally, enabling man-in-the-middle attacks.",
+ "Use ssh.FixedHostKey(knownKey) or knownhosts.New(...) as the HostKeyCallback so unexpected host keys are rejected.")
+ return
+ }
+ }
+ }
+ return
+ }
+
+ // Field absent — flag the literal ONLY when it is a real inline config
+ // (at least one keyed field is set, e.g. User/Auth), not a bare
+ // zero-value `ssh.ClientConfig{}` placeholder that is populated later via
+ // field assignment or only used as a type reference. This avoids a false
+ // positive on partial-initialization patterns while still catching the
+ // common `&ssh.ClientConfig{User: ..., Auth: ...}` that simply forgot to
+ // set HostKeyCallback (no host-key verification at all).
+ if !c.litHasKeyedFields(lit) {
+ return
+ }
+ c.addSSHFinding(lit, "ssh.ClientConfig has no HostKeyCallback set — the SSH host key is not verified, enabling man-in-the-middle attacks.",
+ "Set HostKeyCallback to ssh.FixedHostKey(knownKey) or knownhosts.New(...) so unexpected host keys are rejected.")
+}
+
+// litHasKeyedFields reports whether a composite literal has at least one
+// keyed (Field: value) element. A bare `T{}` zero-value literal returns false.
+func (c *astChecker) litHasKeyedFields(lit *ast.CompositeLit) bool {
+ for _, elt := range lit.Elts {
+ if _, ok := elt.(*ast.KeyValueExpr); ok {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *astChecker) addSSHFinding(node ast.Node, desc, suggestion string) {
+ pos := c.fset.Position(node.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-010",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Insecure SSH host key verification",
+ Description: desc,
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(node),
+ Suggestion: suggestion,
+ CWEID: "CWE-322",
+ OWASPCategory: "A07:2021-Identification and Authentication Failures",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"ssh", "mitm", "host-key"},
+ })
+}
+
+// --------------------------------------------------------------------
+// BATOU-AST-011: Decompression bomb — unbounded io.Copy from a
+// decompressing reader (CWE-409)
+// --------------------------------------------------------------------
+
+// checkDecompressionBomb flags `io.Copy(dst, src)` where src is a decompressing
+// reader (gzip.Reader / flate / zlib / bzip2 / a tar.Reader) and the file does
+// NOT bound the copy with io.LimitReader or io.CopyN. A 1 KB malicious archive
+// can decompress to gigabytes, exhausting memory/disk (decompression bomb).
+//
+// Precision: we anchor on io.Copy specifically (package-qualified) AND require
+// the source argument to be a *decompressing reader by constructor/name; a
+// guard (io.LimitReader / io.CopyN anywhere in the same file, or a LimitReader
+// wrapping the source) suppresses the finding — well-written extractors that
+// cap the output never fire.
+func (c *astChecker) checkDecompressionBomb(call *ast.CallExpr) {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "Copy" {
+ return
+ }
+ ioName := c.localNameFor("io")
+ if ioName == "" {
+ return
+ }
+ if ident, ok := sel.X.(*ast.Ident); !ok || ident.Name != ioName {
+ return
+ }
+ if len(call.Args) < 2 {
+ return
+ }
+
+ srcArg := call.Args[1]
+ // If the source is itself wrapped in io.LimitReader, it is bounded.
+ if c.isLimitReaderCall(srcArg) {
+ return
+ }
+ if !c.isDecompressingReader(srcArg) {
+ return
+ }
+
+ // A LimitReader/CopyN guard anywhere in the file disarms the finding (the
+ // extractor caps total bytes). Conservative: favours suppression to avoid
+ // false positives on extractors that do cap output.
+ if c.fileHasCopyBound() {
+ return
+ }
+
+ pos := c.fset.Position(call.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-011",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Unbounded decompression (decompression bomb)",
+ Description: "io.Copy reads from a decompressing reader with no size limit. A small malicious archive can decompress to an enormous size, exhausting memory and disk (decompression bomb, CWE-409).",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(call),
+ Suggestion: "Bound the copy: use io.CopyN(dst, src, maxBytes) or wrap the reader with io.LimitReader(src, maxBytes) before io.Copy.",
+ CWEID: "CWE-409",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"dos", "decompression-bomb", "zip"},
+ })
+}
+
+// isDecompressingReader reports whether expr is (or names) a reader produced by
+// a decompression constructor: gzip.NewReader / flate.NewReader /
+// zlib.NewReader / bzip2.NewReader / tar.NewReader.
+func (c *astChecker) isDecompressingReader(expr ast.Expr) bool {
+ switch v := expr.(type) {
+ case *ast.CallExpr:
+ // Inline-constructed reader: io.Copy(dst, gzip.NewReader(f)) shape.
+ if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
+ if pkg, ok := sel.X.(*ast.Ident); ok {
+ if c.isDecompressPkgReaderCtor(pkg.Name, sel.Sel.Name) {
+ return true
+ }
+ }
+ }
+ case *ast.Ident:
+ // Named local: `gr, _ := gzip.NewReader(f); io.Copy(w, gr)`.
+ return c.identAssignedFromDecompressor(v.Name)
+ }
+ return false
+}
+
+// isDecompressPkgReaderCtor reports whether pkg.method is a decompression
+// reader constructor we recognise. The package alias is compared against the
+// conventional name for each compress/archive package actually imported.
+func (c *astChecker) isDecompressPkgReaderCtor(pkgAlias, method string) bool {
+ type pkgCtor struct {
+ path string
+ ctors []string
+ }
+ candidates := []pkgCtor{
+ {"compress/gzip", []string{"NewReader"}},
+ {"compress/flate", []string{"NewReader"}},
+ {"compress/zlib", []string{"NewReader"}},
+ {"compress/bzip2", []string{"NewReader"}},
+ {"archive/tar", []string{"NewReader"}},
+ }
+ for _, cand := range candidates {
+ if c.localNameFor(cand.path) != pkgAlias {
+ continue
+ }
+ for _, m := range cand.ctors {
+ if m == method {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// identAssignedFromDecompressor scans the file for an assignment
+// `name, ... := pkg.NewReader(...)` where pkg is a decompression package.
+// Conservative: only matches a direct constructor RHS on the same identifier.
+func (c *astChecker) identAssignedFromDecompressor(name string) bool {
+ found := false
+ ast.Inspect(c.file, func(n ast.Node) bool {
+ if found {
+ return false
+ }
+ assign, ok := n.(*ast.AssignStmt)
+ if !ok {
+ return true
+ }
+ // The target ident must be one of the LHS names.
+ targeted := false
+ for _, lhs := range assign.Lhs {
+ if id, ok := lhs.(*ast.Ident); ok && id.Name == name {
+ targeted = true
+ break
+ }
+ }
+ if !targeted {
+ return true
+ }
+ for _, rhs := range assign.Rhs {
+ if call, ok := rhs.(*ast.CallExpr); ok {
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
+ if pkg, ok := sel.X.(*ast.Ident); ok &&
+ c.isDecompressPkgReaderCtor(pkg.Name, sel.Sel.Name) {
+ found = true
+ return false
+ }
+ }
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// isLimitReaderCall reports whether expr is an io.LimitReader(...) call.
+func (c *astChecker) isLimitReaderCall(expr ast.Expr) bool {
+ call, ok := expr.(*ast.CallExpr)
+ if !ok {
+ return false
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "LimitReader" {
+ return false
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ return ok && ident.Name == c.localNameFor("io")
+}
+
+// fileHasCopyBound reports whether the file uses io.LimitReader or io.CopyN
+// anywhere — a heuristic that a bounded-copy guard is present.
+func (c *astChecker) fileHasCopyBound() bool {
+ ioName := c.localNameFor("io")
+ if ioName == "" {
+ return false
+ }
+ found := false
+ ast.Inspect(c.file, func(n ast.Node) bool {
+ if found {
+ return false
+ }
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == ioName {
+ if sel.Sel.Name == "LimitReader" || sel.Sel.Name == "CopyN" {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// litTypeIs reports whether a composite literal's type is the package-qualified
+// type `pkgPath.typeName`. The package alias is resolved through the file's
+// imports so an aliased import (e.g. `import xtls "crypto/tls"`) still matches.
+func (c *astChecker) litTypeIs(lit *ast.CompositeLit, pkgPath, typeName string) bool {
+ sel, ok := lit.Type.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return false
+ }
+ if sel.Sel.Name != typeName {
+ return false
+ }
+ // The import must actually be present and the alias must match.
+ localName := c.localNameFor(pkgPath)
+ return localName != "" && ident.Name == localName
+}
+
// --------------------------------------------------------------------
// BATOU-AST-007: DeferInLoop
// --------------------------------------------------------------------
@@ -740,12 +1547,34 @@ func (c *astChecker) checkDeferInLoop(loopNode ast.Node) {
// findDeferInBlock searches a block for defer statements, not descending
// into function literals (which create their own scope).
+//
+// When the block unconditionally returns at the end (find-and-return
+// pattern: `for ... { if match { open(); defer close(); return ... } }`),
+// any defer inside this block fires AT MOST ONCE per function invocation —
+// the same as if it were outside the loop. Skip the block in that case to
+// avoid the most common AST-007 false positive.
func (c *astChecker) findDeferInBlock(block *ast.BlockStmt) {
+ if blockEndsWithReturn(block) {
+ return
+ }
for _, stmt := range block.List {
c.findDeferInStmt(stmt)
}
}
+// blockEndsWithReturn reports whether the block's final statement is an
+// unconditional return / continue / break that exits the enclosing loop.
+// Conservatively only `return` is treated as exiting; `break`/`continue`
+// still let the loop iterate (deferred resources accumulate).
+func blockEndsWithReturn(block *ast.BlockStmt) bool {
+ if block == nil || len(block.List) == 0 {
+ return false
+ }
+ last := block.List[len(block.List)-1]
+ _, ok := last.(*ast.ReturnStmt)
+ return ok
+}
+
func (c *astChecker) findDeferInStmt(stmt ast.Stmt) {
switch s := stmt.(type) {
case *ast.DeferStmt:
@@ -806,6 +1635,17 @@ func (c *astChecker) findDeferInStmt(stmt ast.Stmt) {
// BATOU-AST-008: GoroutineLeak
// --------------------------------------------------------------------
+// checkGoroutineLeakIn dispatches to checkGoroutineLeak after applying the
+// "lifecycle-handler" suppression: goroutines launched from a function
+// whose name matches a shutdown/cancel/cleanup verb (see isLifecycleFuncName)
+// are intentionally fire-and-forget and don't need a context.
+func (c *astChecker) checkGoroutineLeakIn(goStmt *ast.GoStmt, enclosingFunc string) {
+ if isLifecycleFuncName(enclosingFunc) {
+ return
+ }
+ c.checkGoroutineLeak(goStmt)
+}
+
func (c *astChecker) checkGoroutineLeak(goStmt *ast.GoStmt) {
// Check if the goroutine function accepts context.Context.
funcLit, ok := goStmt.Call.Fun.(*ast.FuncLit)
@@ -845,6 +1685,25 @@ func (c *astChecker) checkGoroutineLeak(goStmt *ast.GoStmt) {
return // Goroutine manages its own context lifecycle.
}
+ // Check if the goroutine is bounded by a sync.WaitGroup. The idiomatic
+ // pattern pairs `defer wg.Done()` in the body with `wg.Wait()` in the
+ // parent — the parent cannot return until the goroutine exits, so it
+ // cannot leak. This matches the scanner's own concurrent rule
+ // execution in scanner.scanCore().
+ if c.isWaitGroupBounded(funcLit.Body) {
+ return
+ }
+
+ // Bounded by an external blocking call. Common idioms:
+ // go func() { err := cmd.Wait(); ... }() — process bound
+ // go func() { _ = w.CloseWithError(...) }() — io closure
+ // go func() { ...; close(done) }() — channel signal
+ // go func() { for range ch { ... } }() — channel-driven
+ // These are bounded by the underlying resource lifecycle.
+ if c.isBoundedByExternal(funcLit.Body) {
+ return
+ }
+
pos := c.fset.Position(goStmt.Pos())
c.findings = append(c.findings, rules.Finding{
RuleID: "BATOU-AST-008",
@@ -878,6 +1737,16 @@ func (c *astChecker) checkGoroutineCallLeak(goStmt *ast.GoStmt) {
}
}
+ // Daemon-naming convention: `go serveX()`, `go startX()`, `go runX()`,
+ // `go listenX()`, `go monitorX()`, `go watchX()`, `go processX()` are
+ // almost always intentional process-lifetime workers, not leak risks.
+ // gitea (and most Go services) launch dozens of these for debug
+ // servers, queue workers, hook handlers, etc. Without taint or human
+ // review we can't be sure, but the prefix is a strong signal.
+ if isDaemonNamedCall(goStmt.Call.Fun) {
+ return
+ }
+
pos := c.fset.Position(goStmt.Pos())
c.findings = append(c.findings, rules.Finding{
RuleID: "BATOU-AST-008",
@@ -911,6 +1780,68 @@ func (c *astChecker) isContextType(expr ast.Expr) bool {
return ident.Name == "context" && sel.Sel.Name == "Context"
}
+// isWaitGroupBounded returns true if a goroutine body contains a deferred
+// Done() call, indicating it is coordinated by a sync.WaitGroup. The common
+// pattern is:
+//
+// wg.Add(1)
+// go func() {
+// defer wg.Done()
+// ...
+// }()
+// wg.Wait()
+//
+// A goroutine that pairs with Wait() in its parent cannot leak — the parent
+// blocks until it exits. We match on `defer .Done()` rather than on
+// type information (which go/ast alone doesn't have), which is narrow enough
+// to avoid confusion with channel receives like <-ctx.Done() (those wouldn't
+// appear as CallExpr under a DeferStmt anyway).
+func (c *astChecker) isWaitGroupBounded(body *ast.BlockStmt) bool {
+ if body == nil {
+ return false
+ }
+ found := false
+ // Helper: matches `.Done()` (with zero args) — the WaitGroup
+ // signature. Doesn't enforce the receiver name to be `wg` since
+ // codebases use various names (g, group, wg, w).
+ matchesDone := func(call *ast.CallExpr) bool {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ if _, ok := sel.X.(*ast.Ident); !ok {
+ return false
+ }
+ return sel.Sel.Name == "Done" && len(call.Args) == 0
+ }
+ ast.Inspect(body, func(n ast.Node) bool {
+ if found {
+ return false
+ }
+ // Idiomatic: `defer wg.Done()`
+ if def, ok := n.(*ast.DeferStmt); ok {
+ if matchesDone(def.Call) {
+ found = true
+ return false
+ }
+ return true
+ }
+ // Tightening 2026-04-26: also accept bare `wg.Done()` as the
+ // last statement / any statement of the goroutine. ocis uses
+ // `go func() { ...; wg.Done() }()` (no defer) which is still
+ // waitgroup-bounded — the parent's wg.Wait() blocks until this
+ // returns, so the goroutine cannot leak.
+ if exprStmt, ok := n.(*ast.ExprStmt); ok {
+ if call, ok := exprStmt.X.(*ast.CallExpr); ok && matchesDone(call) {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
// usesContextInBody checks if a function body references a context variable.
func (c *astChecker) usesContextInBody(body *ast.BlockStmt) bool {
if body == nil {
@@ -938,6 +1869,56 @@ func (c *astChecker) usesContextInBody(body *ast.BlockStmt) bool {
return found
}
+// isBoundedByExternal returns true when the goroutine body's lifetime is
+// implicitly bounded by an external resource: a process Wait/Close call, a
+// channel close, or a `for range chan` consumer. None of these are leak
+// patterns — they exit when the underlying resource closes.
+func (c *astChecker) isBoundedByExternal(body *ast.BlockStmt) bool {
+ if body == nil {
+ return false
+ }
+ bounded := false
+ ast.Inspect(body, func(n ast.Node) bool {
+ if bounded {
+ return false
+ }
+ switch x := n.(type) {
+ case *ast.CallExpr:
+ // Recognize: cmd.Wait(), cmd.WaitWithStderr(), w.Close(),
+ // w.CloseWithError(), close(ch).
+ if id, ok := x.Fun.(*ast.Ident); ok && id.Name == "close" {
+ bounded = true
+ return false
+ }
+ if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
+ name := sel.Sel.Name
+ if name == "Wait" || name == "WaitWithStderr" ||
+ name == "Close" || name == "CloseWithError" ||
+ strings.HasSuffix(name, "Wait") {
+ bounded = true
+ return false
+ }
+ }
+ case *ast.RangeStmt:
+ // for range channel — exits when channel closes.
+ if x.Key == nil && x.Value == nil {
+ if _, ok := x.X.(*ast.UnaryExpr); ok {
+ // for range <-ch (rare) — channel-bound
+ bounded = true
+ }
+ }
+ if x.Tok == token.DEFINE || x.Tok == token.ASSIGN || x.Key == nil {
+ // for x := range ch — if X is a chan it's bound. We can't
+ // fully verify type without type info, but a `for range`
+ // over a channel-typed expression is the common Go idiom.
+ bounded = true
+ }
+ }
+ return true
+ })
+ return bounded
+}
+
// createsOwnContext checks if a function body creates its own context via
// context.WithTimeout, context.WithCancel, or context.WithDeadline called
// with context.Background() or context.TODO(). This is the standard Go
diff --git a/batou-core/analyzer/goast/goast_coverage.go b/batou-core/analyzer/goast/goast_coverage.go
new file mode 100644
index 0000000..2fd11bb
--- /dev/null
+++ b/batou-core/analyzer/goast/goast_coverage.go
@@ -0,0 +1,647 @@
+package goast
+
+// Coverage-expansion AST detectors (BATOU-AST-012 .. BATOU-AST-018).
+//
+// Every detector here is a CONSTANT-MISCONFIGURATION resolved against the
+// static type or import path of a real stdlib / well-known framework symbol.
+// None match on a bare name: each is anchored on a package-qualified type
+// (net/http.Cookie, crypto/rsa.GenerateKey, net/http/httputil.ReverseProxy,
+// ...) so they cannot collide with same-named fields/methods on unrelated
+// types. They block (high confidence) because there is no source/sink
+// ambiguity — the insecure value is a literal in the program text.
+
+import (
+ "go/ast"
+ "go/token"
+ "strconv"
+ "strings"
+
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// --------------------------------------------------------------------
+// BATOU-AST-012: Insecure cookie / session flags
+// CWE-1004 (HttpOnly omitted/false) · CWE-614 (Secure omitted/false)
+// CWE-1275 (SameSite=None without Secure)
+// --------------------------------------------------------------------
+
+// checkInsecureCookie flags a net/http.Cookie struct literal (or a
+// gorilla/sessions.Options struct literal) that:
+// - omits or sets HttpOnly:false on an otherwise-populated session cookie,
+// - omits or sets Secure:false, or
+// - sets SameSite: http.SameSiteNoneMode without Secure:true.
+//
+// Anchored on the exact type net/http.Cookie / gorilla/sessions.Options, so
+// it never matches a HttpOnly/Secure field on an unrelated config struct.
+//
+// FP guards (deliberately conservative — a HELD FP beats a noisy one):
+// - Only fires on a literal that already sets a value-bearing cookie
+// (Name + Value, or a sessions.Options that sets MaxAge/Path). A bare
+// placeholder literal is skipped.
+// - Cookie-deletion literals (MaxAge < 0) are skipped — clearing a cookie
+// does not need Secure/HttpOnly.
+func (c *astChecker) checkInsecureCookie(lit *ast.CompositeLit) {
+ isHTTPCookie := c.litTypeIs(lit, "net/http", "Cookie")
+ isSessionOpts := c.litTypeIs(lit, "github.com/gorilla/sessions", "Options")
+ if !isHTTPCookie && !isSessionOpts {
+ return
+ }
+
+ var (
+ hasName, hasValue bool
+ hasMaxAgeNeg bool
+ hasPathOrMaxAge bool
+ httpOnlyField, secureField ast.Expr
+ sawHTTPOnly, sawSecure bool
+ sameSiteVal ast.Expr
+ )
+
+ for _, elt := range lit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+ key, ok := kv.Key.(*ast.Ident)
+ if !ok {
+ continue
+ }
+ switch key.Name {
+ case "Name":
+ hasName = true
+ case "Value":
+ hasValue = true
+ case "Path":
+ hasPathOrMaxAge = true
+ case "MaxAge":
+ hasPathOrMaxAge = true
+ if isNegativeIntLit(kv.Value) {
+ hasMaxAgeNeg = true
+ }
+ case "HttpOnly", "HTTPOnly":
+ sawHTTPOnly = true
+ httpOnlyField = kv.Value
+ case "Secure":
+ sawSecure = true
+ secureField = kv.Value
+ case "SameSite":
+ sameSiteVal = kv.Value
+ }
+ }
+
+ // Cookie-deletion literal — clearing a cookie does not require flags.
+ if hasMaxAgeNeg {
+ return
+ }
+
+ // Only flag a literal that is actually establishing a cookie/session.
+ // http.Cookie needs Name+Value; sessions.Options needs Path/MaxAge.
+ populated := (isHTTPCookie && hasName && hasValue) || (isSessionOpts && hasPathOrMaxAge)
+ if !populated {
+ return
+ }
+
+ secureTrue := sawSecure && isTrueIdent(secureField)
+
+ // CWE-1275: SameSite=None requires Secure. Highest-signal — report first.
+ if c.isSameSiteNone(sameSiteVal) && !secureTrue {
+ c.addCookieFinding(lit, "CWE-1275",
+ "Cookie sets SameSite=None without Secure. Browsers reject SameSite=None cookies that are not Secure, and a None cookie sent over plaintext is exposed to network attackers and cross-site requests.",
+ "Set Secure: true whenever SameSite is http.SameSiteNoneMode, or use http.SameSiteLaxMode / http.SameSiteStrictMode.")
+ return
+ }
+
+ // CWE-614: Secure omitted or explicitly false.
+ if !sawSecure || isFalseIdent(secureField) {
+ c.addCookieFinding(lit, "CWE-614",
+ "Cookie is missing the Secure flag, so it is transmitted over plaintext HTTP and can be intercepted by a network attacker.",
+ "Set Secure: true so the cookie is only sent over HTTPS.")
+ return
+ }
+
+ // CWE-1004: HttpOnly omitted or explicitly false (session-bearing cookie).
+ if !sawHTTPOnly || isFalseIdent(httpOnlyField) {
+ c.addCookieFinding(lit, "CWE-1004",
+ "Cookie is missing the HttpOnly flag, so client-side JavaScript can read it. A session or auth cookie without HttpOnly is exposed to XSS-based theft.",
+ "Set HttpOnly: true so the cookie is inaccessible to JavaScript.")
+ return
+ }
+}
+
+// isSameSiteNone reports whether expr is http.SameSiteNoneMode (the only
+// SameSite mode that weakens cross-site protection).
+func (c *astChecker) isSameSiteNone(expr ast.Expr) bool {
+ if expr == nil {
+ return false
+ }
+ sel, ok := expr.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return false
+ }
+ httpName := c.localNameFor("net/http")
+ return httpName != "" && ident.Name == httpName && sel.Sel.Name == "SameSiteNoneMode"
+}
+
+func (c *astChecker) addCookieFinding(node ast.Node, cwe, desc, suggestion string) {
+ pos := c.fset.Position(node.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-012",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "Insecure cookie configuration",
+ Description: desc,
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(node),
+ Suggestion: suggestion,
+ CWEID: cwe,
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"http", "cookie", "session", "misconfig"},
+ })
+}
+
+// --------------------------------------------------------------------
+// BATOU-AST-013: net/http/pprof exposed on the default mux (CWE-489)
+// BATOU-AST-014: net/http/cgi imported — httpoxy (CWE-665)
+// --------------------------------------------------------------------
+
+// checkDebugAndCGIImports flags two import-level misconfigurations:
+// - net/http/pprof (blank or named import): registers /debug/pprof/* on
+// http.DefaultServeMux at import time. If that mux is ever served, the
+// heap/goroutine/profile endpoints leak internals and enable DoS.
+// - net/http/cgi: vulnerable to httpoxy (CVE-2016-5386) — a request's
+// Proxy header maps to the HTTP_PROXY env var inside the CGI process.
+func (c *astChecker) checkDebugAndCGIImports() {
+ for _, imp := range c.file.Imports {
+ path := strings.Trim(imp.Path.Value, `"`)
+ pos := c.fset.Position(imp.Pos())
+ switch path {
+ case "net/http/pprof":
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-013",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Debug pprof endpoints exposed",
+ Description: "Importing net/http/pprof registers /debug/pprof/* handlers on http.DefaultServeMux. If the default mux is served, anyone can pull heap/goroutine/CPU profiles (information disclosure) and trigger expensive profiling (DoS).",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: `import _ "net/http/pprof"`,
+ Suggestion: "Do not import net/http/pprof in production binaries. If you need profiling, register the pprof handlers on a separate, access-controlled mux bound to localhost.",
+ CWEID: "CWE-489",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"http", "pprof", "debug", "info-disclosure"},
+ })
+ case "net/http/cgi":
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-014",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "net/http/cgi vulnerable to httpoxy",
+ Description: "net/http/cgi copies request headers into the CGI environment, so an attacker-supplied Proxy request header becomes the HTTP_PROXY environment variable inside the process (httpoxy, CVE-2016-5386). Outbound requests can then be redirected through an attacker-controlled proxy.",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: `import "net/http/cgi"`,
+ Suggestion: "Avoid net/http/cgi for new code. If it must be used, strip the Proxy header before invoking the CGI handler and pin HTTP_PROXY explicitly.",
+ CWEID: "CWE-665",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGo,
+ Confidence: "medium",
+ Tags: []string{"http", "cgi", "httpoxy"},
+ })
+ }
+ }
+}
+
+// --------------------------------------------------------------------
+// BATOU-AST-015: rsa.GenerateKey with bits < 2048 (CWE-326)
+// BATOU-AST-016: http.FileServer(http.Dir(...)) directory listing (CWE-548)
+// BATOU-AST-017: SHA-224 used as a digest (CWE-328)
+// --------------------------------------------------------------------
+
+// checkWeakCryptoAndFileServer dispatches the CallExpr-level coverage checks.
+func (c *astChecker) checkWeakCryptoAndFileServer(call *ast.CallExpr) {
+ c.checkWeakRSAKeySize(call)
+ c.checkFileServerListing(call)
+ c.checkSHA224Digest(call)
+ c.checkListenAllInterfaces(call)
+}
+
+// --------------------------------------------------------------------
+// BATOU-AST-019: net.Listen bound to 0.0.0.0 / all interfaces (CWE-200)
+// --------------------------------------------------------------------
+
+// checkListenAllInterfaces flags net.Listen("tcp", "0.0.0.0:...") where the
+// address is an EXPLICIT 0.0.0.0 literal. Binding a listener to all interfaces
+// can expose a service intended for localhost to the whole network.
+//
+// Deliberately narrow to avoid FPs: only the explicit "0.0.0.0:" literal is
+// flagged. The idiomatic ":port" form (host omitted) is the common, often
+// intentional default and is NOT flagged. A variable/config-supplied address
+// is out of scope. Anchored on the net package alias.
+func (c *astChecker) checkListenAllInterfaces(call *ast.CallExpr) {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "Listen" {
+ return
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return
+ }
+ netName := c.localNameFor("net")
+ if netName == "" || ident.Name != netName {
+ return
+ }
+ // net.Listen(network, address) — address is arg 1.
+ if len(call.Args) < 2 {
+ return
+ }
+ addr := c.stringLitValue(call.Args[1])
+ if !strings.HasPrefix(addr, "0.0.0.0:") {
+ return
+ }
+ c.addAllInterfacesFinding(call, "net.Listen binds to 0.0.0.0 (all network interfaces), exposing the listener beyond localhost.")
+}
+
+// checkServerAddrAllInterfaces flags an http.Server struct literal whose Addr
+// field is an explicit "0.0.0.0:..." literal. Same FP posture as the
+// net.Listen check — only the literal 0.0.0.0 host is flagged.
+func (c *astChecker) checkServerAddrAllInterfaces(lit *ast.CompositeLit) {
+ if !c.litTypeIs(lit, "net/http", "Server") {
+ return
+ }
+ for _, elt := range lit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+ key, ok := kv.Key.(*ast.Ident)
+ if !ok || key.Name != "Addr" {
+ continue
+ }
+ if strings.HasPrefix(c.stringLitValue(kv.Value), "0.0.0.0:") {
+ c.addAllInterfacesFinding(kv, "http.Server.Addr binds to 0.0.0.0 (all network interfaces), exposing the server beyond localhost.")
+ }
+ return
+ }
+}
+
+func (c *astChecker) addAllInterfacesFinding(node ast.Node, desc string) {
+ pos := c.fset.Position(node.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-019",
+ Severity: rules.Low,
+ SeverityLabel: rules.Low.String(),
+ Title: "Service bound to all network interfaces",
+ Description: desc + " If the service is meant for local or internal use only, it is reachable by any host that can route to the machine.",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(node),
+ Suggestion: "Bind to a specific interface (e.g. 127.0.0.1:PORT for local-only) unless the service genuinely must be reachable from any interface.",
+ CWEID: "CWE-200",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGo,
+ Confidence: "medium",
+ Tags: []string{"net", "bind", "all-interfaces", "exposure"},
+ })
+}
+
+// checkWeakRSAKeySize flags crypto/rsa.GenerateKey(rand, bits) where bits is a
+// constant < 2048. Anchored on the rsa package alias + method name, with the
+// second argument resolved to an integer literal — a variable bit count is
+// out of scope for a constant rule.
+func (c *astChecker) checkWeakRSAKeySize(call *ast.CallExpr) {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "GenerateKey" {
+ return
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return
+ }
+ rsaName := c.localNameFor("crypto/rsa")
+ if rsaName == "" || ident.Name != rsaName {
+ return
+ }
+ if len(call.Args) < 2 {
+ return
+ }
+ bits, ok := intLitValue(call.Args[1])
+ if !ok || bits >= 2048 {
+ return
+ }
+ pos := c.fset.Position(call.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-015",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Weak RSA key size",
+ Description: "rsa.GenerateKey is called with " + strconv.Itoa(bits) + " bits. RSA keys shorter than 2048 bits are factorable with modern resources and are rejected by current standards (NIST SP 800-57).",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(call),
+ Suggestion: "Generate at least 2048-bit RSA keys (rsa.GenerateKey(rand.Reader, 2048)); prefer 3072+ or switch to an EdDSA/ECDSA key.",
+ CWEID: "CWE-326",
+ OWASPCategory: "A02:2021-Cryptographic Failures",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"crypto", "rsa", "weak-key"},
+ })
+}
+
+// checkFileServerListing flags http.FileServer(http.Dir(...)). The default
+// http.FileServer handler renders an autoindex (directory listing) for any
+// directory lacking an index.html, disclosing the full file tree. Anchored on
+// the net/http package alias for both FileServer and the inner Dir conversion.
+func (c *astChecker) checkFileServerListing(call *ast.CallExpr) {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "FileServer" {
+ return
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return
+ }
+ httpName := c.localNameFor("net/http")
+ if httpName == "" || ident.Name != httpName {
+ return
+ }
+ if len(call.Args) != 1 {
+ return
+ }
+ // Inner arg must be http.Dir(...) — http.FS(embed) and custom FileSystems
+ // that disable listing are out of scope.
+ inner, ok := call.Args[0].(*ast.CallExpr)
+ if !ok {
+ return
+ }
+ innerSel, ok := inner.Fun.(*ast.SelectorExpr)
+ if !ok || innerSel.Sel.Name != "Dir" {
+ return
+ }
+ innerIdent, ok := innerSel.X.(*ast.Ident)
+ if !ok || innerIdent.Name != httpName {
+ return
+ }
+ pos := c.fset.Position(call.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-016",
+ Severity: rules.Medium,
+ SeverityLabel: rules.Medium.String(),
+ Title: "Directory listing via http.FileServer",
+ Description: "http.FileServer(http.Dir(...)) serves a directory and renders an automatic directory listing for any path without an index.html, disclosing the entire file tree to clients.",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(call),
+ Suggestion: "Wrap the FileSystem to suppress listings (return os.ErrNotExist for directories), serve a specific file with http.ServeFile, or embed assets with http.FS so no directory index is produced.",
+ CWEID: "CWE-548",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGo,
+ Confidence: "medium",
+ Tags: []string{"http", "fileserver", "directory-listing", "info-disclosure"},
+ })
+}
+
+// checkSHA224Digest flags crypto/sha256.Sum224 and crypto/sha256.New224.
+// SHA-224 is a truncated SHA-256 with a 224-bit output; gosec (G407-class)
+// and NIST guidance flag its use for new signatures/digests. Anchored on the
+// sha256 package alias.
+func (c *astChecker) checkSHA224Digest(call *ast.CallExpr) {
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return
+ }
+ if sel.Sel.Name != "Sum224" && sel.Sel.Name != "New224" {
+ return
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return
+ }
+ sha256Name := c.localNameFor("crypto/sha256")
+ if sha256Name == "" || ident.Name != sha256Name {
+ return
+ }
+ pos := c.fset.Position(call.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-017",
+ Severity: rules.Low,
+ SeverityLabel: rules.Low.String(),
+ Title: "Weak SHA-224 digest",
+ Description: "sha256." + sel.Sel.Name + " produces a 224-bit truncated SHA-256 digest. SHA-224 offers reduced collision resistance and is flagged for cryptographic digests/signatures.",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(call),
+ Suggestion: "Use sha256.Sum256 / sha256.New (or SHA-384/512) for cryptographic digests.",
+ CWEID: "CWE-328",
+ OWASPCategory: "A02:2021-Cryptographic Failures",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"crypto", "hash", "sha224", "weak-hash"},
+ })
+}
+
+// --------------------------------------------------------------------
+// BATOU-AST-018: ReverseProxy Director copies inbound host/URL (CWE-918)
+// --------------------------------------------------------------------
+
+// checkReverseProxyDirector flags a httputil.ReverseProxy struct literal whose
+// Director function copies the inbound request's Host or URL.Host into the
+// outbound target. A Director that does `req.URL.Host = req.Host` (or copies
+// the client-supplied Host) lets a client steer the proxy to an arbitrary
+// upstream — proxy SSRF / host smuggling.
+//
+// Anchored on net/http/httputil.ReverseProxy + the Director field; the body
+// scan requires BOTH an assignment INTO req.URL.Host/req.URL.Scheme AND a
+// read of the inbound req.Host on the RHS, so a normal fixed-upstream Director
+// (`req.URL.Host = "backend:8080"`) does not match.
+func (c *astChecker) checkReverseProxyDirector(lit *ast.CompositeLit) {
+ if !c.litTypeIs(lit, "net/http/httputil", "ReverseProxy") {
+ return
+ }
+ for _, elt := range lit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+ key, ok := kv.Key.(*ast.Ident)
+ if !ok || key.Name != "Director" {
+ continue
+ }
+ fn, ok := kv.Value.(*ast.FuncLit)
+ if !ok || fn.Body == nil {
+ continue
+ }
+ if reqParam := firstRequestParamName(fn); reqParam != "" {
+ if directorCopiesInboundHost(fn.Body, reqParam) {
+ pos := c.fset.Position(lit.Pos())
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-AST-018",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Reverse proxy forwards client-controlled host",
+ Description: "The ReverseProxy Director copies the inbound request's Host into the outbound URL, so a client can set the upstream the proxy connects to (Host header / URL smuggling). This is server-side request forgery against internal services.",
+ FilePath: c.filePath,
+ LineNumber: pos.Line,
+ Column: pos.Column,
+ MatchedText: c.nodeSource(lit),
+ Suggestion: "Set req.URL.Host/Scheme to a fixed, validated upstream (or an allowlist lookup) inside the Director — never to the inbound req.Host.",
+ CWEID: "CWE-918",
+ OWASPCategory: "A10:2021-Server-Side Request Forgery",
+ Language: rules.LangGo,
+ Confidence: "high",
+ Tags: []string{"http", "reverse-proxy", "ssrf", "host-smuggling"},
+ })
+ }
+ }
+ return
+ }
+}
+
+// firstRequestParamName returns the name of the *http.Request parameter of a
+// Director func literal (Director is func(*http.Request)), or "".
+func firstRequestParamName(fn *ast.FuncLit) string {
+ if fn.Type == nil || fn.Type.Params == nil {
+ return ""
+ }
+ for _, field := range fn.Type.Params.List {
+ star, ok := field.Type.(*ast.StarExpr)
+ if !ok {
+ continue
+ }
+ sel, ok := star.X.(*ast.SelectorExpr)
+ if !ok || sel.Sel.Name != "Request" {
+ continue
+ }
+ if len(field.Names) > 0 {
+ return field.Names[0].Name
+ }
+ }
+ return ""
+}
+
+// directorCopiesInboundHost reports whether the Director body BOTH assigns
+// into req.URL.Host/Scheme AND reads the inbound req.Host on the RHS of an
+// assignment to the URL. This two-sided requirement keeps fixed-upstream
+// Directors clean.
+func directorCopiesInboundHost(body *ast.BlockStmt, reqName string) bool {
+ found := false
+ ast.Inspect(body, func(n ast.Node) bool {
+ assign, ok := n.(*ast.AssignStmt)
+ if !ok {
+ return true
+ }
+ // LHS must target req.URL.Host / req.URL.Scheme.
+ lhsIsURLHost := false
+ for _, lhs := range assign.Lhs {
+ if isReqURLHostSelector(lhs, reqName) {
+ lhsIsURLHost = true
+ }
+ }
+ if !lhsIsURLHost {
+ return true
+ }
+ // RHS must read the inbound req.Host (the client-controlled value).
+ for _, rhs := range assign.Rhs {
+ if exprReadsReqHost(rhs, reqName) {
+ found = true
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// isReqURLHostSelector matches req.URL.Host or req.URL.Scheme (req == reqName).
+func isReqURLHostSelector(expr ast.Expr, reqName string) bool {
+ sel, ok := expr.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ if sel.Sel.Name != "Host" && sel.Sel.Name != "Scheme" {
+ return false
+ }
+ // sel.X must be req.URL
+ inner, ok := sel.X.(*ast.SelectorExpr)
+ if !ok || inner.Sel.Name != "URL" {
+ return false
+ }
+ base, ok := inner.X.(*ast.Ident)
+ return ok && base.Name == reqName
+}
+
+// exprReadsReqHost reports whether expr reads req.Host (the inbound Host) for
+// req == reqName, anywhere in the subtree. req.URL.Host (the assignment target)
+// is excluded because its receiver is req.URL, not req.
+func exprReadsReqHost(expr ast.Expr, reqName string) bool {
+ found := false
+ ast.Inspect(expr, func(n ast.Node) bool {
+ sel, ok := n.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ if sel.Sel.Name != "Host" {
+ return true
+ }
+ if base, ok := sel.X.(*ast.Ident); ok && base.Name == reqName {
+ found = true
+ }
+ return true
+ })
+ return found
+}
+
+// --------------------------------------------------------------------
+// shared literal helpers
+// --------------------------------------------------------------------
+
+func isTrueIdent(expr ast.Expr) bool {
+ id, ok := expr.(*ast.Ident)
+ return ok && id.Name == "true"
+}
+
+func isFalseIdent(expr ast.Expr) bool {
+ id, ok := expr.(*ast.Ident)
+ return ok && id.Name == "false"
+}
+
+// intLitValue resolves an integer literal (incl. a leading unary minus) to its
+// int value. Returns (0,false) for non-literal expressions.
+func intLitValue(expr ast.Expr) (int, bool) {
+ switch v := expr.(type) {
+ case *ast.BasicLit:
+ if v.Kind != token.INT {
+ return 0, false
+ }
+ n, err := strconv.ParseInt(v.Value, 0, 64)
+ if err != nil {
+ return 0, false
+ }
+ return int(n), true
+ case *ast.UnaryExpr:
+ if v.Op == token.SUB {
+ if n, ok := intLitValue(v.X); ok {
+ return -n, true
+ }
+ }
+ }
+ return 0, false
+}
+
+func isNegativeIntLit(expr ast.Expr) bool {
+ n, ok := intLitValue(expr)
+ return ok && n < 0
+}
diff --git a/batou-core/analyzer/goast/goast_coverage_test.go b/batou-core/analyzer/goast/goast_coverage_test.go
new file mode 100644
index 0000000..155a644
--- /dev/null
+++ b/batou-core/analyzer/goast/goast_coverage_test.go
@@ -0,0 +1,555 @@
+package goast
+
+import (
+ "strings"
+ "testing"
+)
+
+func hasFindingCWE(t *testing.T, code, ruleID, cwe string) bool {
+ t.Helper()
+ for _, f := range scanGo(code) {
+ if f.RuleID == ruleID && f.CWEID == cwe {
+ return true
+ }
+ }
+ return false
+}
+
+func hasRule(code, ruleID string) bool {
+ for _, f := range scanGo(code) {
+ if f.RuleID == ruleID {
+ return true
+ }
+ }
+ return false
+}
+
+// =========================================================================
+// BATOU-AST-012: Insecure cookie / session flags
+// =========================================================================
+
+func TestAST012_CookieMissingHttpOnly(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func login(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "session", Value: "abc123", Secure: true}
+ http.SetCookie(w, c)
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-012", "CWE-1004") {
+ t.Error("expected AST-012 CWE-1004 for http.Cookie missing HttpOnly")
+ }
+}
+
+func TestAST012_CookieMissingSecure(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func login(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "session", Value: "abc123", HttpOnly: true}
+ http.SetCookie(w, c)
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-012", "CWE-614") {
+ t.Error("expected AST-012 CWE-614 for http.Cookie missing Secure")
+ }
+}
+
+func TestAST012_CookieSecureFalse(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func login(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "session", Value: "abc123", HttpOnly: true, Secure: false}
+ http.SetCookie(w, c)
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-012", "CWE-614") {
+ t.Error("expected AST-012 CWE-614 for http.Cookie Secure:false")
+ }
+}
+
+func TestAST012_CookieSameSiteNoneWithoutSecure(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func login(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "session", Value: "abc123", HttpOnly: true, SameSite: http.SameSiteNoneMode}
+ http.SetCookie(w, c)
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-012", "CWE-1275") {
+ t.Error("expected AST-012 CWE-1275 for SameSite=None without Secure")
+ }
+}
+
+func TestAST012_SecureHttpOnlyCookie_Safe(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func login(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "session", Value: "abc123", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode}
+ http.SetCookie(w, c)
+}
+`
+ if hasRule(code, "BATOU-AST-012") {
+ t.Error("did not expect AST-012 for a fully secure cookie")
+ }
+}
+
+func TestAST012_SameSiteNoneWithSecure_Safe(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func login(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "csrf", Value: "abc123", HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode}
+ http.SetCookie(w, c)
+}
+`
+ if hasRule(code, "BATOU-AST-012") {
+ t.Error("did not expect AST-012 for SameSite=None WITH Secure:true")
+ }
+}
+
+func TestAST012_CookieDeletion_Safe(t *testing.T) {
+ // Clearing a cookie (MaxAge < 0) does not require Secure/HttpOnly.
+ code := `package main
+
+import "net/http"
+
+func logout(w http.ResponseWriter) {
+ c := &http.Cookie{Name: "session", Value: "", MaxAge: -1}
+ http.SetCookie(w, c)
+}
+`
+ if hasRule(code, "BATOU-AST-012") {
+ t.Error("did not expect AST-012 for a cookie-deletion literal (MaxAge:-1)")
+ }
+}
+
+func TestAST012_BarePlaceholder_Safe(t *testing.T) {
+ // A bare/partial literal that does not set Name+Value is not a cookie
+ // being established here — skip to avoid FPs on field-assignment patterns.
+ code := `package main
+
+import "net/http"
+
+func f() {
+ c := &http.Cookie{Name: "session"}
+ _ = c
+}
+`
+ if hasRule(code, "BATOU-AST-012") {
+ t.Error("did not expect AST-012 for a bare placeholder cookie literal")
+ }
+}
+
+func TestAST012_UnrelatedStructWithSecureField_Safe(t *testing.T) {
+ // A non-http.Cookie struct that happens to have Secure/HttpOnly fields
+ // must NOT match — ObjectType anchoring.
+ code := `package main
+
+type Config struct {
+ Name string
+ Value string
+ HttpOnly bool
+ Secure bool
+}
+
+func f() {
+ c := Config{Name: "x", Value: "y", HttpOnly: false, Secure: false}
+ _ = c
+}
+`
+ if hasRule(code, "BATOU-AST-012") {
+ t.Error("did not expect AST-012 on an unrelated struct with HttpOnly/Secure fields")
+ }
+}
+
+func TestAST012_GorillaSessionsOptions(t *testing.T) {
+ code := `package main
+
+import "github.com/gorilla/sessions"
+
+func opts() *sessions.Options {
+ return &sessions.Options{Path: "/", MaxAge: 3600, HttpOnly: true}
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-012", "CWE-614") {
+ t.Error("expected AST-012 CWE-614 for gorilla sessions.Options missing Secure")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-013: net/http/pprof exposed
+// =========================================================================
+
+func TestAST013_PprofImport(t *testing.T) {
+ code := `package main
+
+import (
+ "net/http"
+ _ "net/http/pprof"
+)
+
+func main() {
+ http.ListenAndServe(":6060", nil)
+}
+`
+ if !hasRule(code, "BATOU-AST-013") {
+ t.Error("expected AST-013 for blank import of net/http/pprof")
+ }
+}
+
+func TestAST013_NoPprof_Safe(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func main() {
+ http.ListenAndServe(":8080", nil)
+}
+`
+ if hasRule(code, "BATOU-AST-013") {
+ t.Error("did not expect AST-013 without net/http/pprof import")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-014: net/http/cgi (httpoxy)
+// =========================================================================
+
+func TestAST014_CGIImport(t *testing.T) {
+ code := `package main
+
+import "net/http/cgi"
+
+func run(h *cgi.Handler) {
+ _ = h
+}
+`
+ if !hasRule(code, "BATOU-AST-014") {
+ t.Error("expected AST-014 for net/http/cgi import")
+ }
+}
+
+func TestAST014_RuntimePprofNotCGI_Safe(t *testing.T) {
+ code := `package main
+
+import "runtime/pprof"
+
+func f() {
+ _ = pprof.Lookup("heap")
+}
+`
+ if hasRule(code, "BATOU-AST-014") || hasRule(code, "BATOU-AST-013") {
+ t.Error("did not expect AST-013/014 for runtime/pprof (not net/http/pprof or cgi)")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-015: weak RSA key size
+// =========================================================================
+
+func TestAST015_RSA1024(t *testing.T) {
+ code := `package main
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+)
+
+func gen() {
+ _, _ = rsa.GenerateKey(rand.Reader, 1024)
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-015", "CWE-326") {
+ t.Error("expected AST-015 for rsa.GenerateKey with 1024 bits")
+ }
+}
+
+func TestAST015_RSA2048_Safe(t *testing.T) {
+ code := `package main
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+)
+
+func gen() {
+ _, _ = rsa.GenerateKey(rand.Reader, 2048)
+}
+`
+ if hasRule(code, "BATOU-AST-015") {
+ t.Error("did not expect AST-015 for rsa.GenerateKey with 2048 bits")
+ }
+}
+
+func TestAST015_RSAVariableBits_Safe(t *testing.T) {
+ // Non-literal bit count is out of scope for a constant-misconfig rule.
+ code := `package main
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+)
+
+func gen(bits int) {
+ _, _ = rsa.GenerateKey(rand.Reader, bits)
+}
+`
+ if hasRule(code, "BATOU-AST-015") {
+ t.Error("did not expect AST-015 for variable bit count")
+ }
+}
+
+func TestAST015_UnrelatedGenerateKey_Safe(t *testing.T) {
+ // ecdsa.GenerateKey / a custom GenerateKey are not crypto/rsa.
+ code := `package main
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+)
+
+func gen() {
+ _, _ = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+}
+`
+ if hasRule(code, "BATOU-AST-015") {
+ t.Error("did not expect AST-015 for ecdsa.GenerateKey")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-016: http.FileServer directory listing
+// =========================================================================
+
+func TestAST016_FileServerDir(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func mount(mux *http.ServeMux) {
+ mux.Handle("/static/", http.FileServer(http.Dir("/var/www")))
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-016", "CWE-548") {
+ t.Error("expected AST-016 for http.FileServer(http.Dir(...))")
+ }
+}
+
+func TestAST016_FileServerFS_Safe(t *testing.T) {
+ // http.FileServer(http.FS(embed)) does not autoindex from a real dir.
+ code := `package main
+
+import (
+ "embed"
+ "net/http"
+)
+
+var assets embed.FS
+
+func mount(mux *http.ServeMux) {
+ mux.Handle("/static/", http.FileServer(http.FS(assets)))
+}
+`
+ if hasRule(code, "BATOU-AST-016") {
+ t.Error("did not expect AST-016 for http.FileServer(http.FS(...))")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-017: SHA-224 digest
+// =========================================================================
+
+func TestAST017_SHA224(t *testing.T) {
+ code := `package main
+
+import "crypto/sha256"
+
+func digest(b []byte) [28]byte {
+ return sha256.Sum224(b)
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-017", "CWE-328") {
+ t.Error("expected AST-017 for sha256.Sum224")
+ }
+}
+
+func TestAST017_SHA256_Safe(t *testing.T) {
+ code := `package main
+
+import "crypto/sha256"
+
+func digest(b []byte) [32]byte {
+ return sha256.Sum256(b)
+}
+`
+ if hasRule(code, "BATOU-AST-017") {
+ t.Error("did not expect AST-017 for sha256.Sum256")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-018: ReverseProxy Director copies inbound host
+// =========================================================================
+
+func TestAST018_DirectorCopiesInboundHost(t *testing.T) {
+ code := `package main
+
+import (
+ "net/http"
+ "net/http/httputil"
+)
+
+func proxy() *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Director: func(req *http.Request) {
+ req.URL.Scheme = "http"
+ req.URL.Host = req.Host
+ },
+ }
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-018", "CWE-918") {
+ t.Error("expected AST-018 for Director copying req.Host into req.URL.Host")
+ }
+}
+
+func TestAST018_FixedUpstreamDirector_Safe(t *testing.T) {
+ code := `package main
+
+import (
+ "net/http"
+ "net/http/httputil"
+)
+
+func proxy() *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Director: func(req *http.Request) {
+ req.URL.Scheme = "http"
+ req.URL.Host = "backend.internal:8080"
+ },
+ }
+}
+`
+ if hasRule(code, "BATOU-AST-018") {
+ t.Error("did not expect AST-018 for a fixed-upstream Director")
+ }
+}
+
+// =========================================================================
+// BATOU-AST-019: bound to all interfaces (0.0.0.0)
+// =========================================================================
+
+func TestAST019_NetListenAllInterfaces(t *testing.T) {
+ code := `package main
+
+import "net"
+
+func serve() {
+ l, _ := net.Listen("tcp", "0.0.0.0:8080")
+ _ = l
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-019", "CWE-200") {
+ t.Error("expected AST-019 for net.Listen on 0.0.0.0")
+ }
+}
+
+func TestAST019_ServerAddrAllInterfaces(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func serve() {
+ s := &http.Server{Addr: "0.0.0.0:9090"}
+ _ = s
+}
+`
+ if !hasFindingCWE(t, code, "BATOU-AST-019", "CWE-200") {
+ t.Error("expected AST-019 for http.Server.Addr on 0.0.0.0")
+ }
+}
+
+func TestAST019_LocalhostBind_Safe(t *testing.T) {
+ code := `package main
+
+import "net"
+
+func serve() {
+ l, _ := net.Listen("tcp", "127.0.0.1:8080")
+ _ = l
+}
+`
+ if hasRule(code, "BATOU-AST-019") {
+ t.Error("did not expect AST-019 for a localhost bind")
+ }
+}
+
+func TestAST019_PortOnlyBind_Safe(t *testing.T) {
+ // The idiomatic ":port" form is the common default and is NOT flagged.
+ code := `package main
+
+import "net"
+
+func serve() {
+ l, _ := net.Listen("tcp", ":8080")
+ _ = l
+}
+`
+ if hasRule(code, "BATOU-AST-019") {
+ t.Error("did not expect AST-019 for the idiomatic :port form")
+ }
+}
+
+func TestAST019_VariableAddr_Safe(t *testing.T) {
+ code := `package main
+
+import "net"
+
+func serve(addr string) {
+ l, _ := net.Listen("tcp", addr)
+ _ = l
+}
+`
+ if hasRule(code, "BATOU-AST-019") {
+ t.Error("did not expect AST-019 for a variable address")
+ }
+}
+
+func TestAST018_DescriptionMentionsSSRF(t *testing.T) {
+ code := `package main
+
+import (
+ "net/http"
+ "net/http/httputil"
+)
+
+func proxy() *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Director: func(r *http.Request) {
+ r.URL.Host = r.Host
+ },
+ }
+}
+`
+ var got string
+ for _, f := range scanGo(code) {
+ if f.RuleID == "BATOU-AST-018" {
+ got = f.Description
+ }
+ }
+ if !strings.Contains(strings.ToLower(got), "forgery") && !strings.Contains(strings.ToLower(got), "upstream") {
+ t.Errorf("AST-018 description should explain the SSRF risk, got: %q", got)
+ }
+}
diff --git a/batou-core/analyzer/goast/goast_test.go b/batou-core/analyzer/goast/goast_test.go
index 6280e5c..15858dd 100644
--- a/batou-core/analyzer/goast/goast_test.go
+++ b/batou-core/analyzer/goast/goast_test.go
@@ -1,9 +1,8 @@
package goast
import (
- "testing"
-
"github.com/turenlabs/batou-rules/rules"
+ "testing"
)
func scanGo(code string) []rules.Finding {
@@ -561,6 +560,84 @@ func main() {
}
}
+// TestAST006_PartialTimeout_ReadHeaderOnly is the real-world FP regression test.
+// Grafana's pkg/server/instrumentation_service.go sets ReadHeaderTimeout (the
+// Go-documented Slowloris defense) but deliberately omits WriteTimeout and
+// IdleTimeout. The old rule demanded all three and flagged this well-defended
+// server. A request-phase timeout is present, so the CWE-400 threat is bounded
+// and the finding must NOT fire.
+func TestAST006_PartialTimeout_ReadHeaderOnly_NoFinding(t *testing.T) {
+ code := `package main
+
+import (
+ "net/http"
+ "time"
+)
+
+func newServer(router http.Handler) *http.Server {
+ return &http.Server{
+ // 5s timeout for header reads to avoid Slowloris attacks
+ ReadHeaderTimeout: 5 * time.Second,
+ Addr: ":8080",
+ Handler: router,
+ }
+}
+`
+ findings := scanGo(code)
+ if f := findByRule(findings, "BATOU-AST-006"); f != nil {
+ t.Errorf("FP: server with ReadHeaderTimeout (Slowloris defense) should not be flagged AST-006; got %q", f.Title)
+ }
+}
+
+// TestAST006_PartialTimeout_ReadTimeoutOnly_NoFinding mirrors Grafana's
+// pkg/api/http_server.go, which sets only ReadTimeout. A single request-phase
+// timeout is enough to bound the DoS threat the rule guards against.
+func TestAST006_PartialTimeout_ReadTimeoutOnly_NoFinding(t *testing.T) {
+ code := `package main
+
+import (
+ "net/http"
+ "time"
+)
+
+func newServer(h http.Handler) *http.Server {
+ return &http.Server{
+ Addr: ":8080",
+ Handler: h,
+ ReadTimeout: 10 * time.Second,
+ }
+}
+`
+ findings := scanGo(code)
+ if f := findByRule(findings, "BATOU-AST-006"); f != nil {
+ t.Errorf("FP: server with ReadTimeout should not be flagged AST-006; got %q", f.Title)
+ }
+}
+
+// TestAST006_NoTimeoutAtAll_StillFires is the true-positive guard proving the
+// rule was TIGHTENED, not disabled: a server with NO timeout field whatsoever
+// is genuinely Slowloris-exploitable and must still fire AST-006.
+func TestAST006_NoTimeoutAtAll_StillFires(t *testing.T) {
+ code := `package main
+
+import "net/http"
+
+func newServer(h http.Handler) *http.Server {
+ return &http.Server{
+ Addr: ":8080",
+ Handler: h,
+ }
+}
+`
+ findings := scanGo(code)
+ if findByRule(findings, "BATOU-AST-006") == nil {
+ t.Error("TP lost: http.Server with no timeout fields at all must still fire AST-006")
+ for _, f := range findings {
+ t.Logf(" %s: %s (line %d)", f.RuleID, f.Title, f.LineNumber)
+ }
+ }
+}
+
// =========================================================================
// BATOU-AST-007: DeferInLoop
// =========================================================================
@@ -750,6 +827,77 @@ func handler() {
}
}
+// AST-008 regression: a sync.WaitGroup-coordinated goroutine cannot leak
+// because the parent blocks on wg.Wait() until it exits. The scanner's own
+// concurrent rule loop in scanner.scanCore uses exactly this pattern.
+func TestAST008_SafeGoroutineBoundedByWaitGroup(t *testing.T) {
+ code := `package main
+
+import "sync"
+
+func handler() {
+ var wg sync.WaitGroup
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ doWork(n)
+ }(i)
+ }
+ wg.Wait()
+}
+`
+ findings := scanGo(code)
+ f := findByRule(findings, "BATOU-AST-008")
+ if f != nil {
+ t.Errorf("should not flag goroutine bounded by WaitGroup; got: %+v", f)
+ }
+}
+
+// AST-004 regression: the fuzzy "auth"/"crypt" substring match used to flag
+// any method name containing those substrings, including helpers like
+// c.checkDeprecatedCryptoImports(). It should only match calls qualified by
+// an imported package.
+func TestAST004_LocalMethodNamedLikeCrypto(t *testing.T) {
+ code := `package main
+
+type checker struct{}
+
+func (c *checker) checkDeprecatedCryptoImports() {}
+func (c *checker) validateAuthorization() {}
+
+func driver() {
+ c := &checker{}
+ c.checkDeprecatedCryptoImports()
+ c.validateAuthorization()
+}
+`
+ findings := scanGo(code)
+ f := findByRule(findings, "BATOU-AST-004")
+ if f != nil {
+ t.Errorf("should not flag local-method calls whose names contain 'crypt'/'auth'; got: %+v", f)
+ }
+}
+
+// AST-004 positive: a real call through an imported bcrypt package still
+// fires the fuzzy-match. This guards against the rule fix being too
+// permissive.
+func TestAST004_StillFlagsImportedBcrypt(t *testing.T) {
+ code := `package main
+
+import "golang.org/x/crypto/bcrypt"
+
+func login(pw []byte) {
+ bcrypt.CompareHashAndPassword(nil, pw)
+}
+`
+ findings := scanGo(code)
+ f := findByRule(findings, "BATOU-AST-004")
+ if f == nil {
+ t.Error("expected BATOU-AST-004 for discarded bcrypt.CompareHashAndPassword call")
+ }
+}
+
// FP 3: _, err := f() should NOT be flagged — error IS captured.
func TestAST004_SafeTupleReturnBlankFirst(t *testing.T) {
code := `package main
@@ -910,3 +1058,324 @@ func TestEmptyFile(t *testing.T) {
t.Errorf("expected no findings for empty file, got %d", len(findings))
}
}
+
+func TestIsLikelyDDLQuery(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ want bool
+ }{
+ // DDL keywords — always identifier-interpolation territory.
+ {"create_table", "CREATE TABLE foo (id INT)", true},
+ {"alter_add_column", "ALTER TABLE %s ADD COLUMN x", true},
+ {"drop_table", "DROP TABLE %s", true},
+ {"truncate", "TRUNCATE TABLE %s", true},
+ {"rename_table", "RENAME TABLE %s TO %s", true},
+ // Engine-specific admin commands (sequence / identity / privileges).
+ {"alter_sequence", "ALTER SEQUENCE `%s` RENAME TO `%s`", true},
+ {"setval", "SELECT setval('%s', COALESCE((SELECT MAX(id)+1 FROM `%s`), 1), false)", true},
+ {"identity_insert_on", "SET IDENTITY_INSERT %s ON", true},
+ {"identity_insert_off", "SET IDENTITY_INSERT %s OFF", true},
+ {"grant", "GRANT SELECT ON %s TO %s", true},
+ {"vacuum_table", "VACUUM ANALYZE %s", true},
+ {"reindex_table", "REINDEX TABLE %s", true},
+
+ // DML with identifier slot AND value placeholders → likely safe.
+ {"insert_into_with_q", "INSERT INTO %s (a,b) VALUES (?,?)", true},
+ {"update_set_with_q", "UPDATE %s SET col=? WHERE id=?", true},
+ {"select_from_with_q", "SELECT * FROM %s WHERE id=?", true},
+ {"select_from_pg_placeholder", "SELECT * FROM %s WHERE id=$1", true},
+ {"backticked_identifier", "INSERT INTO `%s` (a) VALUES (?)", true},
+ {"join_with_q", "SELECT a FROM t JOIN %s ON t.id=u.id WHERE t.id=?", true},
+
+ // DML with identifier slot but NO value placeholders — real risk.
+ {"insert_into_no_q", "INSERT INTO %s VALUES (%s)", false},
+ {"update_set_no_q", "UPDATE %s SET col=%s", false},
+ {"select_from_no_q", "SELECT * FROM %s WHERE id=%s", false},
+
+ // Plain DML, no interpolation at all.
+ {"plain_insert", "INSERT INTO users (a) VALUES (?)", false},
+
+ // Empty / non-SQL.
+ {"empty", "", false},
+ {"non_sql", "hello world", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isLikelyDDLQuery(tt.query); got != tt.want {
+ t.Errorf("isLikelyDDLQuery(%q) = %v, want %v", tt.query, got, tt.want)
+ }
+ })
+ }
+}
+
+// =========================================================================
+// BATOU-AST-009: Insecure TLS configuration (CWE-295 / CWE-327)
+// =========================================================================
+
+func TestAST009_InsecureSkipVerifyTrue(t *testing.T) {
+ code := `package main
+
+import "crypto/tls"
+
+func client() *tls.Config {
+ return &tls.Config{
+ InsecureSkipVerify: true,
+ }
+}
+`
+ f := findByRule(scanGo(code), "BATOU-AST-009")
+ if f == nil {
+ t.Fatal("expected BATOU-AST-009 for InsecureSkipVerify: true")
+ }
+ if f.CWEID != "CWE-295" {
+ t.Errorf("expected CWE-295, got %s", f.CWEID)
+ }
+}
+
+func TestAST009_MinVersionTLS10(t *testing.T) {
+ code := `package main
+
+import "crypto/tls"
+
+func cfg() *tls.Config {
+ return &tls.Config{
+ MinVersion: tls.VersionTLS10,
+ }
+}
+`
+ f := findByRule(scanGo(code), "BATOU-AST-009")
+ if f == nil {
+ t.Fatal("expected BATOU-AST-009 for MinVersion: tls.VersionTLS10")
+ }
+ if f.CWEID != "CWE-327" {
+ t.Errorf("expected CWE-327, got %s", f.CWEID)
+ }
+}
+
+func TestAST009_SkipVerifyFalse_Safe(t *testing.T) {
+ // InsecureSkipVerify: false means verification is ENABLED — not a finding.
+ code := `package main
+
+import "crypto/tls"
+
+func cfg() *tls.Config {
+ return &tls.Config{
+ InsecureSkipVerify: false,
+ MinVersion: tls.VersionTLS13,
+ }
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-009"); f != nil {
+ t.Errorf("did not expect AST-009 on a hardened tls.Config, got: %s", f.Description)
+ }
+}
+
+func TestAST009_UnrelatedStructField_Safe(t *testing.T) {
+ // A field literally named InsecureSkipVerify on a DIFFERENT type must not
+ // trigger — anchored on crypto/tls.Config only.
+ code := `package main
+
+type MyOpts struct {
+ InsecureSkipVerify bool
+}
+
+func opts() MyOpts {
+ return MyOpts{InsecureSkipVerify: true}
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-009"); f != nil {
+ t.Errorf("did not expect AST-009 on an unrelated struct field, got: %s", f.Description)
+ }
+}
+
+// =========================================================================
+// BATOU-AST-010: SSH host key verification (CWE-322)
+// =========================================================================
+
+func TestAST010_InsecureIgnoreHostKey(t *testing.T) {
+ code := `package main
+
+import "golang.org/x/crypto/ssh"
+
+func cfg() *ssh.ClientConfig {
+ return &ssh.ClientConfig{
+ User: "root",
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ }
+}
+`
+ f := findByRule(scanGo(code), "BATOU-AST-010")
+ if f == nil {
+ t.Fatal("expected BATOU-AST-010 for ssh.InsecureIgnoreHostKey()")
+ }
+ if f.CWEID != "CWE-322" {
+ t.Errorf("expected CWE-322, got %s", f.CWEID)
+ }
+}
+
+func TestAST010_MissingHostKeyCallback(t *testing.T) {
+ code := `package main
+
+import "golang.org/x/crypto/ssh"
+
+func cfg() *ssh.ClientConfig {
+ return &ssh.ClientConfig{
+ User: "root",
+ }
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-010"); f == nil {
+ t.Fatal("expected BATOU-AST-010 for ssh.ClientConfig with no HostKeyCallback")
+ }
+}
+
+func TestAST010_FixedHostKey_Safe(t *testing.T) {
+ code := `package main
+
+import "golang.org/x/crypto/ssh"
+
+func cfg(key ssh.PublicKey) *ssh.ClientConfig {
+ return &ssh.ClientConfig{
+ User: "root",
+ HostKeyCallback: ssh.FixedHostKey(key),
+ }
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-010"); f != nil {
+ t.Errorf("did not expect AST-010 when HostKeyCallback is ssh.FixedHostKey, got: %s", f.Description)
+ }
+}
+
+func TestAST010_UnrelatedHostKeyField_Safe(t *testing.T) {
+ // HostKeyCallback on an unrelated type must not trigger.
+ code := `package main
+
+type FakeConfig struct {
+ HostKeyCallback func() error
+}
+
+func cfg() FakeConfig {
+ return FakeConfig{}
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-010"); f != nil {
+ t.Errorf("did not expect AST-010 on an unrelated type, got: %s", f.Description)
+ }
+}
+
+// =========================================================================
+// BATOU-AST-011: Decompression bomb (CWE-409)
+// =========================================================================
+
+func TestAST011_UnboundedGzipCopy(t *testing.T) {
+ code := `package main
+
+import (
+ "compress/gzip"
+ "io"
+ "os"
+)
+
+func extract(f *os.File, out io.Writer) error {
+ gr, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(out, gr)
+ return err
+}
+`
+ f := findByRule(scanGo(code), "BATOU-AST-011")
+ if f == nil {
+ t.Fatal("expected BATOU-AST-011 for unbounded io.Copy from gzip.Reader")
+ }
+ if f.CWEID != "CWE-409" {
+ t.Errorf("expected CWE-409, got %s", f.CWEID)
+ }
+}
+
+func TestAST011_BoundedCopyN_Safe(t *testing.T) {
+ code := `package main
+
+import (
+ "compress/gzip"
+ "io"
+ "os"
+)
+
+const maxBytes = 100 << 20
+
+func extract(f *os.File, out io.Writer) error {
+ gr, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ _, err = io.CopyN(out, gr, maxBytes)
+ return err
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-011"); f != nil {
+ t.Errorf("did not expect AST-011 when io.CopyN bounds the copy, got: %s", f.Description)
+ }
+}
+
+func TestAST011_LimitReaderWrap_Safe(t *testing.T) {
+ code := `package main
+
+import (
+ "compress/gzip"
+ "io"
+ "os"
+)
+
+func extract(f *os.File, out io.Writer) error {
+ gr, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(out, io.LimitReader(gr, 100<<20))
+ return err
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-011"); f != nil {
+ t.Errorf("did not expect AST-011 when source is wrapped in io.LimitReader, got: %s", f.Description)
+ }
+}
+
+func TestAST011_PlainFileCopy_Safe(t *testing.T) {
+ // io.Copy from a plain file (not a decompressor) is not a decompression bomb.
+ code := `package main
+
+import (
+ "io"
+ "os"
+)
+
+func cp(src *os.File, out io.Writer) error {
+ _, err := io.Copy(out, src)
+ return err
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-011"); f != nil {
+ t.Errorf("did not expect AST-011 for plain (non-decompressing) io.Copy, got: %s", f.Description)
+ }
+}
+
+func TestAST010_ZeroValuePlaceholder_Safe(t *testing.T) {
+ // A bare zero-value ssh.ClientConfig{} (populated later) must NOT fire —
+ // avoids FPs on partial-initialization patterns.
+ code := `package main
+
+import "golang.org/x/crypto/ssh"
+
+func cfg() *ssh.ClientConfig {
+ c := &ssh.ClientConfig{}
+ c.User = "root"
+ return c
+}
+`
+ if f := findByRule(scanGo(code), "BATOU-AST-010"); f != nil {
+ t.Errorf("did not expect AST-010 on a bare zero-value ssh.ClientConfig{}, got: %s", f.Description)
+ }
+}
diff --git a/batou-core/analyzer/gvyast/gvyast.go b/batou-core/analyzer/gvyast/gvyast.go
index ce13c55..e8105d1 100644
--- a/batou-core/analyzer/gvyast/gvyast.go
+++ b/batou-core/analyzer/gvyast/gvyast.go
@@ -14,12 +14,12 @@ func init() {
rules.Register(&GroovyASTAnalyzer{})
}
-func (g *GroovyASTAnalyzer) ID() string { return "BATOU-GVY-AST" }
-func (g *GroovyASTAnalyzer) Name() string { return "Groovy AST Security Analyzer" }
-func (g *GroovyASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
-func (g *GroovyASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangGroovy} }
+func (g *GroovyASTAnalyzer) ID() string { return "BATOU-GVY-AST" }
+func (g *GroovyASTAnalyzer) Name() string { return "Groovy AST Security Analyzer" }
+func (g *GroovyASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
+func (g *GroovyASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangGroovy} }
func (g *GroovyASTAnalyzer) Description() string {
- return "AST-based analysis of Groovy code for string.execute() command injection, GroovyShell code injection, GString SQL injection, Jenkins pipeline injection, and Runtime.exec."
+ return "AST-based analysis of Groovy code for string.execute() command injection, GroovyShell code injection, GString SQL injection, Jenkins pipeline injection, Runtime.exec, XXE (XmlSlurper/XmlParser without secure processing), unsafe deserialization, and SSRF."
}
func (g *GroovyASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
@@ -35,15 +35,23 @@ func (g *GroovyASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
filePath: ctx.FilePath,
content: ctx.Content,
}
+ // File-level signal: if XXE-hardening is configured anywhere in the
+ // file, suppress the structural XXE finding. Groovy/Java XML parsers
+ // are hardened by setting FEATURE_SECURE_PROCESSING or disabling
+ // external DTDs/entities via setFeature(...). We detect this once on
+ // the raw content so a hardened parser in the same file doesn't fire.
+ c.xxeHardened = groovyHasXXEHardening(ctx.Content)
c.walk()
return c.findings
}
type gvyChecker struct {
- tree *ast.Tree
- filePath string
- content string
- findings []rules.Finding
+ tree *ast.Tree
+ filePath string
+ content string
+ xxeHardened bool
+ ctorVars map[string]string // varName -> constructed type (e.g. "XmlSlurper")
+ findings []rules.Finding
}
func (c *gvyChecker) walk() {
@@ -51,6 +59,18 @@ func (c *gvyChecker) walk() {
if root == nil {
return
}
+ // First pass: map local variables to the constructor type they hold
+ // (e.g. `def p = new XmlSlurper()` → p:XmlSlurper). Used so that a
+ // later `p.parse(req)` / `ois.readObject()` / `conn.openConnection()`
+ // can be attributed structurally even when the receiver is a variable.
+ c.ctorVars = map[string]string{}
+ root.Walk(func(n *ast.Node) bool {
+ if n.Type() == "declaration" {
+ c.recordCtorVar(n)
+ }
+ return true
+ })
+
root.Walk(func(n *ast.Node) bool {
switch n.Type() {
case "function_call":
@@ -59,6 +79,9 @@ func (c *gvyChecker) walk() {
c.checkRuntimeExec(n)
c.checkJenkinsPipeline(n)
c.checkGStringSQLInjection(n)
+ c.checkXXE(n)
+ c.checkUnsafeDeserialization(n)
+ c.checkSSRF(n)
case "declaration":
c.checkGStringSQLDeclaration(n)
}
@@ -66,6 +89,39 @@ func (c *gvyChecker) walk() {
})
}
+// recordCtorVar records `def x = new (...)` assignments so later
+// method calls on x can be attributed to structurally.
+func (c *gvyChecker) recordCtorVar(n *ast.Node) {
+ named := n.NamedChildren()
+ if len(named) < 2 {
+ return
+ }
+ varName := ""
+ for _, child := range named {
+ if child.Type() == "identifier" {
+ varName = child.Text()
+ break
+ }
+ }
+ if varName == "" {
+ return
+ }
+ // The RHS is typically a unary_op ("new ...") wrapping a function_call,
+ // or a bare function_call. Find the constructed type name.
+ t := groovyConstructedType(n)
+ if t == "" {
+ return
+ }
+ // SnakeYAML hardened with a SafeConstructor is not a deserialization
+ // gadget vector — record it under a benign sentinel so the later
+ // `.load()` call is not flagged. groovyIsYamlType() only matches the
+ // bare "Yaml" form, so "Yaml/safe" is invisible to the deser check.
+ if groovyIsYamlType(t) && strings.Contains(n.Text(), "SafeConstructor") {
+ t = t + "/safe"
+ }
+ c.ctorVars[varName] = t
+}
+
// checkStringExecute detects "string".execute() patterns in Groovy.
// In Groovy, String.execute() runs a shell command.
func (c *gvyChecker) checkStringExecute(n *ast.Node) {
@@ -107,27 +163,38 @@ func (c *gvyChecker) checkStringExecute(n *ast.Node) {
Tags: []string{"command-injection", "injection", "rce"},
})
} else if hasExecute {
- // .execute() on any variable is still potentially dangerous
+ // .execute() on a variable: only flag when the variable name
+ // strongly suggests a shell command. The previous branch
+ // fired on EVERY .execute() call, which produced 100+
+ // hits per Groovy repo because .execute() is also the SQL
+ // JDBC Statement method, HTTP-client method, Future
+ // resolver, etc. Without type info we can't disambiguate;
+ // the name heuristic catches the obvious cases (cmd,
+ // command, shell, script) while dropping the noise.
for _, dc := range child.NamedChildren() {
- if dc.Type() == "identifier" && dc.Text() != "execute" {
- c.findings = append(c.findings, rules.Finding{
- RuleID: "BATOU-GVY-AST-001",
- Severity: rules.High,
- SeverityLabel: rules.High.String(),
- Title: "Command execution via .execute()",
- Description: "Groovy's .execute() method runs the receiver string as a shell command. If the string is derived from user input, this enables command injection.",
- FilePath: c.filePath,
- LineNumber: int(n.StartRow()) + 1,
- MatchedText: truncate(n.Text(), 200),
- Suggestion: "Use ['command', 'arg1', 'arg2'].execute() with a list to avoid shell interpretation.",
- CWEID: "CWE-78",
- OWASPCategory: "A03:2021-Injection",
- Language: rules.LangGroovy,
- Confidence: "medium",
- Tags: []string{"command-injection", "injection"},
- })
+ if dc.Type() != "identifier" || dc.Text() == "execute" {
+ continue
+ }
+ if !groovyExecuteNameLikelyShell(dc.Text()) {
break
}
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-GVY-AST-001",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Command execution via .execute()",
+ Description: "Groovy's .execute() method runs the receiver string as a shell command. If the string is derived from user input, this enables command injection.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Use ['command', 'arg1', 'arg2'].execute() with a list to avoid shell interpretation.",
+ CWEID: "CWE-78",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangGroovy,
+ Confidence: "medium",
+ Tags: []string{"command-injection", "injection"},
+ })
+ break
}
}
}
@@ -389,6 +456,315 @@ func (c *gvyChecker) checkGStringSQLDeclaration(n *ast.Node) {
}
}
+// checkXXE detects XML parsing that is vulnerable to XML External Entity
+// (XXE) injection (CWE-611). Groovy's XmlSlurper and XmlParser resolve
+// external entities and DTDs by default; parsing untrusted XML without
+// disabling external entities / enabling secure-processing exposes XXE,
+// SSRF, and local file disclosure.
+//
+// Two structural shapes are detected:
+//
+// new XmlSlurper().parse(input) // direct chained construction
+// new XmlParser().parseText(input)
+//
+// def p = new XmlSlurper(); p.parse(input) // construction recorded, then call
+//
+// The finding is suppressed when the file configures XXE-hardening
+// (setFeature FEATURE_SECURE_PROCESSING / disallow-doctype-decl /
+// external-general-entities false), tracked at the file level.
+func (c *gvyChecker) checkXXE(n *ast.Node) {
+ if c.xxeHardened {
+ return
+ }
+ method := getGroovyMethodName(n)
+ if method != "parse" && method != "parseText" {
+ return
+ }
+ objType := c.groovyCallReceiverType(n)
+ if !groovyIsXMLParserType(objType) {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-GVY-AST-006",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "XML External Entity (XXE) via " + objType + "." + method + "()",
+ Description: "Groovy's " + objType + " resolves external entities and DTDs by default. Parsing untrusted XML without disabling external entities enables XXE: file disclosure, SSRF, and denial of service.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Disable external entities before parsing, e.g. new XmlSlurper(false, false) or set parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true) and FEATURE_SECURE_PROCESSING.",
+ CWEID: "CWE-611",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangGroovy,
+ Confidence: "high",
+ Tags: []string{"xxe", "xml"},
+ })
+}
+
+// checkUnsafeDeserialization detects Java/Groovy object deserialization of
+// untrusted data (CWE-502). ObjectInputStream.readObject() and SnakeYAML's
+// new Yaml().load(...) reconstruct arbitrary object graphs and are a classic
+// RCE gadget vector.
+func (c *gvyChecker) checkUnsafeDeserialization(n *ast.Node) {
+ method := getGroovyMethodName(n)
+ objType := c.groovyCallReceiverType(n)
+
+ var matched bool
+ var title, desc, suggestion string
+ switch {
+ case method == "readObject" && (objType == "ObjectInputStream" || strings.Contains(strings.ToLower(objType), "objectinput")):
+ matched = true
+ title = "Unsafe deserialization via ObjectInputStream.readObject()"
+ desc = "ObjectInputStream.readObject() reconstructs an arbitrary Java object graph. Deserializing untrusted bytes enables remote code execution through gadget chains."
+ suggestion = "Do not deserialize untrusted data. Use a safe format (JSON) or a hardened ObjectInputFilter / allowlist of permitted classes."
+ case method == "load" && groovyIsYamlType(objType) && !strings.Contains(n.Text(), "SafeConstructor"):
+ // `new Yaml(new SafeConstructor()).load(x)` chained inline is not a
+ // gadget vector; the var-tracked form is filtered in recordCtorVar.
+ matched = true
+ title = "Unsafe YAML deserialization via " + objType + ".load()"
+ desc = "SnakeYAML's load() instantiates arbitrary types named in the document. Loading untrusted YAML enables remote code execution; use loadAs or a SafeConstructor."
+ suggestion = "Use new Yaml(new SafeConstructor()).load(...) or yaml.loadAs(input, ExpectedType.class) to restrict instantiable types."
+ }
+ if !matched {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-GVY-AST-007",
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: title,
+ Description: desc,
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: suggestion,
+ CWEID: "CWE-502",
+ OWASPCategory: "A08:2021-Software and Data Integrity Failures",
+ Language: rules.LangGroovy,
+ Confidence: "high",
+ Tags: []string{"deserialization", "rce"},
+ })
+}
+
+// checkSSRF detects server-side request forgery (CWE-918): opening a network
+// connection or reading from a URL whose value is a variable (not a string
+// literal). Groovy idioms:
+//
+// new URL(target).openConnection()
+// new URL(target).text / .getText() / .openStream() / .newInputStream()
+// target.toURL().text / .openConnection()
+func (c *gvyChecker) checkSSRF(n *ast.Node) {
+ method := getGroovyMethodName(n)
+ ssrfMethods := map[string]bool{
+ "openConnection": true, "openStream": true,
+ "getText": true, "newInputStream": true, "newReader": true,
+ }
+ if !ssrfMethods[method] {
+ return
+ }
+ objType := c.groovyCallReceiverType(n)
+ // Receiver must be a URL: either `new URL(...)` chained, or `x.toURL()`.
+ isURL := objType == "URL" || c.groovyReceiverChainHasURL(n)
+ if !isURL {
+ return
+ }
+ // Require a non-literal URL argument somewhere in the URL construction
+ // (variable / interpolation) — a hardcoded literal URL is not SSRF.
+ if !c.groovyURLArgIsDynamic(n) {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-GVY-AST-008",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Server-Side Request Forgery (SSRF) via URL." + method + "()",
+ Description: "A network connection is opened to a URL built from a variable. If the URL is attacker-controlled, this enables SSRF against internal services and cloud metadata endpoints.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Validate the URL against an allowlist of permitted hosts/schemes before fetching, and block requests to private/link-local IP ranges.",
+ CWEID: "CWE-918",
+ OWASPCategory: "A10:2021-Server-Side Request Forgery",
+ Language: rules.LangGroovy,
+ Confidence: "medium",
+ Tags: []string{"ssrf"},
+ })
+}
+
+// groovyCallReceiverType returns the type of the object a method is called
+// on, for a function_call node. It resolves both:
+// - chained construction: `new XmlSlurper().parse(x)` → "XmlSlurper"
+// - recorded ctor vars: `p.parse(x)` where p = new XmlSlurper() → "XmlSlurper"
+//
+// Returns "" when the receiver type cannot be determined.
+func (c *gvyChecker) groovyCallReceiverType(n *ast.Node) string {
+ for _, child := range n.NamedChildren() {
+ if child.Type() != "dotted_identifier" {
+ continue
+ }
+ ids := child.NamedChildren()
+ if len(ids) == 0 {
+ continue
+ }
+ // Receiver is the first child of the dotted_identifier.
+ recv := ids[0]
+ switch recv.Type() {
+ case "function_call":
+ // new Type().method() — recv is `Type()`; first id is the type.
+ if t := groovyConstructedType(recv); t != "" {
+ return t
+ }
+ for _, rc := range recv.NamedChildren() {
+ if rc.Type() == "identifier" {
+ return rc.Text()
+ }
+ }
+ case "identifier":
+ // var.method() — look up the recorded constructor type.
+ if t, ok := c.ctorVars[recv.Text()]; ok {
+ return t
+ }
+ }
+ return ""
+ }
+ return ""
+}
+
+// groovyReceiverChainHasURL reports whether the receiver chain of a call
+// contains a URL construction or a .toURL() conversion, e.g.
+// `new URL(x).openConnection()` or `x.toURL().text`.
+func (c *gvyChecker) groovyReceiverChainHasURL(n *ast.Node) bool {
+ for _, child := range n.NamedChildren() {
+ if child.Type() != "dotted_identifier" {
+ continue
+ }
+ found := false
+ child.Walk(func(inner *ast.Node) bool {
+ if found {
+ return false
+ }
+ if inner.Type() == "identifier" {
+ t := inner.Text()
+ if t == "URL" || t == "toURL" {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+ }
+ return false
+}
+
+// groovyURLArgIsDynamic reports whether the URL receiver is built from a
+// non-literal value (variable / interpolated string), i.e. an identifier or
+// interpolation appears inside a `new URL(...)` or `x.toURL()` construction.
+// A URL built purely from string literals is treated as not-SSRF.
+func (c *gvyChecker) groovyURLArgIsDynamic(n *ast.Node) bool {
+ for _, child := range n.NamedChildren() {
+ if child.Type() != "dotted_identifier" {
+ continue
+ }
+ dynamic := false
+ // Walk the receiver chain; any identifier (other than the URL/
+ // method names) or interpolation means the URL value is dynamic.
+ child.Walk(func(inner *ast.Node) bool {
+ switch inner.Type() {
+ case "interpolation":
+ dynamic = true
+ return false
+ case "identifier":
+ t := inner.Text()
+ if t != "URL" && t != "toURL" && t != "openConnection" &&
+ t != "openStream" && t != "getText" && t != "text" &&
+ t != "newInputStream" && t != "newReader" {
+ dynamic = true
+ return false
+ }
+ }
+ return true
+ })
+ return dynamic
+ }
+ return false
+}
+
+// groovyConstructedType returns the class name from a `new Type(...)`
+// expression rooted at (or wrapped by) node n. Handles the tree-sitter
+// shape where `new X()` is a unary_op wrapping a function_call whose first
+// identifier is the type, as well as a bare function_call.
+func groovyConstructedType(n *ast.Node) string {
+ var fc *ast.Node
+ switch n.Type() {
+ case "function_call":
+ fc = n
+ default:
+ // Search children for the nearest function_call (handles unary_op
+ // "new ..." and declaration wrappers).
+ n.Walk(func(inner *ast.Node) bool {
+ if fc != nil {
+ return false
+ }
+ if inner.Type() == "function_call" {
+ fc = inner
+ return false
+ }
+ return true
+ })
+ }
+ if fc == nil {
+ return ""
+ }
+ for _, child := range fc.NamedChildren() {
+ if child.Type() == "identifier" {
+ return child.Text()
+ }
+ }
+ return ""
+}
+
+// groovyIsXMLParserType reports whether a type name is a Groovy/Java XML
+// parser whose default configuration is XXE-vulnerable.
+func groovyIsXMLParserType(t string) bool {
+ switch t {
+ case "XmlSlurper", "XmlParser", "SAXParser", "DocumentBuilder", "SAXBuilder":
+ return true
+ }
+ return false
+}
+
+// groovyIsYamlType reports whether a type name is a SnakeYAML loader.
+func groovyIsYamlType(t string) bool {
+ return t == "Yaml" || strings.HasSuffix(t, "Yaml")
+}
+
+// groovyHasXXEHardening reports whether the file content configures XML
+// parser hardening that mitigates XXE. Used to suppress the structural XXE
+// finding when the parser is explicitly secured.
+func groovyHasXXEHardening(content string) bool {
+ lower := strings.ToLower(content)
+ markers := []string{
+ "feature_secure_processing",
+ "secure-processing",
+ "disallow-doctype-decl",
+ "external-general-entities",
+ "external-parameter-entities",
+ "setexpandentityreferences",
+ "access_external_dtd",
+ // new XmlSlurper(false, false) disables validation + namespace;
+ // the third-arg `allowDocTypeDeclaration=false` (default) hardens it.
+ "new xmlslurper(false, false, false)",
+ }
+ for _, m := range markers {
+ if strings.Contains(lower, m) {
+ return true
+ }
+ }
+ return false
+}
+
// getGroovyMethodName extracts the last method name from a Groovy function_call.
func getGroovyMethodName(n *ast.Node) string {
for _, child := range n.NamedChildren() {
@@ -429,6 +805,21 @@ func hasGroovyVariableArg(n *ast.Node) bool {
return false
}
+// groovyExecuteNameLikelyShell returns true when the variable name hints
+// that .execute() will run a shell command rather than a SQL statement,
+// HTTP request, or async task. Used to narrow the medium-confidence
+// branch of BATOU-GVY-AST-001 which previously fired on every
+// `.execute()` call.
+func groovyExecuteNameLikelyShell(varName string) bool {
+ lower := strings.ToLower(varName)
+ for _, hint := range []string{"cmd", "command", "shell", "script", "exec"} {
+ if strings.Contains(lower, hint) {
+ return true
+ }
+ }
+ return false
+}
+
func truncate(s string, max int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
diff --git a/batou-core/analyzer/gvyast/gvyast_test.go b/batou-core/analyzer/gvyast/gvyast_test.go
index 872b8e0..605547f 100644
--- a/batou-core/analyzer/gvyast/gvyast_test.go
+++ b/batou-core/analyzer/gvyast/gvyast_test.go
@@ -1,11 +1,10 @@
package gvyast
import (
- "strings"
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "strings"
+ "testing"
)
func scanGvy(t *testing.T, code string) []rules.Finding {
@@ -64,6 +63,29 @@ class Foo {
}
}
+// TestNonShellExecuteIsNotFlagged covers the FP shape that dominated the
+// scan-harness Groovy sample: .execute() on receivers that obviously
+// aren't shell strings (statement.execute(sql), future.execute(),
+// task.execute(), etc.). The name heuristic should refuse to fire.
+func TestNonShellExecuteIsNotFlagged(t *testing.T) {
+ code := `
+class Foo {
+ def doStuff(statement, future, task, request) {
+ statement.execute()
+ future.execute()
+ task.execute()
+ request.execute()
+ }
+}
+`
+ findings := scanGvy(t, code)
+ for _, f := range findings {
+ if f.RuleID == "BATOU-GVY-AST-001" {
+ t.Errorf("non-shell .execute() should not produce BATOU-GVY-AST-001; got: %s", f.MatchedText)
+ }
+ }
+}
+
func TestGroovyShellEvaluate(t *testing.T) {
code := `
class Foo {
@@ -189,3 +211,169 @@ func TestWrongLanguage(t *testing.T) {
t.Error("expected no findings for wrong language")
}
}
+
+// hasRuleCWE reports whether findings contain a finding with the given rule
+// ID and CWE.
+func hasRuleCWE(findings []rules.Finding, ruleID, cwe string) bool {
+ for _, f := range findings {
+ if f.RuleID == ruleID && f.CWEID == cwe {
+ return true
+ }
+ }
+ return false
+}
+
+// --- XXE (CWE-611) ---
+
+func TestXXEXmlSlurperParse(t *testing.T) {
+ code := `
+class Foo {
+ def parse(userInput) {
+ return new XmlSlurper().parse(userInput)
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if !hasRuleCWE(findings, "BATOU-GVY-AST-006", "CWE-611") {
+ t.Error("expected XXE finding for new XmlSlurper().parse(userInput)")
+ }
+}
+
+func TestXXEXmlParserParseText(t *testing.T) {
+ code := `
+class Foo {
+ def parse(xml) {
+ return new XmlParser().parseText(xml)
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if !hasRuleCWE(findings, "BATOU-GVY-AST-006", "CWE-611") {
+ t.Error("expected XXE finding for new XmlParser().parseText(xml)")
+ }
+}
+
+func TestXXEVariableTracked(t *testing.T) {
+ code := `
+class Foo {
+ def parse(xml) {
+ def slurper = new XmlSlurper()
+ return slurper.parse(xml)
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if !hasRuleCWE(findings, "BATOU-GVY-AST-006", "CWE-611") {
+ t.Error("expected XXE finding for var-tracked slurper.parse(xml)")
+ }
+}
+
+func TestXXESuppressedWhenHardened(t *testing.T) {
+ code := `
+class Foo {
+ def parse(xml) {
+ def slurper = new XmlSlurper()
+ slurper.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
+ return slurper.parse(xml)
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if hasRuleCWE(findings, "BATOU-GVY-AST-006", "CWE-611") {
+ t.Error("XXE finding should be suppressed when disallow-doctype-decl is set")
+ }
+}
+
+func TestXXENotFiredOnNonParser(t *testing.T) {
+ // .parse() on a non-XML-parser receiver must not fire XXE.
+ code := `
+class Foo {
+ def run(data) {
+ def n = Integer.parse(data)
+ return n
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if hasRuleCWE(findings, "BATOU-GVY-AST-006", "CWE-611") {
+ t.Error("XXE should not fire on Integer.parse")
+ }
+}
+
+// --- Unsafe deserialization (CWE-502) ---
+
+func TestDeserObjectInputStream(t *testing.T) {
+ code := `
+class Foo {
+ def load(stream) {
+ def ois = new ObjectInputStream(stream)
+ return ois.readObject()
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if !hasRuleCWE(findings, "BATOU-GVY-AST-007", "CWE-502") {
+ t.Error("expected deserialization finding for ObjectInputStream.readObject()")
+ }
+}
+
+func TestDeserYamlLoad(t *testing.T) {
+ code := `
+class Foo {
+ def load(input) {
+ def yaml = new Yaml()
+ return yaml.load(input)
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if !hasRuleCWE(findings, "BATOU-GVY-AST-007", "CWE-502") {
+ t.Error("expected deserialization finding for Yaml.load()")
+ }
+}
+
+func TestDeserYamlSafeConstructorNotFlagged(t *testing.T) {
+ code := `
+class Foo {
+ def load(input) {
+ def yaml = new Yaml(new SafeConstructor())
+ return yaml.load(input)
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if hasRuleCWE(findings, "BATOU-GVY-AST-007", "CWE-502") {
+ t.Error("Yaml(new SafeConstructor()).load() must not be flagged as unsafe deserialization")
+ }
+}
+
+// --- SSRF (CWE-918) ---
+
+func TestSSRFUrlOpenConnection(t *testing.T) {
+ code := `
+class Foo {
+ def fetch(target) {
+ return new URL(target).openConnection()
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if !hasRuleCWE(findings, "BATOU-GVY-AST-008", "CWE-918") {
+ t.Error("expected SSRF finding for new URL(target).openConnection()")
+ }
+}
+
+func TestSSRFLiteralUrlNotFlagged(t *testing.T) {
+ // A hardcoded literal URL is not SSRF.
+ code := `
+class Foo {
+ def fetch() {
+ return new URL("https://example.com/static").openConnection()
+ }
+}
+`
+ findings := scanGvy(t, code)
+ if hasRuleCWE(findings, "BATOU-GVY-AST-008", "CWE-918") {
+ t.Error("literal-URL openConnection must not be flagged as SSRF")
+ }
+}
diff --git a/batou-core/analyzer/javaast/javaast.go b/batou-core/analyzer/javaast/javaast.go
index e4b8947..bda8440 100644
--- a/batou-core/analyzer/javaast/javaast.go
+++ b/batou-core/analyzer/javaast/javaast.go
@@ -14,12 +14,12 @@ func init() {
rules.Register(&JavaASTAnalyzer{})
}
-func (j *JavaASTAnalyzer) ID() string { return "BATOU-JAVAAST" }
-func (j *JavaASTAnalyzer) Name() string { return "Java AST Security Analyzer" }
-func (j *JavaASTAnalyzer) DefaultSeverity() rules.Severity { return rules.Critical }
-func (j *JavaASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangJava} }
+func (j *JavaASTAnalyzer) ID() string { return "BATOU-JAVAAST" }
+func (j *JavaASTAnalyzer) Name() string { return "Java AST Security Analyzer" }
+func (j *JavaASTAnalyzer) DefaultSeverity() rules.Severity { return rules.Critical }
+func (j *JavaASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangJava} }
func (j *JavaASTAnalyzer) Description() string {
- return "AST-based analysis of Java source for SQL injection via string concatenation, Runtime.exec command injection, ObjectInputStream deserialization, JNDI lookup injection, and unsafe reflection via Class.forName."
+ return "AST-based analysis of Java source for SQL injection via string concatenation, Runtime.exec command injection, ObjectInputStream deserialization, JNDI lookup injection, unsafe reflection via Class.forName, XXE via unhardened XML parser factories, reflected XSS via servlet writers, SSRF via new URL(...), and SpEL/OGNL expression injection."
}
func (j *JavaASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
@@ -52,9 +52,15 @@ func (c *javaChecker) walk() {
root.Walk(func(n *ast.Node) bool {
if n.Type() == "method_invocation" {
c.checkMethodInvocation(n)
+ c.checkReflectedXSS(n)
+ c.checkSpELOGNL(n)
+ c.checkXXEParse(n)
+ c.checkInsecureTLS(n)
}
if n.Type() == "object_creation_expression" {
c.checkObjectCreation(n)
+ c.checkSSRFURL(n)
+ c.checkProcessBuilder(n)
}
return true
})
@@ -75,7 +81,9 @@ func (c *javaChecker) checkMethodInvocation(n *ast.Node) {
c.checkRuntimeExec(n)
}
- // ProcessBuilder: .command(var) or new ProcessBuilder(var)
+ // Runtime.exec(var) where the receiver is a Runtime instance variable
+ // (e.g. `rt.exec(cmd)` after `Runtime rt = Runtime.getRuntime()`), not the
+ // chained Runtime.getRuntime().exec(...) form handled above.
if methodName == "exec" && objName == "Runtime" {
c.checkRuntimeExec(n)
}
@@ -85,8 +93,14 @@ func (c *javaChecker) checkMethodInvocation(n *ast.Node) {
c.checkDeserialization(n)
}
- // JNDI lookup
- if methodName == "lookup" {
+ // JNDI lookup. Gate on the receiver looking like a JNDI Context — the
+ // bare method name "lookup" collides with many enum-style static lookup
+ // tables (Bouncy Castle CRLReason.lookup, ECNamedDomainParameters.lookup),
+ // Apache StrLookup.lookup, Spring SpringEnvironmentLookup.lookup, etc.
+ // Real JNDI receivers are Context / InitialContext / DirContext /
+ // LdapContext (or instances thereof, conventionally named ctx, context,
+ // initialContext, ic, ictx, jndi*, ldap*, namingContext, directory).
+ if methodName == "lookup" && isJNDIReceiver(objName) {
c.checkJNDILookup(n)
}
@@ -186,6 +200,96 @@ func (c *javaChecker) checkRuntimeExec(n *ast.Node) {
})
}
+// checkProcessBuilder detects `new ProcessBuilder(...)` whose command list
+// contains a STRING CONCATENATION that embeds a non-literal value, e.g.
+// `new ProcessBuilder(new String[]{"sh","-c","ping -c 2 " + ipAddress})` or the
+// varargs `new ProcessBuilder("sh","-c","ping " + ip)`. Building a shell command
+// string by concatenating input directly inside the constructor is the
+// canonical command-injection shape (the SasanLabs/VulnerableApp ping idiom):
+// when the process is run via `sh -c`/`cmd /c`, the concatenated value is parsed
+// by the shell, so attacker metacharacters inject additional commands.
+//
+// This is the construction-side companion to checkRuntimeExec: the javaast
+// analyzer's contract advertised `new ProcessBuilder(var)` coverage but only the
+// Runtime.exec() form was wired, so helper code that builds a process from a
+// concatenated tainted ipAddress/filename went uncovered by the AST tier.
+//
+// Scope is deliberately limited to the in-constructor concatenation form. A bare
+// variable / List argument (`new ProcessBuilder(argList)` / `pb.command(argList)`)
+// is NOT flagged here: an argv list passed to ProcessBuilder is overwhelmingly a
+// fixed command vector assembled elsewhere, and whether any element is tainted is
+// a dataflow question the taint tier already answers — flagging the bare-list
+// construction structurally floods false positives on safe fixed-command usage.
+// A purely literal command list is likewise never flagged.
+func (c *javaChecker) checkProcessBuilder(n *ast.Node) {
+ if javaConstructedType(n) != "ProcessBuilder" {
+ return
+ }
+ args := n.ChildByFieldName("arguments")
+ if args == nil {
+ args = findChild(n, "argument_list")
+ }
+ if args == nil {
+ return
+ }
+ if !anyProcessBuilderArgIsTaintedConcat(args) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JAVAAST-002",
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: "Command injection via ProcessBuilder",
+ Description: "A ProcessBuilder command element is built by concatenating a string with a non-literal value. When the process is run through a shell (sh -c / cmd /c), a user-controlled value concatenated into the command lets an attacker inject shell metacharacters and execute arbitrary system commands.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Never concatenate input into a shell command string. Pass a fixed argv list (no `sh -c`) and validate/allowlist any user-supplied argument (e.g. restrict an IP/hostname to a strict pattern) before adding it to the command list.",
+ CWEID: "CWE-78",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"command-injection", "injection", "rce", "ast"},
+ })
+}
+
+// anyProcessBuilderArgIsTaintedConcat reports whether any command element in a
+// ProcessBuilder constructor argument_list is a string concatenation that
+// embeds a non-literal operand. It descends into array_creation_expression /
+// array_initializer so the `new String[]{"sh","-c","ping "+ip}` idiom is
+// inspected element-by-element.
+func anyProcessBuilderArgIsTaintedConcat(args *ast.Node) bool {
+ for _, arg := range args.NamedChildren() {
+ if processBuilderElementIsTaintedConcat(arg) {
+ return true
+ }
+ }
+ return false
+}
+
+// processBuilderElementIsTaintedConcat reports whether a single command element
+// (or an array of them) is a variable-bearing string concatenation. Bare
+// variables and pure literals return false.
+func processBuilderElementIsTaintedConcat(n *ast.Node) bool {
+ if n == nil {
+ return false
+ }
+ switch n.Type() {
+ case "binary_expression":
+ // "ping " + ip — flagged only when an operand is non-literal.
+ return containsStringConcat(n)
+ case "array_creation_expression", "array_initializer":
+ for _, child := range n.NamedChildren() {
+ if processBuilderElementIsTaintedConcat(child) {
+ return true
+ }
+ }
+ return false
+ }
+ return false
+}
+
// checkDeserialization flags readObject() calls.
func (c *javaChecker) checkDeserialization(n *ast.Node) {
line := int(n.StartRow()) + 1
@@ -236,8 +340,754 @@ func (c *javaChecker) checkJNDILookup(n *ast.Node) {
})
}
-// checkUnsafeReflection detects Class.forName(variable).
-func (c *javaChecker) checkUnsafeReflection(n *ast.Node) {
+// checkUnsafeReflection detects Class.forName(variable).
+func (c *javaChecker) checkUnsafeReflection(n *ast.Node) {
+ args := findChild(n, "argument_list")
+ if args == nil {
+ return
+ }
+ firstArg := firstNamedChild(args)
+ if firstArg == nil || isJavaLiteral(firstArg) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JAVAAST-005",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Unsafe reflection via Class.forName() with variable",
+ Description: "Class.forName() is called with a non-literal class name. If the class name is user-controlled, an attacker can instantiate arbitrary classes leading to code execution.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Validate the class name against an allowlist of permitted classes before calling Class.forName().",
+ CWEID: "CWE-470",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"reflection", "injection", "ast"},
+ })
+}
+
+// checkXXEParse detects an XML parse() call whose underlying parser factory
+// (DocumentBuilderFactory / SAXParserFactory / XMLInputFactory / SchemaFactory /
+// TransformerFactory) was created in the enclosing method WITHOUT any of the
+// hardening flags that disable DOCTYPE/external-entity resolution. Unhardened
+// factories resolve external entities by default, so parse()-ing attacker XML
+// allows XXE (file read, SSRF, billion-laughs).
+//
+// This is a *structural misconfig* detector, not a dataflow one: the absence of
+// a setFeature("...disallow-doctype-decl...", true) / setExpandEntityReferences
+// (false) / XMLConstants.FEATURE_SECURE_PROCESSING call in the same method is
+// the vulnerability, independent of whether the parsed argument is tainted.
+// The taint tier already covers the source->parse() flow; this gives the same
+// class the AST confidence tier when the factory is left in its insecure
+// default state.
+func (c *javaChecker) checkXXEParse(n *ast.Node) {
+ methodName := javaMethodName(n)
+ // XML parse entrypoints across DOM (parse), SAX (parse), StAX
+ // (createXMLStreamReader/createXMLEventReader), JAXP transform/newSchema,
+ // JDOM/dom4j (build/read).
+ if !isXMLParseMethod(methodName) {
+ return
+ }
+ // Confine the analysis to the enclosing method/constructor body so that a
+ // hardening call elsewhere in the file does not mask a different method's
+ // misconfig, and vice-versa.
+ scope := enclosingBody(n)
+ if scope == nil {
+ return
+ }
+ // JAXB Unmarshaller.unmarshal(...) is a distinct XXE vector with no JAXP
+ // *factory* in scope to key on: a vanilla Unmarshaller resolves external
+ // entities by default. Handle it via its own discriminating shape before the
+ // factory gate (which it would otherwise fail). checkJAXBUnmarshal reports
+ // true once it has definitively handled a genuine JAXB unmarshal (fired or
+ // recognized a safe shape), so the generic factory path is skipped.
+ if methodName == "unmarshal" && c.checkJAXBUnmarshal(n, scope) {
+ return
+ }
+ // Require an XML parser factory to be constructed in this scope; otherwise
+ // "parse"/"read"/"build" is too generic (collides with JSON parsers,
+ // number parsing, file builders, etc.).
+ if !scopeCreatesXMLFactory(scope) {
+ return
+ }
+ // If the scope hardens the factory on a path that unconditionally reaches
+ // this parse call, it's safe — suppress. Hardening nested inside a branch
+ // (e.g. `if (securityEnabled) { xif.setProperty(ACCESS_EXTERNAL_DTD, "") }`)
+ // that does NOT also contain the parse call does not protect it: the
+ // hardening may be skipped while the parse still runs. This is the canonical
+ // "security toggle defaults off" XXE-evasion shape (WebGoat CommentsCache).
+ if scopeHasXXEHardening(scope, n) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JAVAAST-006",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "XXE: XML parsed by an unhardened parser factory",
+ Description: "An XML document is parsed via a DocumentBuilderFactory/SAXParserFactory/XMLInputFactory created without disabling DOCTYPE declarations or external entity resolution. Unhardened JAXP factories resolve external entities by default, enabling XXE (arbitrary file disclosure, SSRF, and denial of service).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Harden the factory before parsing: dbf.setFeature(\"http://apache.org/xml/features/disallow-doctype-decl\", true); dbf.setFeature(\"http://xml.org/sax/features/external-general-entities\", false); dbf.setFeature(\"http://xml.org/sax/features/external-parameter-entities\", false); dbf.setExpandEntityReferences(false);",
+ CWEID: "CWE-611",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"xxe", "xml", "misconfig", "ast"},
+ })
+}
+
+// checkJAXBUnmarshal detects a JAXB Unmarshaller.unmarshal() call
+// that resolves external entities by default (CWE-611). Unlike DOM/SAX/StAX,
+// JAXB exposes no JAXP factory to harden directly, so the structural detector
+// above (scopeCreatesXMLFactory) never fires on it.
+//
+// To stay false-positive-safe at this structural tier, it fires ONLY when the
+// argument is a *raw* byte/char/entity input that JAXB itself will parse with
+// external entities enabled — an InputStream, File, Reader, or InputSource. It
+// deliberately does NOT fire when the argument is a StAX reader
+// (XMLEventReader/XMLStreamReader — the application's own XML pipeline, commonly
+// fed by a hardened/defensive XMLInputFactory), a wrapped
+// javax.xml.transform.Source, a DOM Node, or any argument whose type cannot be
+// positively resolved to a raw input. That conservative allowlist accepts a
+// tolerable false negative (a StAX/Source pipeline built from an *unhardened*
+// factory) in exchange for never flagging the idiomatic safe StAX/Source forms
+// used throughout real codebases (e.g. Spring's createDefensiveInputFactory()).
+//
+// It returns true once it has definitively handled a genuine JAXB unmarshal —
+// either by emitting a finding or by recognizing a non-raw/safe shape — so the
+// caller skips the generic factory path. It returns false for non-JAXB
+// `.unmarshal(...)` calls (Jackson/Gson, or a JAXB unmarshal whose context is
+// established outside this method scope), leaving prior behavior unchanged.
+func (c *javaChecker) checkJAXBUnmarshal(n, scope *ast.Node) bool {
+ // (a) Confirm this is genuinely JAXB: a JAXBContext is constructed or an
+ // Unmarshaller is established in this method scope. Without that anchor,
+ // "unmarshal" is too generic (Jackson XmlMapper, custom DTOs, etc.).
+ if !scopeEstablishesJAXBUnmarshaller(scope) {
+ return false
+ }
+ // (b) Fire only on a positively-resolved raw byte/char/entity input. StAX
+ // readers, wrapped Sources, DOM nodes, and unresolvable args are the
+ // developer's own XML pipeline — not flagged (avoids FPs on idiomatic
+ // safe code; tolerable FN).
+ if !jaxbUnmarshalArgIsRawInput(n, scope) {
+ return true
+ }
+ // (c) Even for a raw input, if the enclosing scope hardens an XML parser on a
+ // path that reaches this call, suppress — reuse the existing control-flow-
+ // aware hardening check (defense in depth; can only reduce firing).
+ if scopeHasXXEHardening(scope, n) {
+ return true
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JAVAAST-006",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "XXE: untrusted XML unmarshalled by an unhardened JAXB Unmarshaller",
+ Description: "A JAXB Unmarshaller.unmarshal(...) call consumes a raw InputStream/File/Reader/InputSource without disabling DOCTYPE declarations or external-entity resolution. A vanilla JAXB Unmarshaller resolves external entities by default, enabling XXE (arbitrary file disclosure, SSRF, and denial of service). The secure form unmarshals a javax.xml.transform.Source (e.g. a SAXSource) built from an XMLReader configured with disallow-doctype-decl.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Do not pass a raw stream to unmarshal(). Build a hardened SAXSource: SAXParserFactory spf = SAXParserFactory.newInstance(); spf.setFeature(\"http://apache.org/xml/features/disallow-doctype-decl\", true); spf.setFeature(\"http://xml.org/sax/features/external-general-entities\", false); spf.setFeature(\"http://xml.org/sax/features/external-parameter-entities\", false); Source src = new SAXSource(spf.newSAXParser().getXMLReader(), new InputSource(in)); unmarshaller.unmarshal(src);",
+ CWEID: "CWE-611",
+ OWASPCategory: "A05:2021-Security Misconfiguration",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"xxe", "xml", "jaxb", "ast"},
+ })
+ return true
+}
+
+// checkInsecureTLS detects improper TLS certificate / hostname validation
+// (CWE-295): an all-accepting HostnameVerifier or an empty-bodied
+// X509TrustManager wired into the JSSE / OkHttp / HttpsURLConnection stack.
+// These disable the protections TLS provides against man-in-the-middle
+// attacks. Detection is purely STRUCTURAL — it only fires when the supplied
+// verifier/trust-manager body is provably permissive (a bare `return true`
+// verifier, or empty checkServerTrusted/checkClientTrusted bodies). A verifier
+// or trust manager with any real validation logic in its body is NOT flagged,
+// which keeps the false-positive rate near zero (CodeQL's "NoHostnameVerification"
+// / "TrustAllX509TrustManager" queries cover the same class; this is an
+// independent implementation from the CWE-295 definition + JSSE API docs).
+func (c *javaChecker) checkInsecureTLS(n *ast.Node) {
+ methodName := javaMethodName(n)
+ switch methodName {
+ case "setHostnameVerifier", "setDefaultHostnameVerifier", "hostnameVerifier":
+ // The verifier is the first argument. Flag only if its body always
+ // returns true (lambda `-> true`, or an anonymous HostnameVerifier whose
+ // verify(...) body is `return true;`).
+ arg := firstCallArg(n)
+ if arg == nil {
+ return
+ }
+ if !verifierAlwaysTrue(arg) {
+ return
+ }
+ c.appendInsecureTLS(n, "BATOU-JAVAAST-010",
+ "Insecure TLS: HostnameVerifier accepts all hostnames",
+ "A custom HostnameVerifier that returns true for every hostname disables TLS hostname verification, allowing a man-in-the-middle to present a certificate for any host. This defeats the purpose of HTTPS.",
+ "Remove the custom verifier and rely on the default HostnameVerifier, or implement verify() to actually compare the hostname against the certificate's CN/SAN.")
+ case "init":
+ // SSLContext.init(KeyManager[], TrustManager[], SecureRandom): the
+ // second argument carries the trust managers. Flag when it constructs an
+ // X509TrustManager with empty checkServerTrusted / checkClientTrusted
+ // bodies (an all-trusting trust manager).
+ if scopeHasEmptyX509TrustManager(n) {
+ c.appendInsecureTLS(n, "BATOU-JAVAAST-011",
+ "Insecure TLS: X509TrustManager trusts all certificates",
+ "An X509TrustManager whose checkServerTrusted()/checkClientTrusted() methods have empty bodies accepts any certificate without validation, including self-signed and attacker-controlled certificates. This enables man-in-the-middle attacks against every TLS connection using this context.",
+ "Implement certificate-chain validation, or use the platform default TrustManagerFactory (TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())) instead of a custom all-trusting trust manager.")
+ }
+ }
+}
+
+// appendInsecureTLS records a CWE-295 finding.
+func (c *javaChecker) appendInsecureTLS(n *ast.Node, ruleID, title, desc, fix string) {
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: ruleID,
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: title,
+ Description: desc,
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: fix,
+ CWEID: "CWE-295",
+ OWASPCategory: "A07:2021-Identification and Authentication Failures",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"tls", "certificate-validation", "misconfig", "ast"},
+ })
+}
+
+// firstCallArg returns the first argument node of a method_invocation, skipping
+// the opening paren and punctuation.
+func firstCallArg(n *ast.Node) *ast.Node {
+ args := n.ChildByFieldName("arguments")
+ if args == nil {
+ args = findChild(n, "argument_list")
+ }
+ if args == nil {
+ return nil
+ }
+ return firstNamedChild(args)
+}
+
+// verifierAlwaysTrue reports whether the supplied HostnameVerifier argument is
+// an unconditional accept-all: a lambda whose body is the literal `true`, or an
+// anonymous HostnameVerifier class whose verify() method body is exactly
+// `return true;`.
+func verifierAlwaysTrue(arg *ast.Node) bool {
+ switch arg.Type() {
+ case "lambda_expression":
+ body := arg.ChildByFieldName("body")
+ if body == nil {
+ return false
+ }
+ // Expression-bodied lambda: `(h, s) -> true`.
+ if body.Type() == "true" {
+ return true
+ }
+ // Block-bodied lambda: `(h, s) -> { return true; }`.
+ if body.Type() == "block" {
+ return blockJustReturnsTrue(body)
+ }
+ return false
+ case "object_creation_expression":
+ // Anonymous `new HostnameVerifier() { public boolean verify(...) {...} }`.
+ body := findChild(arg, "class_body")
+ if body == nil {
+ return false
+ }
+ for _, m := range body.NamedChildren() {
+ if m.Type() != "method_declaration" {
+ continue
+ }
+ if methodDeclName(m) != "verify" {
+ continue
+ }
+ if b := m.ChildByFieldName("body"); b != nil && blockJustReturnsTrue(b) {
+ return true
+ }
+ }
+ return false
+ }
+ return false
+}
+
+// methodDeclName returns the declared name of a method_declaration node (its
+// `name` field). Unlike javaMethodName (which is for method_invocation call
+// sites), this reads a declaration's identifier.
+func methodDeclName(m *ast.Node) string {
+ if m == nil {
+ return ""
+ }
+ if id := m.ChildByFieldName("name"); id != nil {
+ return id.Text()
+ }
+ return ""
+}
+
+// blockJustReturnsTrue reports whether a `block` node's only statement is
+// `return true;` (ignoring braces). A verifier with any additional logic is not
+// matched, keeping the detector precise.
+func blockJustReturnsTrue(block *ast.Node) bool {
+ stmts := block.NamedChildren()
+ if len(stmts) != 1 {
+ return false
+ }
+ ret := stmts[0]
+ if ret.Type() != "return_statement" {
+ return false
+ }
+ val := firstNamedChild(ret)
+ return val != nil && val.Type() == "true"
+}
+
+// scopeHasEmptyX509TrustManager reports whether the argument subtree of an
+// SSLContext.init call constructs an X509TrustManager (or X509ExtendedTrustManager)
+// whose checkServerTrusted/checkClientTrusted method bodies are empty — i.e. an
+// all-trusting trust manager. Inspecting the trust manager body (rather than
+// merely the presence of the type) keeps the detector from flagging a properly
+// validating custom trust manager.
+func scopeHasEmptyX509TrustManager(initCall *ast.Node) bool {
+ args := initCall.ChildByFieldName("arguments")
+ if args == nil {
+ return false
+ }
+ found := false
+ args.Walk(func(n *ast.Node) bool {
+ if found {
+ return false
+ }
+ if n.Type() != "object_creation_expression" {
+ return true
+ }
+ t := javaConstructedType(n)
+ if t != "X509TrustManager" && t != "X509ExtendedTrustManager" {
+ return true
+ }
+ body := findChild(n, "class_body")
+ if body == nil {
+ return true
+ }
+ // An all-trusting trust manager has BOTH check methods present with
+ // empty bodies. Require at least one of checkServerTrusted/
+ // checkClientTrusted to be empty-bodied; flag if no non-empty check
+ // method provides real validation.
+ sawEmptyCheck := false
+ sawNonEmptyCheck := false
+ for _, m := range body.NamedChildren() {
+ if m.Type() != "method_declaration" {
+ continue
+ }
+ mn := methodDeclName(m)
+ if mn != "checkServerTrusted" && mn != "checkClientTrusted" {
+ continue
+ }
+ b := m.ChildByFieldName("body")
+ if b == nil {
+ continue
+ }
+ if len(b.NamedChildren()) == 0 {
+ sawEmptyCheck = true
+ } else {
+ sawNonEmptyCheck = true
+ }
+ }
+ if sawEmptyCheck && !sawNonEmptyCheck {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// checkReflectedXSS detects writing a non-literal value directly to a servlet
+// response writer/stream: response.getWriter().print(var) / .println(var) /
+// .write(var) / .append(var), or out.print(var) where `out` came from
+// getWriter()/getOutputStream(). Reflected, unencoded output of user input is
+// classic reflected XSS.
+func (c *javaChecker) checkReflectedXSS(n *ast.Node) {
+ methodName := javaMethodName(n)
+ if methodName != "print" && methodName != "println" &&
+ methodName != "write" && methodName != "append" {
+ return
+ }
+ obj := n.ChildByFieldName("object")
+ if obj == nil {
+ return
+ }
+ // The receiver must be (or derive from) a servlet writer/output stream.
+ // Strongest structural signal: object is itself a getWriter()/
+ // getOutputStream() invocation, i.e. response.getWriter().print(...).
+ if !isServletWriterReceiver(obj) {
+ return
+ }
+ args := n.ChildByFieldName("arguments")
+ if args == nil {
+ return
+ }
+ firstArg := firstNamedChild(args)
+ if firstArg == nil || isJavaLiteral(firstArg) {
+ return
+ }
+ // Precision guard: if the written identifier was assigned from a known
+ // HTML/output encoder in the enclosing method (htmlEscape/escapeHtml/
+ // Encode.forHtml/ESAPI.encodeForHTML/Jsoup.clean/...), the value is already
+ // sanitized — don't flag. This mirrors the taint engine's neutralization so
+ // the AST tier stays as precise as the catalog on sanitized flows.
+ scope := enclosingBody(n)
+ if firstArg.Type() == "identifier" {
+ if scope != nil && c.identifierIsHTMLSanitized(scope, firstArg.Text()) {
+ return
+ }
+ }
+ // Origin gate: only flag when the written value provably derives from
+ // user input — an inline request getter, a trivial assignment chain back
+ // to one, or a parameter of a method that also handles servlet
+ // request/response objects. Writing a value the AST tier cannot trace to
+ // the request (collection reads, helper returns, computed locals) is left
+ // to the taint engine, which tracks those flows with real precision.
+ // Flagging every non-literal write floods servlet code that emits
+ // computed-but-safe HTML.
+ if !c.exprIsRequestDerived(scope, n, firstArg, 0) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JAVAAST-007",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Reflected XSS: unencoded value written to servlet response",
+ Description: "A non-literal value is written directly to the servlet response writer/output stream (" + methodName + "()). If the value contains user input, an attacker can inject HTML/JavaScript that executes in victims' browsers (reflected XSS).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "HTML-encode untrusted output before writing it (e.g. org.owasp.encoder.Encode.forHtml(value), Apache Commons StringEscapeUtils, or your framework's auto-escaping templating).",
+ CWEID: "CWE-79",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"xss", "injection", "ast"},
+ })
+}
+
+// checkSSRFURL detects `new URL(var)` / `new URI(var)` constructed from a
+// non-literal argument WHOSE VALUE THEN FLOWS TO A FETCH (openConnection /
+// openStream / HttpClient.send / RestTemplate / WebClient ...). URL/URI
+// construction on its own is NOT SSRF — it is overwhelmingly used to PARSE a
+// string (url.getHost(), uri.getPath(), redirect-URI allowlist comparison).
+// The server-side request only happens when something opens a connection on
+// the constructed value. Firing on the constructor alone produced a flood of
+// false positives on URL-parsing / URL-validation code (Keycloak's pairwise
+// subject mappers, redirect-URI validators, identity-provider profile parsers),
+// none of which ever issue a request. We require the constructed value to reach
+// a fetch within the enclosing method body before flagging.
+func (c *javaChecker) checkSSRFURL(n *ast.Node) {
+ typeName := javaConstructedType(n)
+ if typeName != "URL" && typeName != "URI" {
+ return
+ }
+ args := n.ChildByFieldName("arguments")
+ if args == nil {
+ return
+ }
+ firstArg := firstNamedChild(args)
+ if firstArg == nil || isJavaLiteral(firstArg) {
+ return
+ }
+ // A multi-arg URL(context, spec) form (base + relative path) is far less
+ // likely to be a full attacker-controlled endpoint; require the
+ // single-argument shape which is the classic SSRF sink.
+ if len(args.NamedChildren()) != 1 {
+ return
+ }
+ // Require the constructed URL/URI value to reach a fetch/connection within
+ // the enclosing method. Construction with no subsequent connection is URL
+ // parsing/validation, not SSRF — the actual request is the sink.
+ if !c.urlValueReachesFetch(n) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JAVAAST-008",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "SSRF: " + typeName + " built from a non-literal address",
+ Description: "A " + typeName + " is constructed from a non-literal string. If the address is user-controlled, opening a connection (openConnection/openStream) lets an attacker make the server issue requests to internal services, cloud metadata endpoints, or arbitrary hosts (SSRF).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Validate the URL against an allowlist of permitted hosts/schemes before opening a connection. Reject internal/link-local addresses and disable redirects to untrusted hosts.",
+ CWEID: "CWE-918",
+ OWASPCategory: "A10:2021-Server-Side Request Forgery",
+ Language: rules.LangJava,
+ Confidence: "high",
+ Tags: []string{"ssrf", "ast"},
+ })
+}
+
+// urlFetchMethods are methods that issue a server-side request on a
+// java.net.URL/URLConnection/URI — i.e. the operations that turn URL
+// construction into SSRF. A call to one of these ON the constructed value (or
+// on a connection opened from it) is what we require before flagging.
+var urlFetchMethods = map[string]bool{
+ "openConnection": true, // URL.openConnection()
+ "openStream": true, // URL.openStream()
+ "getContent": true, // URL.getContent()
+ "getInputStream": true, // URLConnection.getInputStream()
+ "connect": true, // URLConnection.connect()
+}
+
+// urlFetchSinkMethods are HTTP-client send/exchange operations that take a
+// URL/URI (or a request built from one) as an argument and issue the request.
+// When the constructed URL/URI identifier appears anywhere in such a call's
+// argument list we treat the value as reaching a fetch.
+var urlFetchSinkMethods = map[string]bool{
+ "send": true, // HttpClient.send / HttpClient.sendAsync
+ "sendAsync": true,
+ "exchange": true, // RestTemplate.exchange / WebClient ... exchange
+ "execute": true, // HttpClient.execute (Apache), RestTemplate.execute
+ "getForObject": true,
+ "getForEntity": true,
+ "postForObject": true,
+ "postForEntity": true,
+ "postForLocation": true,
+ "newRequest": true, // Jetty HttpClient.newRequest(uri)
+ "uri": true, // HttpRequest.newBuilder().uri(uri), WebClient ... .uri(uri)
+}
+
+// urlFetchSinkTypes are constructor types that wrap a URL/URI into an
+// outbound HTTP request (Apache HttpClient verbs, OkHttp/Google request
+// builders). `new HttpGet(uri)` etc. consume the value into a fetch.
+var urlFetchSinkTypes = map[string]bool{
+ "HttpGet": true,
+ "HttpPost": true,
+ "HttpPut": true,
+ "HttpDelete": true,
+ "HttpPatch": true,
+ "HttpHead": true,
+ "HttpUriRequest": true,
+ "GenericUrl": true,
+ "URIBuilder": true,
+}
+
+// urlValueReachesFetch reports whether the URL/URI built at construction node
+// `n` flows to a fetch operation within the enclosing method body. It handles:
+// - direct chaining: new URL(x).openConnection()
+// - a local assigned then fetched: URL u = new URL(x); u.openConnection();
+// - the value passed as an argument to an HTTP send/exchange or wrapped into
+// a request constructor: client.send(req(u)), new HttpGet(uri), etc.
+//
+// Anything it cannot connect to a fetch is treated as URL parsing/validation
+// and NOT flagged — the precise dataflow judgement belongs to the taint engine,
+// which independently models the openConnection/send catalog sinks.
+func (c *javaChecker) urlValueReachesFetch(n *ast.Node) bool {
+ // Case 1: directly chained on the construction — new URL(x).openStream().
+ // The parent method_invocation has `n` (possibly wrapped) as its object.
+ if directFetchOnConstruction(n) {
+ return true
+ }
+
+ // Determine the variable the construction is bound to, if any.
+ varName := constructionBoundVar(n)
+ body := enclosingBody(n)
+ if body == nil {
+ return false
+ }
+ if varName == "" {
+ // Unbound construction (e.g. passed inline as an argument). Only a
+ // direct chain (handled above) or inline-into-fetch makes it a sink;
+ // check whether the construction node sits inside a fetch call's args.
+ return constructionInsideFetchCall(n)
+ }
+
+ found := false
+ body.Walk(func(m *ast.Node) bool {
+ if found || m.Type() != "method_invocation" {
+ return !found
+ }
+ method := javaMethodName(m)
+ // u.openConnection() / u.openStream() — fetch on the URL value itself.
+ if urlFetchMethods[method] && javaObjectName(m) == varName {
+ found = true
+ return false
+ }
+ // HTTP send/exchange/etc. that references the URL value in its args:
+ // client.send(HttpRequest.newBuilder().uri(u).build(), ...),
+ // restTemplate.exchange(u, ...).
+ if urlFetchSinkMethods[method] && callArgsReference(m, varName) {
+ found = true
+ return false
+ }
+ return !found
+ })
+ if found {
+ return true
+ }
+
+ // new HttpGet(u) / new GenericUrl(u) — wrapping the value into a request.
+ body.Walk(func(m *ast.Node) bool {
+ if found || m.Type() != "object_creation_expression" {
+ return !found
+ }
+ if urlFetchSinkTypes[javaConstructedType(m)] && constructorArgsReference(m, varName) {
+ found = true
+ return false
+ }
+ return !found
+ })
+ return found
+}
+
+// directFetchOnConstruction reports whether the construction node is the
+// receiver of an immediately-chained fetch call: new URL(x).openConnection().
+// Tree-sitter nests the construction as the `object` of the outer
+// method_invocation (possibly through a parenthesized_expression).
+func directFetchOnConstruction(n *ast.Node) bool {
+ for p := n.Parent(); p != nil; p = p.Parent() {
+ switch p.Type() {
+ case "parenthesized_expression":
+ continue
+ case "method_invocation":
+ obj := p.ChildByFieldName("object")
+ if obj != nil && nodeContainsConstruction(obj, n) && urlFetchMethods[javaMethodName(p)] {
+ return true
+ }
+ return false
+ default:
+ return false
+ }
+ }
+ return false
+}
+
+// constructionInsideFetchCall reports whether the construction node appears
+// inside the argument list of an HTTP send/exchange call or a request-wrapping
+// constructor — the inline `client.send(new HttpGet(new URI(x)))` shape.
+func constructionInsideFetchCall(n *ast.Node) bool {
+ for p := n.Parent(); p != nil; p = p.Parent() {
+ switch p.Type() {
+ case "method_invocation":
+ if urlFetchSinkMethods[javaMethodName(p)] {
+ return true
+ }
+ case "object_creation_expression":
+ if urlFetchSinkTypes[javaConstructedType(p)] {
+ return true
+ }
+ case "method_declaration", "constructor_declaration", "lambda_expression":
+ return false
+ }
+ }
+ return false
+}
+
+// nodeContainsConstruction reports whether `n` is, or transitively wraps,
+// the construction node `target` (used to see through parenthesization).
+// Identity is pointer equality; the byte range narrows the recursive search.
+func nodeContainsConstruction(n, target *ast.Node) bool {
+ if n == nil || target == nil {
+ return false
+ }
+ if n == target {
+ return true
+ }
+ if target.StartByte() < n.StartByte() || target.EndByte() > n.EndByte() {
+ return false
+ }
+ for _, child := range n.NamedChildren() {
+ if nodeContainsConstruction(child, target) {
+ return true
+ }
+ }
+ return false
+}
+
+// constructionBoundVar returns the local-variable / field name that the
+// construction node is assigned to, or "" if it is not a simple binding.
+// Handles `URL u = new URL(x)` (variable_declarator) and `u = new URL(x)`
+// (assignment_expression).
+func constructionBoundVar(n *ast.Node) string {
+ p := n.Parent()
+ // Tree-sitter may nest the value behind a parenthesized_expression.
+ for p != nil && p.Type() == "parenthesized_expression" {
+ p = p.Parent()
+ }
+ if p == nil {
+ return ""
+ }
+ switch p.Type() {
+ case "variable_declarator":
+ if nm := p.ChildByFieldName("name"); nm != nil {
+ return nm.Text()
+ }
+ case "assignment_expression":
+ if left := p.ChildByFieldName("left"); left != nil && left.Type() == "identifier" {
+ return left.Text()
+ }
+ }
+ return ""
+}
+
+// callArgsReference reports whether identifier `name` appears anywhere in the
+// argument list of method invocation `m`.
+func callArgsReference(m *ast.Node, name string) bool {
+ args := m.ChildByFieldName("arguments")
+ if args == nil {
+ return false
+ }
+ return subtreeReferencesIdent(args, name)
+}
+
+// constructorArgsReference reports whether identifier `name` appears in the
+// argument list of object creation `m`.
+func constructorArgsReference(m *ast.Node, name string) bool {
+ args := m.ChildByFieldName("arguments")
+ if args == nil {
+ return false
+ }
+ return subtreeReferencesIdent(args, name)
+}
+
+// subtreeReferencesIdent reports whether `name` occurs as an identifier node
+// anywhere under `n`.
+func subtreeReferencesIdent(n *ast.Node, name string) bool {
+ found := false
+ n.Walk(func(m *ast.Node) bool {
+ if found {
+ return false
+ }
+ if m.Type() == "identifier" && m.Text() == name {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// checkSpELOGNL detects expression-language evaluation of a non-literal string:
+// parser.parseExpression(var) (Spring SpEL) and Ognl.parseExpression(var) /
+// Ognl.getValue(var, ...) (OGNL). Parsing+evaluating attacker-controlled
+// expressions yields remote code execution (e.g. Struts2/Spring SpEL RCE).
+func (c *javaChecker) checkSpELOGNL(n *ast.Node) {
+ methodName := javaMethodName(n)
+ objName := javaObjectName(n)
+ isSpEL := methodName == "parseExpression"
+ isOGNL := (methodName == "parseExpression" || methodName == "getValue" || methodName == "setValue") && objName == "Ognl"
+ if !isSpEL && !isOGNL {
+ return
+ }
args := findChild(n, "argument_list")
if args == nil {
return
@@ -246,22 +1096,26 @@ func (c *javaChecker) checkUnsafeReflection(n *ast.Node) {
if firstArg == nil || isJavaLiteral(firstArg) {
return
}
+ engine := "Spring SpEL"
+ if isOGNL {
+ engine = "OGNL"
+ }
line := int(n.StartRow()) + 1
c.findings = append(c.findings, rules.Finding{
- RuleID: "BATOU-JAVAAST-005",
- Severity: rules.High,
- SeverityLabel: rules.High.String(),
- Title: "Unsafe reflection via Class.forName() with variable",
- Description: "Class.forName() is called with a non-literal class name. If the class name is user-controlled, an attacker can instantiate arbitrary classes leading to code execution.",
+ RuleID: "BATOU-JAVAAST-009",
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: "Expression injection via " + engine + " (" + methodName + ")",
+ Description: engine + " parses and evaluates a non-literal expression string. If the expression is user-controlled, an attacker can invoke arbitrary methods (e.g. T(java.lang.Runtime).getRuntime().exec(...)) and achieve remote code execution.",
FilePath: c.filePath,
LineNumber: line,
MatchedText: truncate(n.Text(), 200),
- Suggestion: "Validate the class name against an allowlist of permitted classes before calling Class.forName().",
- CWEID: "CWE-470",
+ Suggestion: "Never evaluate user input as an expression. Use a fixed expression with a bound evaluation context, or a non-evaluating data format. For SpEL, use SimpleEvaluationContext to restrict reflection/type access.",
+ CWEID: "CWE-917",
OWASPCategory: "A03:2021-Injection",
Language: rules.LangJava,
Confidence: "high",
- Tags: []string{"reflection", "injection", "ast"},
+ Tags: []string{"expression-injection", "spel", "ognl", "injection", "rce", "ast"},
})
}
@@ -307,6 +1161,47 @@ func isRuntimeExec(n *ast.Node) bool {
return strings.Contains(text, "Runtime") && strings.Contains(text, "exec")
}
+// isJNDIReceiver reports whether the named receiver of a `lookup(...)` call
+// looks like a JNDI Context. Real JNDI types are javax.naming.Context and
+// its subinterfaces (DirContext, LdapContext, EventContext) plus
+// InitialContext / InitialDirContext / InitialLdapContext, and adjacent
+// helpers like Spring's JndiTemplate / JndiLocatorDelegate and log4j2's
+// JndiManager. We only have the receiver IDENTIFIER text from tree-sitter,
+// not full type info, so match by name shape:
+// - Class names ending in Context (Context, InitialContext, DirContext,
+// LdapContext, JndiContext, NamingContext, etc.)
+// - Names containing "jndi" (JndiTemplate, jndiManager, jndiLocator)
+// - Conventional instance names: ctx, ictx, context, initialContext, ic,
+// namingContext, directory, ldap, dirCtx, ldapCtx
+//
+// Bare calls (empty receiver) are conservatively skipped — they collide
+// with countless project-internal lookup() methods (Apache StrLookup,
+// SpringEnvironmentLookup, Bouncy Castle CRLReason.lookup, etc.).
+func isJNDIReceiver(recv string) bool {
+ if recv == "" {
+ return false
+ }
+ lower := strings.ToLower(recv)
+
+ // Conventional instance names — exact match.
+ switch lower {
+ case "ctx", "ictx", "ic", "context", "initialcontext",
+ "namingcontext", "dircontext", "ldapcontext",
+ "directory", "ldap", "dirctx", "ldapctx", "jndi":
+ return true
+ }
+
+ // Substring shapes: anything with "context" suffix, "jndi" anywhere,
+ // "ldap"/"dir" with "context" suffix.
+ if strings.HasSuffix(lower, "context") {
+ return true
+ }
+ if strings.Contains(lower, "jndi") {
+ return true
+ }
+ return false
+}
+
func isSQLMethod(name string) bool {
return name == "executeQuery" || name == "execute" || name == "executeUpdate"
}
@@ -383,3 +1278,799 @@ func truncate(s string, maxLen int) string {
}
return s
}
+
+// --- helpers for the deepened detectors (XXE / XSS / SSRF / SpEL) ---
+
+// isXMLParseMethod reports whether a method name is an XML parse/build
+// entrypoint that resolves entities on a JAXP-family parser.
+func isXMLParseMethod(name string) bool {
+ switch name {
+ case "parse", "build", "read",
+ "createXMLStreamReader", "createXMLEventReader",
+ "newSchema", "transform", "unmarshal":
+ return true
+ }
+ return false
+}
+
+// enclosingBody returns the nearest enclosing method/constructor/static-init
+// body (a "block" node) for n, or nil if none. Used to confine XXE-hardening
+// analysis to a single method scope.
+func enclosingBody(n *ast.Node) *ast.Node {
+ for p := n.Parent(); p != nil; p = p.Parent() {
+ switch p.Type() {
+ case "method_declaration", "constructor_declaration", "static_initializer":
+ if b := p.ChildByFieldName("body"); b != nil {
+ return b
+ }
+ // static_initializer's block is its first child.
+ if blk := findChild(p, "block"); blk != nil {
+ return blk
+ }
+ return p
+ }
+ }
+ return nil
+}
+
+// xmlFactoryTypes are the JAXP factory types whose default (unhardened)
+// configuration resolves external entities / DOCTYPEs.
+var xmlFactoryTypes = map[string]bool{
+ "DocumentBuilderFactory": true,
+ "SAXParserFactory": true,
+ "XMLInputFactory": true,
+ "SchemaFactory": true,
+ "TransformerFactory": true,
+ "SAXBuilder": true, // JDOM
+ "SAXReader": true, // dom4j
+ "XMLReader": true,
+}
+
+// scopeCreatesXMLFactory reports whether the given scope constructs one of the
+// known XML parser factories — either via `Type.newInstance()` /
+// `Type.newFactory()` (factory idiom) or `new Type(...)` (JDOM/dom4j idiom),
+// or declares a local/field of that type.
+func scopeCreatesXMLFactory(scope *ast.Node) bool {
+ found := false
+ scope.Walk(func(n *ast.Node) bool {
+ if found {
+ return false
+ }
+ switch n.Type() {
+ case "method_invocation":
+ // DocumentBuilderFactory.newInstance() etc.
+ if obj := n.ChildByFieldName("object"); obj != nil &&
+ obj.Type() == "identifier" && xmlFactoryTypes[obj.Text()] {
+ m := javaMethodName(n)
+ if m == "newInstance" || m == "newFactory" || m == "newDefaultInstance" {
+ found = true
+ return false
+ }
+ }
+ case "object_creation_expression":
+ if xmlFactoryTypes[javaConstructedType(n)] {
+ found = true
+ return false
+ }
+ case "type_identifier":
+ if xmlFactoryTypes[n.Text()] {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// scopeHasXXEHardening reports whether the scope contains a call that disables
+// DOCTYPE/external-entity processing AND that call unconditionally reaches the
+// parse call, i.e. the factory is genuinely hardened on the parse path.
+// Recognizes the standard OWASP hardening idioms: setFeature(disallow-doctype-
+// decl / no external entities / secure-processing, ...), setExpandEntity-
+// References(false), setXIncludeAware(false), setProperty(ACCESS_EXTERNAL_*, ""),
+// and setValidating with feature constants.
+//
+// A hardening call nested inside a branch (if/switch/ternary/catch) that does
+// NOT also contain the parse node is ignored — it may be skipped while the
+// parse still executes (the "security toggle defaults off" XXE-evasion shape,
+// e.g. WebGoat's `if (securityEnabled) { xif.setProperty(...) }` parsed with
+// securityEnabled=false). Hardening in the same branch as the parse, or at the
+// unconditional top level of the scope, still counts (no false positive).
+func scopeHasXXEHardening(scope *ast.Node, parse *ast.Node) bool {
+ found := false
+ scope.Walk(func(n *ast.Node) bool {
+ if found {
+ return false
+ }
+ if n.Type() != "method_invocation" {
+ return true
+ }
+ m := javaMethodName(n)
+ isHardening := false
+ switch m {
+ case "setFeature", "setProperty", "setAttribute":
+ // Inspect the argument text for known hardening feature/property URIs
+ // or constants.
+ if args := n.ChildByFieldName("arguments"); args != nil {
+ isHardening = xxeHardeningArg(args.Text())
+ }
+ case "setExpandEntityReferences", "setXIncludeAware":
+ // These are hardening only when passed false; check arg text.
+ if args := n.ChildByFieldName("arguments"); args != nil &&
+ strings.Contains(args.Text(), "false") {
+ isHardening = true
+ }
+ }
+ if isHardening && hardeningReachesParse(n, parse, scope) {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// branchNodeTypes are the Java AST control-flow nodes that introduce a
+// conditional path: a hardening call nested inside one of these (within the
+// analyzed method scope) only protects the parse when the parse is inside the
+// same branch.
+var branchNodeTypes = map[string]bool{
+ "if_statement": true,
+ "switch_expression": true, // tree-sitter-java models switch as switch_expression
+ "switch_block": true,
+ "switch_block_statement_group": true,
+ "ternary_expression": true,
+ "catch_clause": true,
+}
+
+// hardeningReachesParse reports whether a hardening call unconditionally reaches
+// the parse call. It walks the hardening node's ancestors up to (but not past)
+// the method scope; if any ancestor is a branch node that does NOT contain the
+// parse node, the hardening is on a conditional path that may be skipped while
+// the parse runs, so it does not protect the parse. If parse is nil (defensive),
+// hardening is credited unconditionally to preserve prior behavior.
+//
+// Exception (FP guard): a lazy-initialization / cache guard — `if (field ==
+// null) { ...build+harden...; this.field = factory; }` — does NOT skip the
+// hardening on later parses: the factory is hardened on first build and cached,
+// so every parse uses a hardened factory. Such a null-check guard is excluded
+// from the rejection so the canonical secure lazy-init idiom (Spring's
+// SourceHttpMessageConverter) is not flagged. A boolean security toggle
+// (`if (securityEnabled)`) is NOT a null-check, so it still rejects (WebGoat).
+func hardeningReachesParse(hardening, parse, scope *ast.Node) bool {
+ if parse == nil {
+ return true
+ }
+ parseStart := parse.StartByte()
+ for p := hardening.Parent(); p != nil && p != scope; p = p.Parent() {
+ if branchNodeTypes[p.Type()] {
+ // If the branch does not enclose the parse call, the hardening is
+ // gated behind a condition the parse does not depend on — unless that
+ // condition is a lazy-init/cache null guard (the factory is hardened
+ // once and reused on every subsequent parse).
+ if !p.ContainsOffset(parseStart) && !isLazyInitNullGuard(p) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// isLazyInitNullGuard reports whether an if_statement's condition is a null
+// equality/inequality check (`x == null` / `x != null`), the hallmark of the
+// build-once-and-cache idiom. Only `if_statement` carries a security/cache
+// condition worth distinguishing; other branch nodes (catch/ternary/switch)
+// are treated as plain conditionals.
+func isLazyInitNullGuard(branch *ast.Node) bool {
+ if branch.Type() != "if_statement" {
+ return false
+ }
+ cond := branch.ChildByFieldName("condition")
+ if cond == nil {
+ return false
+ }
+ // condition is a parenthesized_expression; unwrap to the inner expression.
+ inner := cond
+ for inner != nil && inner.Type() == "parenthesized_expression" {
+ if nc := firstNamedChild(inner); nc != nil {
+ inner = nc
+ } else {
+ break
+ }
+ }
+ if inner == nil || inner.Type() != "binary_expression" {
+ return false
+ }
+ // A binary_expression `lhs OP rhs` where OP is == or != and one side is the
+ // null literal.
+ op := inner.ChildByFieldName("operator")
+ if op == nil || (op.Text() != "==" && op.Text() != "!=") {
+ return false
+ }
+ l := inner.ChildByFieldName("left")
+ r := inner.ChildByFieldName("right")
+ return (l != nil && l.Type() == "null_literal") ||
+ (r != nil && r.Type() == "null_literal")
+}
+
+// xxeHardeningArg reports whether the (textual) argument list of a setFeature/
+// setProperty/setAttribute call references a known XXE-hardening feature.
+func xxeHardeningArg(argText string) bool {
+ needles := []string{
+ "disallow-doctype-decl",
+ "external-general-entities",
+ "external-parameter-entities",
+ "load-external-dtd",
+ "FEATURE_SECURE_PROCESSING",
+ "ACCESS_EXTERNAL_DTD",
+ "ACCESS_EXTERNAL_SCHEMA",
+ "ACCESS_EXTERNAL_STYLESHEET",
+ "XMLConstants.ACCESS_EXTERNAL", // covers all ACCESS_EXTERNAL_* constants
+ "IS_SUPPORTING_EXTERNAL_ENTITIES",
+ "SUPPORT_DTD",
+ }
+ for _, nd := range needles {
+ if strings.Contains(argText, nd) {
+ return true
+ }
+ }
+ return false
+}
+
+// jaxbRawInputTypes are the raw byte/char/entity input types whose JAXB
+// unmarshal(...) overloads parse XML with external entities enabled by default —
+// the XXE-prone shape. Restricting the structural detector to these (rather than
+// flagging everything that is not a wrapped Source) is what keeps it FP-safe:
+// StAX readers (XMLEventReader/XMLStreamReader), javax.xml.transform.Source
+// wrappers, and DOM nodes are the application's own XML pipeline and are never
+// flagged here. These are the exact parameter types of the entity-resolving
+// Unmarshaller.unmarshal overloads.
+var jaxbRawInputTypes = map[string]bool{
+ "InputStream": true,
+ "File": true,
+ "Reader": true,
+ "InputSource": true,
+}
+
+// jaxbRawInputMethods are calls whose result is a raw entity input stream/reader
+// (servlet/request accessors), recognized when used inline as the unmarshal
+// argument: unmarshal(request.getInputStream()). Deliberately excludes
+// createXMLEventReader/createXMLStreamReader (StAX readers) so the defensive-
+// factory idiom is not flagged.
+var jaxbRawInputMethods = map[string]bool{
+ "getInputStream": true,
+ "getReader": true,
+}
+
+// scopeEstablishesJAXBUnmarshaller reports whether the given method/constructor
+// scope constructs a JAXBContext or otherwise establishes a JAXB Unmarshaller,
+// which anchors a bare `.unmarshal(...)` call as genuinely JAXB (not Jackson's
+// XmlMapper, a custom DTO mapper, or some other unrelated unmarshal). Recognized
+// signals: `JAXBContext.newInstance(...)`, a `.createUnmarshaller()` call, or a
+// local/parameter typed `Unmarshaller`/`JAXBContext`.
+func scopeEstablishesJAXBUnmarshaller(scope *ast.Node) bool {
+ found := false
+ scope.Walk(func(n *ast.Node) bool {
+ if found {
+ return false
+ }
+ switch n.Type() {
+ case "method_invocation":
+ m := javaMethodName(n)
+ if m == "createUnmarshaller" {
+ found = true
+ return false
+ }
+ // JAXBContext.newInstance(...) — object is the JAXBContext identifier.
+ if m == "newInstance" {
+ if obj := n.ChildByFieldName("object"); obj != nil &&
+ obj.Type() == "identifier" && obj.Text() == "JAXBContext" {
+ found = true
+ return false
+ }
+ }
+ case "type_identifier":
+ if t := n.Text(); t == "Unmarshaller" || t == "JAXBContext" {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// jaxbUnmarshalArgIsRawInput reports whether the first argument of a JAXB
+// unmarshal call is positively resolvable to a raw byte/char/entity input
+// (InputStream/File/Reader/InputSource) — the XXE-prone shape. It returns false
+// (do not flag) for StAX readers, wrapped Sources, DOM nodes, and any argument
+// whose type cannot be determined, keeping the detector conservative.
+//
+// Recognized raw shapes:
+// - direct construction: unmarshal(new FileInputStream(...)) / new InputSource(...)
+// - identifier resolved (via local-var declared type, local-var constructor
+// initializer, or formal-parameter type) to a raw input type
+// - inline raw accessor: unmarshal(request.getInputStream()) / getReader()
+func jaxbUnmarshalArgIsRawInput(n, scope *ast.Node) bool {
+ arg := firstCallArg(n)
+ if arg == nil {
+ return false
+ }
+ // Unwrap a leading cast / parenthesization: `(InputStream) x`, `(in)`.
+ arg = unwrapCastParen(arg)
+ switch arg.Type() {
+ case "object_creation_expression":
+ return jaxbRawInputTypes[jaxbBaseInputType(javaConstructedType(arg))]
+ case "identifier":
+ return jaxbRawInputTypes[resolveJaxbArgType(n, scope, arg.Text())]
+ case "method_invocation":
+ return jaxbRawInputMethods[javaMethodName(arg)]
+ }
+ return false
+}
+
+// resolveJaxbArgType resolves the (last-segment, base-normalized) declared type
+// of an identifier used as a JAXB unmarshal argument: first a local variable
+// declaration in the scope (by declared type, else by its constructor
+// initializer), then a formal parameter of the enclosing method. Returns "" when
+// the type cannot be determined, which the caller treats as not-raw (no flag).
+func resolveJaxbArgType(n, scope *ast.Node, name string) string {
+ if t := scopeLocalVarType(scope, name); t != "" {
+ return t
+ }
+ return enclosingParamType(n, name)
+}
+
+// scopeLocalVarType returns the base input type of a local variable `name`
+// declared in the scope — from its declared type (`InputStream in = ...`) or,
+// when the declaration uses `var`, from its constructor initializer
+// (`var in = new FileInputStream(...)`). Returns "" if not found.
+func scopeLocalVarType(scope *ast.Node, name string) string {
+ result := ""
+ scope.Walk(func(n *ast.Node) bool {
+ if result != "" {
+ return false
+ }
+ if n.Type() != "local_variable_declaration" {
+ return true
+ }
+ declType := ""
+ if t := n.ChildByFieldName("type"); t != nil {
+ declType = lastDottedSegment(strings.TrimSpace(t.Text()))
+ }
+ for _, d := range n.NamedChildren() {
+ if d.Type() != "variable_declarator" {
+ continue
+ }
+ nameNode := d.ChildByFieldName("name")
+ if nameNode == nil || nameNode.Text() != name {
+ continue
+ }
+ if declType != "" && declType != "var" {
+ result = jaxbBaseInputType(declType)
+ return false
+ }
+ // `var x = new FileInputStream(...)` — take the constructed type.
+ if val := d.ChildByFieldName("value"); val != nil {
+ v := unwrapCastParen(val)
+ if v.Type() == "object_creation_expression" {
+ result = jaxbBaseInputType(javaConstructedType(v))
+ return false
+ }
+ }
+ }
+ return true
+ })
+ return result
+}
+
+// enclosingParamType returns the base input type of a formal parameter named
+// `name` on the method/constructor enclosing `n`, or "" if none. This catches
+// the Spring shape `unmarshal(Source source)` (param typed Source -> not raw ->
+// not flagged) as well as `unmarshal(InputStream in)` directly on a parameter.
+func enclosingParamType(n *ast.Node, name string) string {
+ var params *ast.Node
+ for p := n.Parent(); p != nil; p = p.Parent() {
+ if p.Type() == "method_declaration" || p.Type() == "constructor_declaration" {
+ params = p.ChildByFieldName("parameters")
+ if params == nil {
+ params = findChild(p, "formal_parameters")
+ }
+ }
+ if params != nil {
+ break
+ }
+ }
+ if params == nil {
+ return ""
+ }
+ for _, fp := range params.NamedChildren() {
+ if fp.Type() != "formal_parameter" {
+ continue
+ }
+ nameNode := fp.ChildByFieldName("name")
+ if nameNode == nil || nameNode.Text() != name {
+ continue
+ }
+ if t := fp.ChildByFieldName("type"); t != nil {
+ return jaxbBaseInputType(lastDottedSegment(strings.TrimSpace(t.Text())))
+ }
+ }
+ return ""
+}
+
+// jaxbBaseInputType normalizes a concrete raw-input subclass to its JAXB-overload
+// base type so the allowlist stays small but covers the common concrete forms:
+// FileInputStream/ByteArrayInputStream/... -> InputStream, FileReader/
+// BufferedReader/... -> Reader. Names not recognized as a raw subclass are
+// returned unchanged (so StAX readers, Sources, etc. fall through to not-raw).
+func jaxbBaseInputType(t string) string {
+ switch {
+ case strings.HasSuffix(t, "InputStream"):
+ return "InputStream"
+ case t == "File":
+ return "File"
+ case t == "InputSource":
+ return "InputSource"
+ case t == "XMLStreamReader" || t == "XMLEventReader":
+ // StAX readers are explicitly NOT raw inputs — return unchanged so they
+ // fall through to not-raw (no flag).
+ return t
+ case strings.HasSuffix(t, "Reader"):
+ // Raw character readers (Reader/BufferedReader/FileReader/...).
+ return "Reader"
+ }
+ return t
+}
+
+// unwrapCastParen strips leading cast_expression / parenthesized_expression
+// layers to reach the underlying value node.
+func unwrapCastParen(n *ast.Node) *ast.Node {
+ for n != nil {
+ switch n.Type() {
+ case "parenthesized_expression":
+ if inner := firstNamedChild(n); inner != nil {
+ n = inner
+ continue
+ }
+ case "cast_expression":
+ if v := n.ChildByFieldName("value"); v != nil {
+ n = v
+ continue
+ }
+ }
+ return n
+ }
+ return n
+}
+
+// lastDottedSegment returns the final segment of a possibly-qualified, possibly-
+// generic type name: "javax.xml.transform.Source" -> "Source", "List" ->
+// "List".
+func lastDottedSegment(text string) string {
+ if i := strings.IndexByte(text, '<'); i >= 0 {
+ text = text[:i]
+ }
+ if i := strings.LastIndexByte(text, '.'); i >= 0 {
+ text = text[i+1:]
+ }
+ return strings.TrimSpace(text)
+}
+
+// isServletWriterReceiver reports whether obj is (or derives from) a servlet
+// response writer/output stream — the strongest structural shape being an
+// inline getWriter()/getOutputStream() invocation:
+// response.getWriter().print(...).
+func isServletWriterReceiver(obj *ast.Node) bool {
+ if obj == nil {
+ return false
+ }
+ // response.getWriter().X(...) / response.getOutputStream().X(...)
+ if obj.Type() == "method_invocation" {
+ m := javaMethodName(obj)
+ return m == "getWriter" || m == "getOutputStream"
+ }
+ return false
+}
+
+// htmlEncoderNeedles are textual markers of HTML/JS output encoders that
+// neutralize XSS — kept in sync with the Java sanitizer catalog
+// (java_sanitizers.go).
+var htmlEncoderNeedles = []string{
+ "htmlEscape", "escapeHtml", "escapeHtml4", "escapeXml",
+ "forHtml", "forHtmlContent", "forHtmlAttribute", "forJavaScript",
+ "encodeForHTML", "encodeForJavaScript", "encodeForHTMLAttribute",
+ "Jsoup.clean", "javaScriptEscape", "URLEncoder.encode",
+ "StringEscapeUtils", "HtmlUtils",
+}
+
+func textHasHTMLEncoder(s string) bool {
+ for _, nd := range htmlEncoderNeedles {
+ if strings.Contains(s, nd) {
+ return true
+ }
+ }
+ return false
+}
+
+// identifierIsHTMLSanitized reports whether, within scope, the identifier `name`
+// is defined from an HTML-encoded value. Two cases are recognized:
+//
+// 1. Direct: `name = ...htmlEscape(param)...` (encoder call in the RHS text).
+// 2. Helper-return: `name = helper.foo(...)` where the helper method `foo`
+// (located anywhere in the compilation unit) returns an HTML-encoded value.
+//
+// This keeps the AST XSS detector as precise as the taint engine on sanitized
+// flows (incl. the inner-class helper-return shape) without re-implementing full
+// dataflow.
+func (c *javaChecker) identifierIsHTMLSanitized(scope *ast.Node, name string) bool {
+ sanitized := false
+ scope.Walk(func(n *ast.Node) bool {
+ if sanitized {
+ return false
+ }
+ // Match `name = ` in a variable_declarator or assignment.
+ var rhs *ast.Node
+ switch n.Type() {
+ case "variable_declarator":
+ if nm := n.ChildByFieldName("name"); nm != nil && nm.Text() == name {
+ rhs = n.ChildByFieldName("value")
+ }
+ case "assignment_expression":
+ if lhs := n.ChildByFieldName("left"); lhs != nil && lhs.Text() == name {
+ rhs = n.ChildByFieldName("right")
+ }
+ }
+ if rhs == nil {
+ return true
+ }
+ // Case 1: encoder applied directly in the RHS.
+ if textHasHTMLEncoder(rhs.Text()) {
+ sanitized = true
+ return false
+ }
+ // Case 2: RHS is a helper invocation that returns an encoded value.
+ if rhs.Type() == "method_invocation" {
+ if c.helperReturnsHTMLEncoded(javaMethodName(rhs)) {
+ sanitized = true
+ return false
+ }
+ }
+ return true
+ })
+ return sanitized
+}
+
+// helperReturnsHTMLEncoded reports whether a method named `methodName`, defined
+// anywhere in the current compilation unit, returns a value produced by an HTML
+// encoder. Conservative textual scan of the method body's return statement.
+func (c *javaChecker) helperReturnsHTMLEncoded(methodName string) bool {
+ if methodName == "" {
+ return false
+ }
+ root := c.tree.Root()
+ if root == nil {
+ return false
+ }
+ found := false
+ root.Walk(func(n *ast.Node) bool {
+ if found {
+ return false
+ }
+ if n.Type() != "method_declaration" {
+ return true
+ }
+ nm := n.ChildByFieldName("name")
+ if nm == nil || nm.Text() != methodName {
+ return true
+ }
+ body := n.ChildByFieldName("body")
+ if body == nil {
+ return true
+ }
+ // Collect variables assigned from an encoder in the body, then check if
+ // any return statement returns such a variable (or the encoder directly).
+ encodedVars := map[string]bool{}
+ body.Walk(func(m *ast.Node) bool {
+ if m.Type() == "variable_declarator" {
+ if vn := m.ChildByFieldName("name"); vn != nil {
+ if val := m.ChildByFieldName("value"); val != nil && textHasHTMLEncoder(val.Text()) {
+ encodedVars[vn.Text()] = true
+ }
+ }
+ }
+ if m.Type() == "return_statement" {
+ rt := m.Text()
+ if textHasHTMLEncoder(rt) {
+ found = true
+ return false
+ }
+ for _, child := range m.NamedChildren() {
+ if child.Type() == "identifier" && encodedVars[child.Text()] {
+ found = true
+ return false
+ }
+ }
+ }
+ return true
+ })
+ return false // don't descend further into this method
+ })
+ return found
+}
+
+// servletRequestGetters are the HttpServletRequest accessors that return
+// attacker-controlled data. Matched by method name only (the AST tier has no
+// type information); the names are distinctive enough that a collision with a
+// non-servlet receiver is unlikely, and the call is only consulted underneath
+// a confirmed servlet writer sink.
+var servletRequestGetters = map[string]bool{
+ "getParameter": true,
+ "getParameterValues": true,
+ "getParameterNames": true,
+ "getParameterMap": true,
+ "getHeader": true,
+ "getHeaders": true,
+ "getHeaderNames": true,
+ "getCookies": true,
+ "getQueryString": true,
+ "getPathInfo": true,
+ "getRequestURI": true,
+ "getRequestURL": true,
+ "getRemoteUser": true,
+ "getReader": true,
+ "getInputStream": true,
+ "getPart": true,
+ "getParts": true,
+}
+
+// exprIsRequestDerived reports whether an expression written to a servlet
+// writer provably derives from request input. It recognises: a request getter
+// called inline, an identifier whose assignment chain (bare-identifier hops
+// only) reaches a request getter, a method call whose receiver chain derives
+// from one (param.toCharArray()), string concatenation with a derived operand,
+// and parameter names of a method that also takes servlet request/response
+// objects (the delegated-writer shape: handle(HttpServletResponse resp,
+// String name)). Anything it cannot resolve — collection reads, helper
+// returns, computed values — is treated as NOT derived: those flows belong to
+// the taint engine, which models them precisely.
+func (c *javaChecker) exprIsRequestDerived(scope, sink, expr *ast.Node, depth int) bool {
+ if expr == nil || depth > 6 {
+ return false
+ }
+ switch expr.Type() {
+ case "method_invocation":
+ if servletRequestGetters[javaMethodName(expr)] {
+ return true
+ }
+ // param.toCharArray(), param.toString(): a value-preserving call on a
+ // derived receiver is still the same user input.
+ if obj := expr.ChildByFieldName("object"); obj != nil {
+ return c.exprIsRequestDerived(scope, sink, obj, depth+1)
+ }
+ return false
+ case "identifier":
+ name := expr.Text()
+ if scope != nil && c.identifierChainReachesRequest(scope, name, depth) {
+ return true
+ }
+ return c.isServletMethodParam(sink, name)
+ case "binary_expression", "parenthesized_expression", "cast_expression", "ternary_expression":
+ for _, child := range expr.NamedChildren() {
+ if c.exprIsRequestDerived(scope, sink, child, depth+1) {
+ return true
+ }
+ }
+ return false
+ }
+ return false
+}
+
+// identifierChainReachesRequest reports whether any assignment to `name`
+// inside scope resolves — through bare-identifier hops only — to a servlet
+// request getter. A RHS the walk cannot follow (collection get, helper call,
+// arithmetic) breaks the chain: the laundering may or may not preserve taint,
+// and that judgement belongs to the taint engine.
+func (c *javaChecker) identifierChainReachesRequest(scope *ast.Node, name string, depth int) bool {
+ if depth > 6 {
+ return false
+ }
+ derived := false
+ scope.Walk(func(n *ast.Node) bool {
+ if derived {
+ return false
+ }
+ var rhs *ast.Node
+ switch n.Type() {
+ case "variable_declarator":
+ if nm := n.ChildByFieldName("name"); nm != nil && nm.Text() == name {
+ rhs = n.ChildByFieldName("value")
+ }
+ case "assignment_expression":
+ if lhs := n.ChildByFieldName("left"); lhs != nil && lhs.Text() == name {
+ rhs = n.ChildByFieldName("right")
+ }
+ }
+ if rhs == nil {
+ return true
+ }
+ switch rhs.Type() {
+ case "method_invocation":
+ if servletRequestGetters[javaMethodName(rhs)] {
+ derived = true
+ return false
+ }
+ case "identifier":
+ if rhs.Text() != name && c.identifierChainReachesRequest(scope, rhs.Text(), depth+1) {
+ derived = true
+ return false
+ }
+ }
+ return true
+ })
+ return derived
+}
+
+// isServletMethodParam reports whether `name` is a formal parameter of the
+// method enclosing the sink AND that method also takes a servlet
+// request/response object. A String parameter of a servlet handler is
+// plausibly user input forwarded by the caller (the delegated-writer shape);
+// parameters of unrelated methods are not.
+func (c *javaChecker) isServletMethodParam(sink *ast.Node, name string) bool {
+ var method *ast.Node
+ for p := sink.Parent(); p != nil; p = p.Parent() {
+ if p.Type() == "method_declaration" || p.Type() == "constructor_declaration" {
+ method = p
+ break
+ }
+ }
+ if method == nil {
+ return false
+ }
+ params := method.ChildByFieldName("parameters")
+ if params == nil {
+ return false
+ }
+ hasServletParam := false
+ isParam := false
+ for _, p := range params.NamedChildren() {
+ if p.Type() != "formal_parameter" && p.Type() != "spread_parameter" {
+ continue
+ }
+ if tn := p.ChildByFieldName("type"); tn != nil && strings.Contains(tn.Text(), "Servlet") {
+ hasServletParam = true
+ }
+ if nm := p.ChildByFieldName("name"); nm != nil && nm.Text() == name {
+ isParam = true
+ }
+ }
+ return hasServletParam && isParam
+}
+
+// javaConstructedType returns the constructed type name of an
+// object_creation_expression (`new Foo(...)` -> "Foo"). Handles both bare
+// type_identifier and dotted/generic scoped_type_identifier by taking the last
+// segment.
+func javaConstructedType(n *ast.Node) string {
+ if n == nil || n.Type() != "object_creation_expression" {
+ return ""
+ }
+ t := n.ChildByFieldName("type")
+ if t == nil {
+ t = findChild(n, "type_identifier")
+ }
+ if t == nil {
+ return ""
+ }
+ text := strings.TrimSpace(t.Text())
+ // Strip generics: "URL<...>" -> "URL".
+ if i := strings.IndexByte(text, '<'); i >= 0 {
+ text = text[:i]
+ }
+ // Take last dotted segment: "java.net.URL" -> "URL".
+ if i := strings.LastIndexByte(text, '.'); i >= 0 {
+ text = text[i+1:]
+ }
+ return strings.TrimSpace(text)
+}
diff --git a/batou-core/analyzer/javaast/javaast_processbuilder_test.go b/batou-core/analyzer/javaast/javaast_processbuilder_test.go
new file mode 100644
index 0000000..c32fe23
--- /dev/null
+++ b/batou-core/analyzer/javaast/javaast_processbuilder_test.go
@@ -0,0 +1,105 @@
+package javaast
+
+import "testing"
+
+// TestProcessBuilderConstructorCommandInjection covers the construction-side
+// command-injection sink that the javaast analyzer previously left uncovered:
+// `new ProcessBuilder(...)` whose command list contains a string concatenation
+// embedding a non-literal value. Before the fix, only Runtime.exec() was wired,
+// so the idiomatic ping helper pattern (controller @RequestParam -> private
+// helper -> ProcessBuilder over a concatenated argument, as in
+// SasanLabs/VulnerableApp CommandInjection.java) produced no AST-tier finding.
+//
+// The check is deliberately scoped to the in-constructor concatenation form.
+// A bare variable / List argument is NOT flagged structurally (the OWASP
+// Benchmark cmdi corpus uses `pb.command(argList)` / `new ProcessBuilder(list)`
+// for BOTH its vulnerable and its safe cases, so firing on the bare-list shape
+// floods false positives) — those are left to the dataflow tier. The negative
+// assertions below pin that scope so it cannot silently widen.
+func TestProcessBuilderConstructorCommandInjection(t *testing.T) {
+ // POSITIVE — VulnerableApp idiom: a concatenation inside the String[] passed
+ // to the ProcessBuilder constructor, in a helper whose argument is tainted.
+ vulnArray := `
+class Handler {
+ StringBuilder run(String ipAddress) throws Exception {
+ Process process =
+ new ProcessBuilder(new String[] {"sh", "-c", "ping -c 2 " + ipAddress})
+ .redirectErrorStream(true)
+ .start();
+ return new StringBuilder();
+ }
+}
+`
+ if f := findByRule(scanJava(vulnArray), "BATOU-JAVAAST-002"); f == nil {
+ t.Fatalf("expected BATOU-JAVAAST-002 for ProcessBuilder over String[] concat, got none")
+ } else if f.CWEID != "CWE-78" {
+ t.Fatalf("expected CWE-78, got %q", f.CWEID)
+ }
+
+ // POSITIVE — varargs form with a concatenated argument.
+ vulnVarargs := `
+class Handler {
+ void run(String ip) throws Exception {
+ new ProcessBuilder("sh", "-c", "ping " + ip).start();
+ }
+}
+`
+ if findByRule(scanJava(vulnVarargs), "BATOU-JAVAAST-002") == nil {
+ t.Fatalf("expected BATOU-JAVAAST-002 for ProcessBuilder varargs concat, got none")
+ }
+
+ // NEGATIVE — an all-literal command list has no injection vector and must NOT
+ // fire. This is what keeps the construction check from flooding on benign
+ // fixed-command ProcessBuilder usage.
+ safeLiterals := `
+class Handler {
+ void run() throws Exception {
+ new ProcessBuilder("ls", "-l").start();
+ }
+}
+`
+ if f := findByRule(scanJava(safeLiterals), "BATOU-JAVAAST-002"); f != nil {
+ t.Fatalf("did not expect BATOU-JAVAAST-002 for an all-literal ProcessBuilder, got line %d", f.LineNumber)
+ }
+
+ // NEGATIVE — array of only string literals — also safe.
+ safeArray := `
+class Handler {
+ void run() throws Exception {
+ new ProcessBuilder(new String[] {"ls", "-l", "/tmp"}).start();
+ }
+}
+`
+ if f := findByRule(scanJava(safeArray), "BATOU-JAVAAST-002"); f != nil {
+ t.Fatalf("did not expect BATOU-JAVAAST-002 for an all-literal String[] ProcessBuilder, got line %d", f.LineNumber)
+ }
+
+ // NEGATIVE (scope pin) — a bare List variable passed to the constructor is
+ // intentionally NOT flagged structurally; whether the list is tainted is a
+ // dataflow question. This mirrors the OWASP cmdi safe-case idiom and must
+ // stay out of the AST tier to avoid a false-positive flood.
+ bareList := `
+class Handler {
+ void run(java.util.List argList) throws Exception {
+ new ProcessBuilder(argList).start();
+ }
+}
+`
+ if f := findByRule(scanJava(bareList), "BATOU-JAVAAST-002"); f != nil {
+ t.Fatalf("did not expect BATOU-JAVAAST-002 for a bare-List ProcessBuilder (dataflow-tier concern), got line %d", f.LineNumber)
+ }
+
+ // NEGATIVE (scope pin) — pb.command(list) re-set is likewise left to dataflow.
+ commandList := `
+class Handler {
+ void run(java.util.List argList) throws Exception {
+ ProcessBuilder pb = new ProcessBuilder();
+ pb.command(argList);
+ pb.start();
+ }
+}
+`
+ if f := findByRule(scanJava(commandList), "BATOU-JAVAAST-002"); f != nil {
+ t.Fatalf("did not expect BATOU-JAVAAST-002 for pb.command(list) (dataflow-tier concern), got line %d", f.LineNumber)
+ }
+}
diff --git a/batou-core/analyzer/javaast/javaast_test.go b/batou-core/analyzer/javaast/javaast_test.go
index 09fc89d..150bb59 100644
--- a/batou-core/analyzer/javaast/javaast_test.go
+++ b/batou-core/analyzer/javaast/javaast_test.go
@@ -1,10 +1,9 @@
package javaast
import (
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "testing"
)
func scanJava(code string) []rules.Finding {
@@ -158,6 +157,67 @@ class Handler {
}
}
+// TestNonJNDILookupReceiverDoesNotFire guards against the high-volume FP
+// shape observed in real-world OSS scans: enum-style static lookup tables
+// (Bouncy Castle CRLReason.lookup, ECNamedDomainParameters.lookup), Apache
+// StrLookup.lookup, Spring's SpringEnvironmentLookup.lookup, and other
+// project-internal lookup() methods on non-JNDI receivers.
+func TestNonJNDILookupReceiverDoesNotFire(t *testing.T) {
+ cases := []struct {
+ name string
+ code string
+ }{
+ {"BouncyCastle CRLReason enum lookup", `
+class Reason {
+ static int lookup(int code) { return code; }
+ void use(int code) { CRLReason.lookup(code); }
+}`},
+ {"BouncyCastle EC named parameters lookup", `
+class Curve {
+ void use(String oid) { ECNamedDomainParameters.lookup(oid); }
+}`},
+ {"Spring SpringEnvironmentLookup", `
+class L {
+ String lookup(String key) { return resolver.resolve(key); }
+}`},
+ {"Apache StrLookup", `
+class L extends StrLookup {
+ public String lookup(String key) { return values.get(key); }
+}`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, f := range scanJava(tc.code) {
+ if f.RuleID == "BATOU-JAVAAST-004" {
+ t.Errorf("should NOT flag non-JNDI lookup() in %s: %s",
+ tc.name, f.MatchedText)
+ }
+ }
+ })
+ }
+}
+
+// TestJNDILookupOnAdjacentReceivers covers the receivers we DO want to fire
+// on: the conventional ctx/context/jndi* names plus log4j2's JndiManager.
+func TestJNDILookupOnAdjacentReceivers(t *testing.T) {
+ cases := []string{
+ `class H { void f(String n) throws Exception { ctx.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { context.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { initialContext.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { ictx.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { jndiManager.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { jndiTemplate.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { dirContext.lookup(n); } }`,
+ `class H { void f(String n) throws Exception { ldapContext.lookup(n); } }`,
+ }
+ for _, code := range cases {
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-004") == nil {
+ t.Errorf("expected JNDI finding for %q", code)
+ }
+ }
+}
+
func TestClassForName(t *testing.T) {
code := `
class Handler {
@@ -192,6 +252,660 @@ class Handler {
}
}
+// --- XXE factory-misconfig (CWE-611, BATOU-JAVAAST-006) ---
+
+func TestXXEUnhardenedFactory(t *testing.T) {
+ code := `
+class Handler {
+ void parseXml(String xml) throws Exception {
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ db.parse(xml);
+ }
+}
+`
+ findings := scanJava(code)
+ f := findByRule(findings, "BATOU-JAVAAST-006")
+ if f == nil {
+ t.Error("expected XXE finding for unhardened DocumentBuilderFactory + parse")
+ for _, f := range findings {
+ t.Logf(" %s: %s (line %d)", f.RuleID, f.Title, f.LineNumber)
+ }
+ return
+ }
+ if f.CWEID != "CWE-611" {
+ t.Errorf("expected CWE-611, got %s", f.CWEID)
+ }
+}
+
+func TestXXEHardenedFactorySafe(t *testing.T) {
+ code := `
+class Handler {
+ void parseXml(String xml) throws Exception {
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ db.parse(xml);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag hardened factory: %s", f.MatchedText)
+ }
+ }
+}
+
+func TestXXESecureProcessingSafe(t *testing.T) {
+ code := `
+class Handler {
+ void parseXml(String xml) throws Exception {
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ dbf.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ db.parse(xml);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag FEATURE_SECURE_PROCESSING factory: %s", f.MatchedText)
+ }
+ }
+}
+
+func TestXXENoFactoryNoFire(t *testing.T) {
+ // A bare parse()/build()/read() with no XML factory in scope must not fire
+ // (avoid colliding with JSON parsers, number parsing, file builders).
+ code := `
+class Handler {
+ void parse(String s) {
+ Integer.parseInt(s);
+ gson.parse(s);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag non-XML parse without factory: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXEConditionalHardeningBypass is the load-bearing test for the WebGoat
+// CommentsCache shape: the hardening calls live inside an `if (securityEnabled)`
+// block while the parse runs unconditionally below it. The toggle defaults off,
+// so the hardening is dead code on the parse path — this must STILL fire CWE-611
+// (it was previously a false negative because the textual hardening scan ignored
+// control flow).
+func TestXXEConditionalHardeningBypass(t *testing.T) {
+ code := `
+class Handler {
+ Object parseXml(String xml, boolean securityEnabled) throws Exception {
+ javax.xml.stream.XMLInputFactory xif = javax.xml.stream.XMLInputFactory.newInstance();
+ if (securityEnabled) {
+ xif.setProperty(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ xif.setProperty(javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+ }
+ return xif.createXMLStreamReader(new java.io.StringReader(xml));
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-006")
+ if f == nil {
+ t.Fatal("expected XXE finding: hardening gated behind if(securityEnabled) does not protect the unconditional parse")
+ }
+ if f.CWEID != "CWE-611" {
+ t.Errorf("expected CWE-611, got %s", f.CWEID)
+ }
+}
+
+// TestXXEHardeningInSameBranchSafe is the FP-guard negative: when the parse call
+// is INSIDE the same branch as the hardening, the hardening does protect it, so
+// no finding. This keeps the dominance check from over-firing on legitimately
+// hardened-then-parsed code that happens to be conditional.
+func TestXXEHardeningInSameBranchSafe(t *testing.T) {
+ code := `
+class Handler {
+ void parseXml(String xml, boolean cond) throws Exception {
+ if (cond) {
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ db.parse(xml);
+ }
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag: hardening and parse are in the same branch: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXEUnconditionalHardeningSafe re-asserts that top-level (unconditional)
+// hardening above a parse keeps suppressing the finding — the dominance check
+// must not regress the plain hardened case.
+func TestXXEUnconditionalHardeningSafe(t *testing.T) {
+ code := `
+class Handler {
+ Object parseXml(String xml) throws Exception {
+ javax.xml.stream.XMLInputFactory xif = javax.xml.stream.XMLInputFactory.newInstance();
+ xif.setProperty(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ xif.setProperty(javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+ return xif.createXMLStreamReader(new java.io.StringReader(xml));
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag unconditionally hardened factory: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXELazyInitCacheGuardSafe is the FP-guard for the build-once-and-cache
+// idiom (Spring's SourceHttpMessageConverter): the factory is built and hardened
+// inside `if (factory == null)`, cached to a field, and parsed below. The
+// hardening is gated behind a NULL check, not a security toggle — every parse
+// uses a hardened factory — so this must NOT fire (no false positive). This is
+// the precise shape the dominance check must exclude to avoid regressing secure
+// real-world code while still catching the `if (securityEnabled)` evasion.
+func TestXXELazyInitCacheGuardSafe(t *testing.T) {
+ code := `
+class Handler {
+ private volatile DocumentBuilderFactory dbf;
+ Object parseXml(java.io.InputStream body) throws Exception {
+ DocumentBuilderFactory factory = this.dbf;
+ if (factory == null) {
+ factory = DocumentBuilderFactory.newInstance();
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ this.dbf = factory;
+ }
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ return builder.parse(body);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag lazy-init/cache-guard hardened factory: %s", f.MatchedText)
+ }
+ }
+}
+
+// --- JAXB Unmarshaller XXE (CWE-611, BATOU-JAVAAST-006) ---
+
+// TestXXEJAXBUnmarshalRawStream is the load-bearing positive: a vanilla JAXB
+// Unmarshaller unmarshalling a raw request InputStream with no factory and no
+// hardening in scope (SasanLabs VulnerableApp's getVulnerablePayloadLevel1).
+// JAXB resolves external entities by default, so this is CWE-611. Baseline
+// missed it because no JAXP factory is constructed in scope.
+func TestXXEJAXBUnmarshalRawStream(t *testing.T) {
+ code := `
+class XXEVulnerability {
+ Object getVulnerablePayloadLevel1(HttpServletRequest request) throws Exception {
+ InputStream in = request.getInputStream();
+ JAXBContext jc = JAXBContext.newInstance(ObjectFactory.class);
+ Unmarshaller jaxbUnmarshaller = jc.createUnmarshaller();
+ JAXBElement el = (JAXBElement) (jaxbUnmarshaller.unmarshal(in));
+ return el.getValue();
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-006")
+ if f == nil {
+ t.Fatal("expected CWE-611 for JAXB unmarshal of a raw InputStream with no hardening")
+ }
+ if f.CWEID != "CWE-611" {
+ t.Errorf("expected CWE-611, got %s", f.CWEID)
+ }
+}
+
+// TestXXEJAXBUnmarshalHardenedSAXSourceSafe is the load-bearing FP-guard: the
+// secure VulnerableApp shape (saveJaxBBasedBookInformation) unmarshals a
+// SAXSource the developer explicitly built, rather than a raw stream. This must
+// NOT fire — the structural tier stays conservative on wrapped Sources.
+func TestXXEJAXBUnmarshalHardenedSAXSourceSafe(t *testing.T) {
+ code := `
+class XXEVulnerability {
+ Object saveJaxBBasedBookInformation(SAXParserFactory spf, InputStream in) throws Exception {
+ JAXBContext jc = JAXBContext.newInstance(ObjectFactory.class);
+ Source xmlSource = new SAXSource(spf.newSAXParser().getXMLReader(), new InputSource(in));
+ Unmarshaller jaxbUnmarshaller = jc.createUnmarshaller();
+ JAXBElement el = (JAXBElement) (jaxbUnmarshaller.unmarshal(xmlSource));
+ return el.getValue();
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag JAXB unmarshal of an explicitly-built SAXSource: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXEJAXBUnmarshalInScopeHardeningSafe verifies the reused control-flow-aware
+// hardening check also covers JAXB: a raw stream is unmarshalled, but the scope
+// hardens a SAXParserFactory with disallow-doctype-decl before the call, so the
+// parser is genuinely hardened — must NOT fire.
+func TestXXEJAXBUnmarshalInScopeHardeningSafe(t *testing.T) {
+ code := `
+class Handler {
+ Object handle(InputStream in) throws Exception {
+ SAXParserFactory spf = SAXParserFactory.newInstance();
+ spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ XMLReader reader = spf.newSAXParser().getXMLReader();
+ JAXBContext jc = JAXBContext.newInstance(ObjectFactory.class);
+ Unmarshaller u = jc.createUnmarshaller();
+ return u.unmarshal(new SAXSource(reader, new InputSource(in)));
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag JAXB unmarshal when the scope hardens the parser: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXEJAXBUnmarshalSourceParamSafe is the FP-guard for the Spring-like shape
+// where createUnmarshaller() and unmarshal(source) are co-located in one method
+// but the argument is a method parameter typed javax.xml.transform.Source (a
+// pre-built Source the caller controls, not a raw stream). Must NOT fire.
+func TestXXEJAXBUnmarshalSourceParamSafe(t *testing.T) {
+ code := `
+class Jaxb2Marshaller {
+ Object unmarshal(Source source) throws Exception {
+ Unmarshaller unmarshaller = getJaxbContext().createUnmarshaller();
+ return unmarshaller.unmarshal(source);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag JAXB unmarshal of a Source-typed parameter: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXEJAXBUnmarshalStaxReaderSafe is the load-bearing FP-guard for the most
+// common modern-JAXB idiom (Spring's Jaxb2XmlDecoder / Jaxb2CollectionHttp-
+// MessageConverter): the unmarshal argument is a StAX reader
+// (XMLStreamReader/XMLEventReader) produced by an XMLInputFactory, not a raw
+// stream. These are the application's own (typically hardened/defensive) XML
+// pipeline and must NOT be flagged at the structural tier — flagging them was the
+// false-positive cluster found on real spring-framework code.
+func TestXXEJAXBUnmarshalStaxReaderSafe(t *testing.T) {
+ code := `
+class Jaxb2CollectionHttpMessageConverter {
+ Object readFromSource(Class clazz, XMLStreamReader streamReader) throws Exception {
+ Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
+ return unmarshaller.unmarshal(streamReader, clazz).getValue();
+ }
+ Object decode(XMLEventReader eventReader) throws Exception {
+ Unmarshaller unmarshaller = getJaxbContext().createUnmarshaller();
+ return unmarshaller.unmarshal(eventReader);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag JAXB unmarshal of a StAX reader: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXEJAXBUnmarshalStaxLocalVarSafe covers the same StAX idiom where the
+// reader is a local variable built inline from a defensive XMLInputFactory.
+func TestXXEJAXBUnmarshalStaxLocalVarSafe(t *testing.T) {
+ code := `
+class Jaxb2XmlDecoder {
+ Object decodeStream(InputStream body) throws Exception {
+ Unmarshaller unmarshaller = getJaxbContext().createUnmarshaller();
+ XMLStreamReader streamReader = inputFactory.createXMLStreamReader(body);
+ return unmarshaller.unmarshal(streamReader);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag JAXB unmarshal of a StAX reader from a factory: %s", f.MatchedText)
+ }
+ }
+}
+
+// TestXXENonJAXBUnmarshalNoFire guards against over-firing on a non-JAXB
+// `.unmarshal(...)` (e.g. Jackson XmlMapper) where no JAXBContext/Unmarshaller is
+// established in scope — must NOT fire (prior behavior unchanged).
+func TestXXENonJAXBUnmarshalNoFire(t *testing.T) {
+ code := `
+class Handler {
+ Object handle(InputStream in) throws Exception {
+ XmlMapper mapper = new XmlMapper();
+ return mapper.unmarshal(in);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-006" {
+ t.Errorf("should NOT flag a non-JAXB unmarshal with no JAXBContext in scope: %s", f.MatchedText)
+ }
+ }
+}
+
+// --- Reflected XSS (CWE-79, BATOU-JAVAAST-007) ---
+
+func TestReflectedXSS(t *testing.T) {
+ code := `
+class Handler {
+ void handle(HttpServletResponse resp, String name) throws Exception {
+ resp.getWriter().print(name);
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-007")
+ if f == nil {
+ t.Error("expected reflected XSS finding for getWriter().print(var)")
+ } else if f.CWEID != "CWE-79" {
+ t.Errorf("expected CWE-79, got %s", f.CWEID)
+ }
+}
+
+func TestReflectedXSSLiteralSafe(t *testing.T) {
+ code := `
+class Handler {
+ void handle(HttpServletResponse resp) throws Exception {
+ resp.getWriter().print("
Hello
");
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-007" {
+ t.Errorf("should NOT flag literal print: %s", f.MatchedText)
+ }
+ }
+}
+
+func TestReflectedXSSEncodedSafe(t *testing.T) {
+ code := `
+class Handler {
+ void handle(HttpServletResponse resp, String name) throws Exception {
+ String safe = org.springframework.web.util.HtmlUtils.htmlEscape(name);
+ resp.getWriter().print(safe);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-007" {
+ t.Errorf("should NOT flag HTML-encoded print: %s", f.MatchedText)
+ }
+ }
+}
+
+func TestReflectedXSSInlineRequestGetter(t *testing.T) {
+ code := `
+class Handler {
+ void doGet(HttpServletRequest request, HttpServletResponse response) throws Exception {
+ response.getWriter().println(request.getParameter("name"));
+ }
+}
+`
+ if f := findByRule(scanJava(code), "BATOU-JAVAAST-007"); f == nil {
+ t.Error("expected reflected XSS finding for inline request.getParameter write")
+ }
+}
+
+func TestReflectedXSSTrivialChain(t *testing.T) {
+ code := `
+class Handler {
+ void doGet(HttpServletRequest request, HttpServletResponse response) throws Exception {
+ String param = request.getHeader("Referer");
+ String bar = param;
+ response.getWriter().print(bar);
+ }
+}
+`
+ if f := findByRule(scanJava(code), "BATOU-JAVAAST-007"); f == nil {
+ t.Error("expected reflected XSS finding for identifier chain back to request.getHeader")
+ }
+}
+
+func TestReflectedXSSUnresolvableOriginNoFire(t *testing.T) {
+ // The written value comes from a collection read / helper return — the
+ // AST tier cannot prove request origin, so it must stay quiet and leave
+ // the flow decision to the taint engine (which models per-index list
+ // taint). This is the OWASP Benchmark safe-case shape that produced 113
+ // xss false positives.
+ code := `
+class Handler {
+ void doGet(HttpServletRequest request, HttpServletResponse response) throws Exception {
+ String param = request.getHeader("Referer");
+ java.util.List values = new java.util.ArrayList();
+ values.add("safe");
+ values.add(param);
+ String bar = values.get(0);
+ response.getWriter().print(bar);
+ response.getWriter().print(bar.toCharArray());
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-007" {
+ t.Errorf("should NOT flag collection-read value (taint engine's call): %s", f.MatchedText)
+ }
+ }
+}
+
+func TestReflectedXSSNonServletMethodParamNoFire(t *testing.T) {
+ // A parameter of a method with no servlet types in its signature is not
+ // presumed to be user input.
+ code := `
+class Renderer {
+ void render(java.io.PrintWriter w, String name) {
+ response.getWriter().print(name);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-007" {
+ t.Errorf("should NOT flag param of non-servlet method: %s", f.MatchedText)
+ }
+ }
+}
+
+func TestReflectedXSSNonWriterReceiverNoFire(t *testing.T) {
+ // print() on a non-servlet receiver (e.g. System.out, a logger) must not fire.
+ code := `
+class Handler {
+ void handle(String name) {
+ System.out.print(name);
+ logger.print(name);
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-007" {
+ t.Errorf("should NOT flag non-servlet print: %s", f.MatchedText)
+ }
+ }
+}
+
+// --- SSRF (CWE-918, BATOU-JAVAAST-008) ---
+
+func TestSSRFNewURL(t *testing.T) {
+ code := `
+class Handler {
+ void fetch(String target) throws Exception {
+ URL u = new URL(target);
+ u.openConnection();
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-008")
+ if f == nil {
+ t.Error("expected SSRF finding for new URL(var)")
+ } else if f.CWEID != "CWE-918" {
+ t.Errorf("expected CWE-918, got %s", f.CWEID)
+ }
+}
+
+func TestSSRFNewURLLiteralSafe(t *testing.T) {
+ code := `
+class Handler {
+ void fetch() throws Exception {
+ URL u = new URL("https://example.com/api");
+ u.openConnection();
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-008" {
+ t.Errorf("should NOT flag literal URL: %s", f.MatchedText)
+ }
+ }
+}
+
+func TestSSRFRelativeURLNoFire(t *testing.T) {
+ // new URL(base, spec) (context + relative path) is not the classic full
+ // attacker-controlled-endpoint shape; require single-arg form.
+ code := `
+class Handler {
+ void fetch(URL base, String path) throws Exception {
+ URL u = new URL(base, path);
+ u.openConnection();
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-008" {
+ t.Errorf("should NOT flag two-arg URL(base, path): %s", f.MatchedText)
+ }
+ }
+}
+
+// TestSSRFURLParseOnlyNoFire reproduces the real-world false-positive class
+// found scanning Keycloak: `new URL(var)` / `new URI(var)` is constructed only
+// to PARSE the address (getHost / getPath / redirect-allowlist comparison),
+// never to open a connection. URL construction is not SSRF — the fetch is — so
+// these must stay CLEAN. Mirrors Keycloak's StackoverflowIdentityProvider
+// (extractUsernameFromProfileURL) and PairwiseSubMapperUtils / redirect-URI
+// validators that flooded the SSRF detector before the fetch-reachability gate.
+func TestSSRFURLParseOnlyNoFire(t *testing.T) {
+ code := `
+class Handler {
+ String extractHost(String profileURL) throws Exception {
+ URL u = new URL(profileURL);
+ return u.getHost();
+ }
+ boolean validateRedirect(String redirectUri, String allowed) throws Exception {
+ URI uri = new URI(redirectUri);
+ return uri.getPath().equals(allowed);
+ }
+ String pairwiseSub(String sectorIdentifierUri) throws Exception {
+ URI uri = new URI(sectorIdentifierUri);
+ return uri.getHost();
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-008" {
+ t.Errorf("URL/URI parsed but never fetched must NOT flag SSRF: %s (line %d)", f.MatchedText, f.LineNumber)
+ }
+ }
+}
+
+// TestSSRFHttpClientSendFires is the matching true positive for the gate added
+// alongside TestSSRFURLParseOnlyNoFire: a `new URI(var)` whose value reaches an
+// HttpClient.send must STILL fire. This proves the fetch-reachability gate
+// tightened the detector rather than disabling it.
+func TestSSRFHttpClientSendFires(t *testing.T) {
+ code := `
+class Handler {
+ void fetch(String userUrl) throws Exception {
+ URI uri = new URI(userUrl);
+ HttpRequest req = HttpRequest.newBuilder().uri(uri).build();
+ client.send(req, HttpResponse.BodyHandlers.ofString());
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-008")
+ if f == nil {
+ t.Fatal("expected SSRF finding for new URI(var) reaching HttpClient.send")
+ }
+ if f.CWEID != "CWE-918" {
+ t.Errorf("expected CWE-918, got %s", f.CWEID)
+ }
+}
+
+// TestSSRFURLOpenStreamFires guards the directly-chained fetch shape
+// (new URL(var).openStream()) and the assigned-then-fetched URI shape.
+func TestSSRFURLOpenStreamFires(t *testing.T) {
+ code := `
+class Handler {
+ void download(String target) throws Exception {
+ new URL(target).openStream();
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-008")
+ if f == nil {
+ t.Fatal("expected SSRF finding for new URL(var).openStream()")
+ }
+ if f.CWEID != "CWE-918" {
+ t.Errorf("expected CWE-918, got %s", f.CWEID)
+ }
+}
+
+// --- SpEL / OGNL expression injection (CWE-917, BATOU-JAVAAST-009) ---
+
+func TestSpELParseExpression(t *testing.T) {
+ code := `
+class Handler {
+ void eval(ExpressionParser parser, String expr) {
+ parser.parseExpression(expr).getValue();
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-009")
+ if f == nil {
+ t.Error("expected SpEL injection finding for parseExpression(var)")
+ } else if f.CWEID != "CWE-917" {
+ t.Errorf("expected CWE-917, got %s", f.CWEID)
+ }
+}
+
+func TestOGNLGetValue(t *testing.T) {
+ code := `
+class Handler {
+ void eval(String expr, Object root) throws Exception {
+ Ognl.getValue(expr, root);
+ }
+}
+`
+ f := findByRule(scanJava(code), "BATOU-JAVAAST-009")
+ if f == nil {
+ t.Error("expected OGNL injection finding for Ognl.getValue(var, ...)")
+ }
+}
+
+func TestSpELLiteralSafe(t *testing.T) {
+ code := `
+class Handler {
+ void eval(ExpressionParser parser) {
+ parser.parseExpression("1 + 1").getValue();
+ }
+}
+`
+ for _, f := range scanJava(code) {
+ if f.RuleID == "BATOU-JAVAAST-009" {
+ t.Errorf("should NOT flag literal expression: %s", f.MatchedText)
+ }
+ }
+}
+
func TestNilTree(t *testing.T) {
ctx := &rules.ScanContext{
FilePath: "/app/Handler.java",
diff --git a/batou-core/analyzer/javaast/javaast_tls_test.go b/batou-core/analyzer/javaast/javaast_tls_test.go
new file mode 100644
index 0000000..aed403a
--- /dev/null
+++ b/batou-core/analyzer/javaast/javaast_tls_test.go
@@ -0,0 +1,168 @@
+package javaast
+
+import "testing"
+
+// =========================================================================
+// COVERAGE ADD (cov/java) — CWE-295 improper TLS certificate / hostname
+// validation. Structural detection: only fires on a provably permissive
+// verifier / trust-manager body. A verifier/trust-manager with real logic in
+// its body must stay clean (the precision guarantee).
+// =========================================================================
+
+// --- BATOU-JAVAAST-010: accept-all HostnameVerifier ---
+
+func TestInsecureTLS_HostnameVerifierLambdaTrue(t *testing.T) {
+ code := `
+import javax.net.ssl.HttpsURLConnection;
+public class C {
+ void f(HttpsURLConnection conn) {
+ conn.setHostnameVerifier((hostname, session) -> true);
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-010") == nil {
+ t.Error("expected BATOU-JAVAAST-010 for accept-all HostnameVerifier lambda")
+ }
+}
+
+func TestInsecureTLS_HostnameVerifierAnonClassTrue(t *testing.T) {
+ code := `
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLSession;
+public class C {
+ void f() {
+ HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
+ public boolean verify(String hostname, SSLSession session) { return true; }
+ });
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-010") == nil {
+ t.Error("expected BATOU-JAVAAST-010 for accept-all anonymous HostnameVerifier")
+ }
+}
+
+func TestInsecureTLS_OkHttpHostnameVerifierTrue(t *testing.T) {
+ code := `
+public class C {
+ void f(okhttp3.OkHttpClient.Builder builder) {
+ builder.hostnameVerifier((hostname, session) -> true);
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-010") == nil {
+ t.Error("expected BATOU-JAVAAST-010 for OkHttp accept-all hostnameVerifier")
+ }
+}
+
+// A verifier with real comparison logic must NOT fire.
+func TestInsecureTLS_HostnameVerifierWithLogicSafe(t *testing.T) {
+ code := `
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLSession;
+public class C {
+ void f(HttpsURLConnection conn) {
+ conn.setHostnameVerifier(new HostnameVerifier() {
+ public boolean verify(String hostname, SSLSession session) {
+ return hostname.equals("api.example.com");
+ }
+ });
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-010") != nil {
+ t.Error("did not expect BATOU-JAVAAST-010 for a verifier with real hostname comparison")
+ }
+}
+
+// A lambda that conditionally returns must NOT fire (body is not a bare true).
+func TestInsecureTLS_HostnameVerifierConditionalSafe(t *testing.T) {
+ code := `
+import javax.net.ssl.HttpsURLConnection;
+public class C {
+ void f(HttpsURLConnection conn) {
+ conn.setHostnameVerifier((hostname, session) -> hostname.startsWith("internal."));
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-010") != nil {
+ t.Error("did not expect BATOU-JAVAAST-010 for a conditional verifier lambda")
+ }
+}
+
+// --- BATOU-JAVAAST-011: all-trusting X509TrustManager ---
+
+func TestInsecureTLS_EmptyTrustManager(t *testing.T) {
+ code := `
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import java.security.cert.X509Certificate;
+public class C {
+ void f(SSLContext sslctx) throws Exception {
+ sslctx.init(null, new TrustManager[]{ new X509TrustManager() {
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {}
+ public X509Certificate[] getAcceptedIssuers() { return null; }
+ }}, null);
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-011") == nil {
+ t.Error("expected BATOU-JAVAAST-011 for empty-bodied X509TrustManager")
+ }
+}
+
+// A trust manager that actually validates (non-empty check bodies) must NOT fire.
+func TestInsecureTLS_ValidatingTrustManagerSafe(t *testing.T) {
+ code := `
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import java.security.cert.X509Certificate;
+import java.security.cert.CertificateException;
+public class C {
+ void f(SSLContext sslctx) throws Exception {
+ sslctx.init(null, new TrustManager[]{ new X509TrustManager() {
+ public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ if (chain == null || chain.length == 0) { throw new CertificateException("empty chain"); }
+ chain[0].checkValidity();
+ }
+ public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ chain[0].checkValidity();
+ }
+ public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ }}, null);
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-011") != nil {
+ t.Error("did not expect BATOU-JAVAAST-011 for a validating X509TrustManager")
+ }
+}
+
+// The default TrustManagerFactory path (no custom trust manager) must NOT fire.
+func TestInsecureTLS_DefaultTrustManagerSafe(t *testing.T) {
+ code := `
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+public class C {
+ void f(SSLContext sslctx, TrustManagerFactory tmf) throws Exception {
+ sslctx.init(null, tmf.getTrustManagers(), null);
+ }
+}
+`
+ findings := scanJava(code)
+ if findByRule(findings, "BATOU-JAVAAST-011") != nil {
+ t.Error("did not expect BATOU-JAVAAST-011 for the default TrustManagerFactory path")
+ }
+}
diff --git a/batou-core/analyzer/jsast/jsast.go b/batou-core/analyzer/jsast/jsast.go
index 733bef9..db9469e 100644
--- a/batou-core/analyzer/jsast/jsast.go
+++ b/batou-core/analyzer/jsast/jsast.go
@@ -1,6 +1,7 @@
package jsast
import (
+ "regexp"
"strings"
"github.com/turenlabs/batou-core/ast"
@@ -38,6 +39,7 @@ func (j *JSASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
tree: tree,
}
c.walk()
+ c.checkDynamicProperty()
return c.findings
}
@@ -128,25 +130,26 @@ func (c *jsChecker) checkAssignment(n *ast.Node) {
if left.Type() == "member_expression" {
propName := memberProperty(left)
if propName == "innerHTML" || propName == "outerHTML" {
- if !isJSLiteral(right) {
- line := int(n.StartRow()) + 1
- c.findings = append(c.findings, rules.Finding{
- RuleID: "BATOU-JSAST-002",
- Severity: rules.High,
- SeverityLabel: rules.High.String(),
- Title: "XSS via " + propName + " assignment",
- Description: propName + " is assigned a non-literal value. If the value contains user input, this enables cross-site scripting (XSS) attacks.",
- FilePath: c.filePath,
- LineNumber: line,
- MatchedText: truncate(n.Text(), 200),
- Suggestion: "Use textContent instead of " + propName + " for text content, or use DOMPurify.sanitize() before setting HTML.",
- CWEID: "CWE-79",
- OWASPCategory: "A03:2021-Injection",
- Language: c.language,
- Confidence: "high",
- Tags: []string{"xss", "dom", "ast"},
- })
+ if isJSSafeHTMLExpression(right) {
+ return
}
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JSAST-002",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "XSS via " + propName + " assignment",
+ Description: propName + " is assigned a non-literal value. If the value contains user input, this enables cross-site scripting (XSS) attacks.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Use textContent instead of " + propName + " for text content, or use DOMPurify.sanitize() before setting HTML.",
+ CWEID: "CWE-79",
+ OWASPCategory: "A03:2021-Injection",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"xss", "dom", "ast"},
+ })
}
}
}
@@ -266,21 +269,27 @@ func (c *jsChecker) checkVariableDeclarator(n *ast.Node) {
}
func (c *jsChecker) checkSQLTemplateString(n *ast.Node) {
- text := n.Text()
- if !containsSQLKeyword(text) {
- return
- }
+ // Only the *literal* parts of the template — `string_fragment` children —
+ // count as the "SQL text". The interpolated `${...}` parts are the
+ // dynamic values; including them in the keyword scan was the source of
+ // FPs (`\`done ${SELECT_OPTION}\``-style identifiers, etc.).
+ var litParts []string
hasInterpolation := false
n.Walk(func(child *ast.Node) bool {
- if child.Type() == "template_substitution" {
+ switch child.Type() {
+ case "string_fragment":
+ litParts = append(litParts, child.Text())
+ case "template_substitution":
hasInterpolation = true
- return false
}
return true
})
if !hasInterpolation {
return
}
+ if !looksLikeSQLFragment(strings.Join(litParts, " ")) {
+ return
+ }
line := int(n.StartRow()) + 1
c.findings = append(c.findings, rules.Finding{
RuleID: "BATOU-JSAST-006",
@@ -300,24 +309,46 @@ func (c *jsChecker) checkSQLTemplateString(n *ast.Node) {
})
}
+// checkSQLBinaryExpression flags `+`-concatenation that builds a SQL string.
+// E6-T5: this used to call strings.Contains-style keyword checks on the whole
+// expression text, which fired on numeric addition like
+// `latestSelectedResourceIndex + step` (the `Selected` substring) and on log
+// messages like `"Update failed for " + id`. The check is now AST-grounded:
+//
+// - the binary operator must be `+`;
+// - the operands are flattened (a `+`-chain nests as nested
+// binary_expressions), and only `string` literal operands contribute to
+// the "SQL text" — their literal value (sans quotes), not identifiers or
+// calls;
+// - that concatenated literal text must look like a SQL fragment
+// (looksLikeSQLFragment: a DML verb paired with a structural keyword, or
+// a standalone structural keyword that's rare in prose) — a bare verb
+// like "Update"/"Delete" in an English sentence does not qualify;
+// - at least one operand must be non-literal (the dynamic value being
+// concatenated in) — a `+` between two numeric/identifier operands never
+// fires.
func (c *jsChecker) checkSQLBinaryExpression(n *ast.Node) {
- text := n.Text()
- if !containsSQLKeyword(text) {
- return
- }
- if !strings.Contains(text, "+") {
+ if jsBinaryOperator(n) != "+" {
return
}
- // Check at least one part is not a literal
- named := n.NamedChildren()
- allLiteral := true
- for _, child := range named {
- if !isJSLiteral(child) {
- allLiteral = false
- break
+ operands := flattenJSConcat(n)
+ var litParts []string
+ hasDynamic := false
+ for _, op := range operands {
+ switch op.Type() {
+ case "string":
+ litParts = append(litParts, jsStringValue(op))
+ case "number", "true", "false", "null", "undefined":
+ // static, non-string operand — contributes nothing
+ default:
+ // identifier, member_expression, call_expression, etc.
+ hasDynamic = true
}
}
- if allLiteral {
+ if !hasDynamic {
+ return
+ }
+ if !looksLikeSQLFragment(strings.Join(litParts, " ")) {
return
}
line := int(n.StartRow()) + 1
@@ -339,6 +370,70 @@ func (c *jsChecker) checkSQLBinaryExpression(n *ast.Node) {
})
}
+// flattenJSConcat returns the leaf operands of a `+`-chain. A chain like
+// `"a" + b + "c"` parses as `(("a" + b) + "c")` — a nested binary_expression
+// whose left is itself a `+` binary_expression — so we recurse only through
+// `+`-operator binary_expressions and treat everything else as a leaf.
+func flattenJSConcat(n *ast.Node) []*ast.Node {
+ if n == nil {
+ return nil
+ }
+ if n.Type() == "binary_expression" && jsBinaryOperator(n) == "+" {
+ var out []*ast.Node
+ for _, side := range []*ast.Node{n.ChildByFieldName("left"), n.ChildByFieldName("right")} {
+ out = append(out, flattenJSConcat(side)...)
+ }
+ return out
+ }
+ if n.Type() == "parenthesized_expression" {
+ if inner := firstNamedChild(n); inner != nil {
+ return flattenJSConcat(inner)
+ }
+ }
+ return []*ast.Node{n}
+}
+
+// jsBinaryOperator returns the operator token of a binary_expression
+// (e.g. "+", "-", "*", "&&"), or "" if it can't be determined.
+func jsBinaryOperator(n *ast.Node) string {
+ if n == nil || n.Type() != "binary_expression" {
+ return ""
+ }
+ if op := n.ChildByFieldName("operator"); op != nil {
+ return op.Text()
+ }
+ return ""
+}
+
+// jsStringValue returns the textual value of a `string` literal node,
+// concatenating its `string_fragment` children (so escape sequences and the
+// surrounding quote characters are excluded). An empty string is returned for
+// quote-only literals like `”`.
+func jsStringValue(n *ast.Node) string {
+ if n == nil {
+ return ""
+ }
+ var parts []string
+ for _, ch := range n.NamedChildren() {
+ if ch.Type() == "string_fragment" {
+ parts = append(parts, ch.Text())
+ }
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, "")
+ }
+ // No string_fragment child (empty literal, or a parser that doesn't emit
+ // fragments) — fall back to trimming the surrounding quotes.
+ t := n.Text()
+ if len(t) >= 2 {
+ q := t[0]
+ if (q == '\'' || q == '"' || q == '`') && t[len(t)-1] == q {
+ return t[1 : len(t)-1]
+ }
+ }
+ return t
+}
+
// --- helpers ---
func jsCallName(n *ast.Node) string {
@@ -420,11 +515,185 @@ func isJSLiteral(n *ast.Node) bool {
return false
}
-func containsSQLKeyword(s string) bool {
- upper := strings.ToUpper(s)
- return strings.Contains(upper, "SELECT") || strings.Contains(upper, "INSERT") ||
- strings.Contains(upper, "UPDATE") || strings.Contains(upper, "DELETE") ||
- strings.Contains(upper, "DROP") || strings.Contains(upper, "ALTER")
+// jsSafeHTMLHelpers lists callable names that, when they appear as the
+// right-hand side of an innerHTML/outerHTML assignment, mean the value is
+// not attacker-derived raw HTML. svg() is the standard typed-icon helper;
+// the others are common app-level wrappers around DOMPurify / static
+// templates, localization helpers (which return developer-authored strings
+// keyed by a constant message id), typed-icon builders (a fixed snippet keyed
+// by a constant icon name), and numeric-coercion functions (whose result is a
+// Number, never raw HTML).
+var jsSafeHTMLHelpers = map[string]bool{
+ "svg": true, // typed SVG-icon constructor (Gitea, Octicon-style)
+ "html": true, // htm-style tagged template helper (returns vnode/string)
+ "htmlsafe": true, // hand-written sanitizer
+ "sanitize": true,
+ "sanitizehtml": true,
+ "sanitize_html": true,
+ "dompurify": true, // DOMPurify(input) one-liner alias
+ "trustedhtml": true, // Trusted Types policy wrapper
+ "sanitizedhtml": true,
+ // Localization helpers: the visible text is a developer-authored template
+ // selected by a constant message id, not attacker-controlled HTML.
+ "i18n": true, // Ember/Discourse I18n.t alias
+ "t": true, // Nextcloud / i18next translate
+ "translate": true,
+ "gettext": true,
+ // Typed-icon / static-fragment builders: a fixed SVG/HTML snippet keyed by
+ // a constant icon name.
+ "iconhtml": true, // Discourse iconHTML("name")
+ "rendericon": true,
+ "escapeexpression": true, // Handlebars / Ember escapeExpression
+ // Numeric-coercion functions: the result is a Number, never HTML.
+ "parseint": true,
+ "parsefloat": true,
+ "number": true,
+}
+
+// isJSSafeHTMLExpression returns true when the right-hand side of an
+// `el.innerHTML = X` assignment is one of:
+//
+// - a plain literal (string/number/etc.)
+// - a parenthesized safe expression
+// - a template literal with no ${} substitutions, or whose every
+// substitution is itself a safe expression
+// - a tagged template literal with a safe tag (html`...`, svg`...`)
+// - a binary expression whose every operand is itself safe (covers
+// `iconHTML("x") + " " + i18n("y")` and numeric `parseInt(s) + 1`)
+// - a call to a known safe-HTML helper (svg(...), sanitize(...), i18n(...),
+// iconHTML(...), parseInt(...), ...)
+// - a call to .sanitize(...) or .escape(...)
+//
+// This is NOT a taint analysis — the analyzer has no taint engine. It only
+// recognises RHS shapes that are *constant or developer-authored by
+// construction* and so cannot carry attacker HTML, eliminating the dominant
+// false-positive classes (numeric results, i18n/icon builders, literals)
+// while still firing on bare variables, member accesses, and templates that
+// interpolate a variable.
+//
+// Conservative: returns false for anything it doesn't recognise.
+func isJSSafeHTMLExpression(n *ast.Node) bool {
+ if n == nil {
+ return false
+ }
+ if isJSLiteral(n) {
+ return true
+ }
+ switch n.Type() {
+ case "parenthesized_expression":
+ // `(expr)` — unwrap and judge the inner expression.
+ if inner := firstNamedChild(n); inner != nil {
+ return isJSSafeHTMLExpression(inner)
+ }
+ return false
+ case "template_string", "template_literal":
+ // Tree-sitter exposes ${...} as a `template_substitution` named child.
+ // A template_string with NO substitutions is a constant; one WITH
+ // substitutions is safe only when every interpolated expression is
+ // itself safe (e.g. `${iconHTML("pause")}${iconHTML("play")}`). A bare
+ // `${tagText}` or `${emojiUnescape(data.text)}` is unsafe and fires.
+ for _, child := range n.NamedChildren() {
+ if child.Type() == "template_substitution" {
+ expr := firstNamedChild(child)
+ if !isJSSafeHTMLExpression(expr) {
+ return false
+ }
+ }
+ }
+ return true
+ case "binary_expression":
+ // Concatenation / arithmetic. Safe only when both operands are safe.
+ // Covers `iconHTML("x") + " " + i18n("y")` (nested binary, all safe
+ // builders/literals) and numeric `parseInt(html, 10) + 1`. A `"
" +
+ // userVar` still fires because the variable operand is not safe.
+ named := n.NamedChildren()
+ if len(named) < 2 {
+ return false
+ }
+ return isJSSafeHTMLExpression(named[0]) && isJSSafeHTMLExpression(named[1])
+ case "tagged_template_expression", "tagged_template_literal":
+ // tag is the first named child, template is the second.
+ named := n.NamedChildren()
+ if len(named) == 0 {
+ return false
+ }
+ tagName := strings.ToLower(strings.TrimSpace(named[0].Text()))
+ // Accept html`...` and svg`...` regardless of substitutions — these
+ // htm/lit-html style tags handle their own escaping. Demote
+ // confidence elsewhere if the project disagrees.
+ return tagName == "html" || tagName == "svg"
+ case "call_expression":
+ // Recognise svg('octicon-...'), htmlSafe(x), sanitize(x), iconHTML(x),
+ // i18n(x), parseInt(x), etc.
+ named := n.NamedChildren()
+ if len(named) == 0 {
+ return false
+ }
+ callee := named[0]
+ var name string
+ switch callee.Type() {
+ case "identifier":
+ name = strings.ToLower(strings.TrimSpace(callee.Text()))
+ case "member_expression":
+ // foo.sanitize(x) / DOMPurify.sanitize(x) — match on the method.
+ name = strings.ToLower(strings.TrimSpace(memberProperty(callee)))
+ }
+ if jsSafeHTMLHelpers[name] {
+ return true
+ }
+ // Method-name-suffix heuristic: anything ending in "sanitize",
+ // "purify", or "escape" is, by convention, a sanitizer.
+ if strings.HasSuffix(name, "sanitize") || strings.HasSuffix(name, "purify") || strings.HasSuffix(name, "escape") {
+ return true
+ }
+ }
+ return false
+}
+
+// sqlFragmentShape recognises text that has the *shape* of a SQL statement
+// or clause, not merely an English word that happens to also be a SQL verb.
+//
+// SQL keywords are whitespace-or-quote delimited (`SELECT *`, `* FROM x`,
+// `WHERE id`), never adjacent to `.`, `-`, `/`, or other identifier/path
+// characters. RE2 has no lookbehind, so the leading delimiter is the
+// alternation `(^|[\s'"`+])` (string start, whitespace, quote, or `+` from
+// concat); the trailing delimiter is a char class. These delimiters keep
+// `Selected`/`selectedIndex` from matching `SELECT`, `inserted` from
+// matching `INSERT`, and path/CSS fragments like `/select-all/` or
+// `select-from-here` from matching `SELECT ... FROM`.
+//
+// On top of the boundaries, a bare DML verb is not enough: it must be paired
+// with a structural keyword (FROM/INTO/SET/WHERE/VALUES), or be a structural
+// keyword that is itself rare in prose (a JOIN variant, UNION SELECT,
+// GROUP/ORDER BY), or a WHERE-comparison, or a DDL statement. Bounded
+// repetition (`{0,N}?`) is supported by RE2, so the "verb ... keyword"
+// shapes are expressed directly.
+const (
+ // sqlKwL is the leading delimiter for a SQL keyword.
+ sqlKwL = "(^|[\\s'\"`+])"
+ // sqlKwR is the trailing delimiter for a SQL keyword.
+ sqlKwR = "([\\s,;)('\"`+*=<>]|$)"
+)
+
+var sqlFragmentShape = regexp.MustCompile(`(?i)(` +
+ sqlKwL + `(SELECT|DELETE)` + sqlKwR + `[\s\S]{0,200}?` + sqlKwL + `FROM` + sqlKwR +
+ `|` + sqlKwL + `INSERT` + sqlKwR + `[\s\S]{0,40}?` + sqlKwL + `INTO` + sqlKwR +
+ `|` + sqlKwL + `UPDATE` + sqlKwR + `[\s\S]{0,80}?` + sqlKwL + `SET` + sqlKwR +
+ `|` + sqlKwL + `WHERE` + sqlKwR + `[\s\S]{0,200}?(=|<|>|!=|` + sqlKwL + `LIKE\s|` + sqlKwL + `IN\s*\(|` + sqlKwL + `IS\s+(NOT\s+)?NULL)` +
+ `|` + sqlKwL + `(LEFT|RIGHT|INNER|OUTER|CROSS|FULL)\s+(OUTER\s+)?JOIN` + sqlKwR +
+ `|` + sqlKwL + `UNION\s+(ALL\s+)?SELECT` + sqlKwR +
+ `|` + sqlKwL + `GROUP\s+BY` + sqlKwR + `|` + sqlKwL + `ORDER\s+BY` + sqlKwR +
+ `|` + sqlKwL + `INTO` + sqlKwR + `[\s\S]{0,80}?` + sqlKwL + `VALUES` + sqlKwR +
+ `|` + sqlKwL + `TRUNCATE\s+TABLE` + sqlKwR +
+ `|` + sqlKwL + `DROP\s+(TABLE|INDEX|DATABASE|VIEW|SCHEMA)` + sqlKwR +
+ `|` + sqlKwL + `ALTER\s+TABLE` + sqlKwR +
+ `|` + sqlKwL + `CREATE\s+(TABLE|INDEX|VIEW|DATABASE|SCHEMA)` + sqlKwR +
+ `)`)
+
+// looksLikeSQLFragment reports whether s (the concatenated *literal* parts of
+// a template literal or string concat) has the shape of a SQL fragment.
+func looksLikeSQLFragment(s string) bool {
+ return sqlFragmentShape.MatchString(s)
}
func truncate(s string, maxLen int) string {
diff --git a/batou-core/analyzer/jsast/jsast_dynprop.go b/batou-core/analyzer/jsast/jsast_dynprop.go
new file mode 100644
index 0000000..c57f890
--- /dev/null
+++ b/batou-core/analyzer/jsast/jsast_dynprop.go
@@ -0,0 +1,311 @@
+package jsast
+
+import (
+ "strings"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// This file implements two AST-grounded structural checks that the catalog's
+// function/method-name taint model cannot express, because the dangerous
+// element is a COMPUTED PROPERTY KEY (or a computed method selector) rather
+// than a named call:
+//
+// BATOU-JSAST-007 — prototype-polluting / remote-property-injection
+// assignment: obj[] = v (CWE-1321 / CWE-471)
+// BATOU-JSAST-008 — unsafe dynamic method/function dispatch:
+// obj[](...) (CWE-94 / CWE-749)
+//
+// Both fire ONLY when the computed key is (a) NOT a string literal and (b)
+// structurally derived from request input — either a member-access chain into
+// a known request object (req.query.field, req.body.k) or a for-in loop
+// variable iterating over such an object (`for (const k in req.body) obj[k]=…`).
+// A constant key (obj.foo, obj["const"]), an array index from a numeric for
+// loop (arr[i]=data[i]), or a key from a non-request variable never matches.
+// This is deliberately conservative — it has no taint engine, so it recognises
+// only request-derived key shapes that are attacker-controlled by construction.
+
+// jsRequestRootIdents are identifiers whose member-access chains carry request
+// (attacker-controlled) data in the dominant Node/Express idioms. Root-anchored
+// so an arbitrary `foo.bar` does not match. Deliberately limited to the
+// unambiguous HTTP-request roots `req`/`request`: `ctx`/`context` are heavily
+// overloaded (React/Vue context, generic handler params, typed DTOs) and
+// matching them as a request root produced false positives on internal typed
+// parameters (e.g. `scopesById[context.projectId]=[]` where `context` is a
+// `{ projectId: string }` arg). Request data carried under a generic root is
+// still caught by the request-SEGMENT check below (req.body / .query / etc).
+var jsRequestRootIdents = map[string]bool{
+ "req": true,
+ "request": true,
+}
+
+// jsRequestSegments are property names that, appearing anywhere in a member
+// chain, denote a request-data container. A chain like `req.body.x`,
+// `ctx.query.y`, or a destructured `body.k` / `query.field` matches. These are
+// the standard request sub-objects across Express/Koa/Fastify/Next.
+var jsRequestSegments = map[string]bool{
+ "body": true,
+ "query": true,
+ "params": true,
+ "headers": true,
+ "cookies": true,
+ "payload": true,
+}
+
+// jsExprFromRequestSource reports whether the expression node is a member-access
+// chain that reads request (attacker-controlled) data. True when:
+// - the chain's ROOT identifier is a known HTTP-request object (req/request),
+// e.g. `req.query.field`, `request.params.id`; OR
+// - ANY property segment of the chain is a request container
+// (body/query/params/headers/cookies/payload), covering destructured forms
+// like `query.field` and deeper chains under a generic root such as
+// `event.payload.id` or `data.body.x`.
+//
+// A bare identifier, a literal, or a chain with neither a request root nor a
+// request segment returns false.
+func jsExprFromRequestSource(n *ast.Node) bool {
+ if n == nil {
+ return false
+ }
+ if n.Type() != "member_expression" {
+ return false
+ }
+ root := jsMemberRootIdent(n)
+ if root != "" && jsRequestRootIdents[strings.ToLower(root)] {
+ return true
+ }
+ // Walk the property segments of the chain.
+ cur := n
+ for cur != nil && cur.Type() == "member_expression" {
+ if prop := memberProperty(cur); prop != "" && jsRequestSegments[strings.ToLower(prop)] {
+ return true
+ }
+ cur = firstNamedChild(cur)
+ }
+ return false
+}
+
+// jsMemberRootIdent returns the leftmost identifier of a member-access chain
+// (the receiver root), or "" if the chain does not bottom out in a plain
+// identifier (e.g. it starts with a call or subscript).
+func jsMemberRootIdent(n *ast.Node) string {
+ cur := n
+ for cur != nil {
+ switch cur.Type() {
+ case "identifier":
+ return cur.Text()
+ case "member_expression":
+ cur = firstNamedChild(cur)
+ default:
+ return ""
+ }
+ }
+ return ""
+}
+
+// subscriptParts returns the base and key nodes of a subscript_expression
+// (`base[key]`). Tree-sitter exposes them as named children 0 and 1 with no
+// field names. Returns (nil, nil) when the node is not a 2-child subscript.
+func subscriptParts(n *ast.Node) (base *ast.Node, key *ast.Node) {
+ if n == nil || n.Type() != "subscript_expression" {
+ return nil, nil
+ }
+ named := n.NamedChildren()
+ if len(named) < 2 {
+ return nil, nil
+ }
+ return named[0], named[1]
+}
+
+// jsKeyIsTaintedKey reports whether a subscript KEY expression is attacker
+// controlled by construction. forInKeys holds loop-variable names bound by an
+// enclosing `for…in` over a request source. The key is tainted when it is a
+// member-access into a request source (`obj[req.query.f]`) or a bare identifier
+// that is one of those for-in keys (`for (const k in req.body) obj[k]=…`).
+// A string literal, number, or any other identifier is NOT tainted.
+func jsKeyIsTaintedKey(key *ast.Node, forInKeys map[string]bool) bool {
+ if key == nil {
+ return false
+ }
+ switch key.Type() {
+ case "string", "number", "true", "false", "null", "undefined":
+ return false
+ case "identifier":
+ return forInKeys[key.Text()]
+ case "member_expression":
+ return jsExprFromRequestSource(key)
+ }
+ return false
+}
+
+// jsBaseIsPlainObject reports whether the subscript base is a write target that
+// is plausibly a plain object whose prototype can be polluted / whose arbitrary
+// property is a security-relevant write. We accept a bare identifier or a
+// member-access (obj, this.cfg, opts.data) and reject array literals, calls,
+// and subscripts (which are typically array/collection element writes, not
+// plain-object key writes).
+func jsBaseIsPlainObject(base *ast.Node) bool {
+ if base == nil {
+ return false
+ }
+ switch base.Type() {
+ case "identifier", "member_expression", "this":
+ return true
+ }
+ return false
+}
+
+// collectForInRequestKeys walks the subtree rooted at fn and returns the set of
+// loop-variable names bound by a `for…in` (or `for…of`) over a request source.
+// `for (const k in req.body)` yields {k}. These names are attacker-controlled
+// keys when used as a computed property.
+func collectForInRequestKeys(root *ast.Node) map[string]bool {
+ keys := map[string]bool{}
+ if root == nil {
+ return keys
+ }
+ root.Walk(func(n *ast.Node) bool {
+ if n.Type() != "for_in_statement" {
+ return true
+ }
+ named := n.NamedChildren()
+ if len(named) < 2 {
+ return true
+ }
+ loopVar := named[0]
+ iterable := named[1]
+ // loopVar may be a plain identifier (`for (k in …)`) or a declaration
+ // (`for (const k in …)`); pull the bound identifier either way.
+ varName := ""
+ switch loopVar.Type() {
+ case "identifier":
+ varName = loopVar.Text()
+ default:
+ if id := firstIdentDescendant(loopVar); id != nil {
+ varName = id.Text()
+ }
+ }
+ if varName == "" {
+ return true
+ }
+ if jsExprFromRequestSource(iterable) {
+ keys[varName] = true
+ }
+ return true
+ })
+ return keys
+}
+
+// firstIdentDescendant returns the first identifier node found in a small
+// declaration subtree (used to pull `k` out of `const k`).
+func firstIdentDescendant(n *ast.Node) *ast.Node {
+ if n == nil {
+ return nil
+ }
+ if n.Type() == "identifier" {
+ return n
+ }
+ for _, c := range n.NamedChildren() {
+ if id := firstIdentDescendant(c); id != nil {
+ return id
+ }
+ }
+ return nil
+}
+
+// checkDynamicProperty runs the two computed-key checks over the whole tree.
+// It is called once per file from the checker walk.
+func (c *jsChecker) checkDynamicProperty() {
+ root := c.tree.Root()
+ if root == nil {
+ return
+ }
+ forInKeys := collectForInRequestKeys(root)
+
+ root.Walk(func(n *ast.Node) bool {
+ switch n.Type() {
+ case "assignment_expression":
+ c.checkProtoPollutingAssign(n, forInKeys)
+ case "call_expression":
+ c.checkDynamicDispatch(n, forInKeys)
+ }
+ return true
+ })
+}
+
+// checkProtoPollutingAssign flags `base[] = value` where the key
+// is request-derived and non-literal — the prototype-polluting /
+// remote-property-injection shape (CWE-1321 / CWE-471).
+func (c *jsChecker) checkProtoPollutingAssign(n *ast.Node, forInKeys map[string]bool) {
+ named := n.NamedChildren()
+ if len(named) < 2 {
+ return
+ }
+ lhs := named[0]
+ if lhs.Type() != "subscript_expression" {
+ return
+ }
+ base, key := subscriptParts(lhs)
+ if !jsBaseIsPlainObject(base) {
+ return
+ }
+ if !jsKeyIsTaintedKey(key, forInKeys) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JSAST-007",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Prototype pollution via computed property assignment",
+ Description: "An object property is written using a computed key (obj[key] = value) where the key is derived from request input. An attacker who controls the key can set __proto__/constructor/prototype, polluting Object.prototype and corrupting unrelated objects across the application.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Reject keys equal to __proto__/constructor/prototype, use a null-prototype object (Object.create(null)) or a Map, or copy only an allowlist of known property names.",
+ CWEID: "CWE-1321",
+ OWASPCategory: "A03:2021-Injection",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"prototype-pollution", "injection", "ast"},
+ })
+}
+
+// checkDynamicDispatch flags `base[](...)` where the method/function
+// selector is request-derived and non-literal — unsafe dynamic dispatch
+// (CWE-94 / CWE-749).
+func (c *jsChecker) checkDynamicDispatch(n *ast.Node, forInKeys map[string]bool) {
+ named := n.NamedChildren()
+ if len(named) == 0 {
+ return
+ }
+ callee := named[0]
+ if callee.Type() != "subscript_expression" {
+ return
+ }
+ base, key := subscriptParts(callee)
+ if base == nil {
+ return
+ }
+ if !jsKeyIsTaintedKey(key, forInKeys) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-JSAST-008",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Unsafe dynamic method dispatch from request input",
+ Description: "A method/function is selected and invoked using a computed key (obj[name](...)) where the selector is derived from request input. An attacker who controls the selector can reach unintended methods (including inherited Object/Function methods), turning a dispatch table into arbitrary behaviour or code execution.",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Validate the selector against an explicit allowlist (e.g. `if (name in ALLOWED)` / a switch over known actions) and call own methods only — Object.prototype.hasOwnProperty.call(handlers, name) before handlers[name]().",
+ CWEID: "CWE-94",
+ OWASPCategory: "A03:2021-Injection",
+ Language: c.language,
+ Confidence: "high",
+ Tags: []string{"dynamic-dispatch", "injection", "ast"},
+ })
+}
diff --git a/batou-core/analyzer/jsast/jsast_dynprop_test.go b/batou-core/analyzer/jsast/jsast_dynprop_test.go
new file mode 100644
index 0000000..b9222a1
--- /dev/null
+++ b/batou-core/analyzer/jsast/jsast_dynprop_test.go
@@ -0,0 +1,97 @@
+package jsast
+
+import "testing"
+
+// hasJSRule reports whether the AST analyzer emits a finding with the given
+// rule ID for the supplied JS source.
+func hasJSRule(code, ruleID string) bool {
+ for _, f := range scanJS(code) {
+ if f.RuleID == ruleID {
+ return true
+ }
+ }
+ return false
+}
+
+// --- BATOU-JSAST-007: prototype-polluting computed-key assignment ---
+
+func TestProtoPollutingAssign_Fires(t *testing.T) {
+ tps := []string{
+ // classic copy-loop over req.body keys
+ `function f(req){ const dst = {}; for (const k in req.body) { dst[k] = req.body[k]; } }`,
+ // direct request-derived key
+ `function f(req){ obj[req.query.field] = val; }`,
+ // member base + request key
+ `function f(req){ this.cfg[req.params.key] = req.params.value; }`,
+ // for-in over request.query (alternate root)
+ `function f(request){ for (const k in request.query) { target[k] = request.query[k]; } }`,
+ // koa-style ctx.body container segment
+ `function f(ctx){ opts[ctx.body.name] = 1; }`,
+ // request container appearing as a chain segment under a non-request root
+ `function f(data){ store[data.body.field] = 2; }`,
+ }
+ for _, c := range tps {
+ if !hasJSRule(c, "BATOU-JSAST-007") {
+ t.Errorf("expected BATOU-JSAST-007 to fire on: %s", c)
+ }
+ }
+}
+
+func TestProtoPollutingAssign_NoFP(t *testing.T) {
+ fps := []string{
+ // literal / constant keys — the dominant safe shape
+ `function f(){ obj.fixed = x; }`,
+ `function f(){ obj["const"] = y; }`,
+ // numeric-index array writes from a counter loop
+ `function f(){ const arr = []; for (let i = 0; i < n; i++) { arr[i] = data[i]; } }`,
+ // plain function-parameter key (not request-derived)
+ `function f(map, k){ map[k] = v; }`,
+ // for-in over a NON-request object
+ `function f(){ for (const k in config) { dst[k] = config[k]; } }`,
+ // hashed/derived key, not request input
+ `function f(){ cache[hash] = result; }`,
+ // `context` is a generic/typed handler param, NOT an HTTP request root —
+ // must not be treated as request-tainted (real n8n rbac.store shape).
+ `function f(context){ scopesById[context.projectId] = []; }`,
+ // `ctx` alone (no request segment) is likewise too generic.
+ `function f(ctx){ store[ctx.id] = v; }`,
+ }
+ for _, c := range fps {
+ if hasJSRule(c, "BATOU-JSAST-007") {
+ t.Errorf("BATOU-JSAST-007 false positive on: %s", c)
+ }
+ }
+}
+
+// --- BATOU-JSAST-008: unsafe dynamic method dispatch ---
+
+func TestDynamicDispatch_Fires(t *testing.T) {
+ tps := []string{
+ `function f(req){ handlers[req.body.action](payload); }`,
+ `function f(req){ table[req.query.cmd](a, b); }`,
+ `function f(req){ for (const k in req.body) { api[k](req.body[k]); } }`,
+ }
+ for _, c := range tps {
+ if !hasJSRule(c, "BATOU-JSAST-008") {
+ t.Errorf("expected BATOU-JSAST-008 to fire on: %s", c)
+ }
+ }
+}
+
+func TestDynamicDispatch_NoFP(t *testing.T) {
+ fps := []string{
+ // constant-string selector dispatch table
+ `function f(){ obj["fixed"](); }`,
+ // normal dotted method call
+ `function f(){ handlers.action(payload); }`,
+ // array element call from a numeric index
+ `function f(arr, i){ arr[i](); }`,
+ // for-in over a NON-request object
+ `function f(){ for (const k in opts) { fns[k](); } }`,
+ }
+ for _, c := range fps {
+ if hasJSRule(c, "BATOU-JSAST-008") {
+ t.Errorf("BATOU-JSAST-008 false positive on: %s", c)
+ }
+ }
+}
diff --git a/batou-core/analyzer/jsast/jsast_test.go b/batou-core/analyzer/jsast/jsast_test.go
index 64d6f4c..a3433db 100644
--- a/batou-core/analyzer/jsast/jsast_test.go
+++ b/batou-core/analyzer/jsast/jsast_test.go
@@ -1,10 +1,9 @@
package jsast
import (
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "testing"
)
func scanJS(code string) []rules.Finding {
@@ -169,6 +168,100 @@ function handler(input) {
}
}
+// --- BATOU-JSAST-006: SQL concat / template-literal — FP gates (E6-T5) ---
+
+// requireCritical asserts a JSAST-006 finding exists and is CRITICAL.
+func requireSQLConcatFinding(t *testing.T, code string) {
+ t.Helper()
+ findings := scanJS(code)
+ f := findByRule(findings, "BATOU-JSAST-006")
+ if f == nil {
+ t.Fatalf("expected BATOU-JSAST-006 finding for %q; got %v", code, ruleIDsOf(findings))
+ }
+ if f.Severity != rules.Critical {
+ t.Fatalf("expected CRITICAL severity, got %v", f.Severity)
+ }
+}
+
+func requireNoSQLConcatFinding(t *testing.T, code string) {
+ t.Helper()
+ findings := scanJS(code)
+ if f := findByRule(findings, "BATOU-JSAST-006"); f != nil {
+ t.Fatalf("did not expect BATOU-JSAST-006 for %q; got finding %q at line %d", code, f.Title, f.LineNumber)
+ }
+}
+
+func ruleIDsOf(fs []rules.Finding) []string {
+ out := make([]string, 0, len(fs))
+ for _, f := range fs {
+ out = append(out, f.RuleID)
+ }
+ return out
+}
+
+// The owncloud/web FP: numeric addition whose identifier *contains* a SQL
+// keyword as a substring (`latestSelectedResourceIndex` -> "Selected").
+func TestSQLConcat_NumericAddition_NoFP(t *testing.T) {
+ for _, code := range []string{
+ "function f() { var x = latestSelectedResourceIndex + step; }",
+ "function f() { const next = offset + limit; }",
+ "function f() { let n = currentInsertedRows + 1; }",
+ "function f() { var i = updatedCount + deletedCount; }",
+ "function f() { const idx = i + 1 + j; }",
+ } {
+ requireNoSQLConcatFinding(t, code)
+ }
+}
+
+// Concatenation that builds an English log/error message containing a bare
+// SQL verb ("Update failed", "Delete this", "/select-all/") must not fire —
+// it has no SQL *shape* (no FROM/SET/WHERE/INTO pairing).
+func TestSQLConcat_LogMessage_NoFP(t *testing.T) {
+ for _, code := range []string{
+ `function f() { var msg = "Update failed for resource " + id + " - retrying"; }`,
+ `function f() { var msg = "Delete this item? " + name; }`,
+ `function f() { var route = "/select-all/" + page; }`,
+ `function f() { var label = "Where did " + user + " go?"; }`,
+ } {
+ requireNoSQLConcatFinding(t, code)
+ }
+}
+
+// Template literal that is not SQL (DOM-selector style, log message) — even
+// if a SQL verb substring appears it lacks the shape.
+func TestSQLTemplate_NotSQL_NoFP(t *testing.T) {
+ for _, code := range []string{
+ "function f() { var sel = `.foo-${id}`; }",
+ "function f() { var sel = `[data-id='${id}']`; }",
+ "function f() { var msg = `done ${count} records`; }",
+ "function f() { var path = `${base}/select-from-here/${page}`; }",
+ } {
+ requireNoSQLConcatFinding(t, code)
+ }
+}
+
+// Real SQL injection via concat — must still fire CRITICAL.
+func TestSQLConcat_RealSQLi_Fires(t *testing.T) {
+ requireSQLConcatFinding(t,
+ `function f(input) { var q = "SELECT * FROM users WHERE name = '" + input + "'"; }`)
+ requireSQLConcatFinding(t,
+ `function f(id) { var q = "DELETE FROM sessions WHERE id = " + id; }`)
+ requireSQLConcatFinding(t,
+ `function f(v) { var q = "INSERT INTO logs (msg) VALUES ('" + v + "')"; }`)
+ requireSQLConcatFinding(t,
+ `function f(name, id) { var q = "UPDATE users SET name = '" + name + "' WHERE id = " + id; }`)
+}
+
+// Real SQL injection via template literal — must still fire CRITICAL.
+func TestSQLTemplate_RealSQLi_Fires(t *testing.T) {
+ requireSQLConcatFinding(t,
+ "function f(input) { var q = `SELECT * FROM users WHERE name = '${input}'`; }")
+ requireSQLConcatFinding(t,
+ "function f(id) { var q = `DELETE FROM sessions WHERE token = '${id}'`; }")
+ requireSQLConcatFinding(t,
+ "function f(col) { var q = `SELECT id FROM t ORDER BY ${col}`; }")
+}
+
func TestNilTree(t *testing.T) {
ctx := &rules.ScanContext{
FilePath: "/app/handler.js",
@@ -233,3 +326,137 @@ function handler(input) {
t.Errorf("expected line 4, got %d", f.LineNumber)
}
}
+
+// JSAST-002 false-positive suppression — innerHTML assignments where the
+// RHS is a safe-shaped expression should not fire.
+func TestInnerHTMLSafeExpressions(t *testing.T) {
+ cases := []struct {
+ name string
+ code string
+ }{
+ {"svg_helper", `el.innerHTML = svg('octicon-copy');`},
+ {"html_tagged_template_no_subst", "el.innerHTML = html``;"},
+ {"html_tagged_template_with_subst", "el.innerHTML = html`
`; }"},
+ {"member_access", `function f(req){ el.innerHTML = req.body.html; }`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ findings := scanJS(tc.code)
+ if findByRule(findings, "BATOU-JSAST-002") == nil {
+ t.Errorf("expected JSAST-002 finding, got none")
+ }
+ })
+ }
+}
+
+// JSAST-002 real-world FP shapes — these are the *exact* innerHTML assignments
+// that fired (39 of them) on Discourse + Nextcloud during a smoke scan despite
+// having a constant / developer-authored / numeric RHS that cannot carry
+// attacker HTML. They must now stay CLEAN. The companion test
+// TestInnerHTML_RealWorld_TPStillFires proves the tightening did not disable
+// the rule for genuinely tainted RHS shapes.
+func TestInnerHTML_RealWorldFP_NoFire(t *testing.T) {
+ cases := []struct {
+ name string
+ code string
+ }{
+ // Numeric expression — `parseInt(...) + 1` is a Number, never HTML.
+ // (Discourse lib/click-track.js: `badge.innerHTML = parseInt(html,10)+1`)
+ {"numeric_parseint_add", `function f(html){ badge.innerHTML = parseInt(html, 10) + 1; }`},
+ // Typed-icon builder call — returns a fixed SVG snippet keyed by a
+ // constant name. (Discourse lib/codeblock-buttons.js)
+ {"iconHTML_call", `overlay.innerHTML = iconHTML("play");`},
+ // i18n localization — visible text is a developer-authored template
+ // selected by a constant message id. (Discourse lib/codeblock-buttons.js)
+ {"i18n_call", `button.innerHTML = i18n("copy_codeblock.copied");`},
+ // Nextcloud / i18next translate alias.
+ // (Nextcloud apps/files_external/.../inlineStorageCheckAction.ts)
+ {"t_translate_call", `span.innerHTML = t('files_external', 'Checking storage');`},
+ // Concatenation of only safe builders + literals.
+ // (Discourse instance-initializers/video-placeholder.js)
+ {"concat_of_safe_builders", `notice.innerHTML = iconHTML("triangle-exclamation") + " " + i18n("invalid_video_url");`},
+ // Template literal interpolating ONLY safe-builder calls.
+ // (Discourse instance-initializers/animated-images-pause-on-click.js)
+ {"template_of_safe_builders", "overlay.innerHTML = `${iconHTML(\"pause\")}${iconHTML(\"play\")}`;"},
+ // Template interpolating only an i18n call.
+ // (Discourse instance-initializers/post-decorations.js, simplified)
+ {"template_of_i18n", "btn.innerHTML = `${i18n(props.label)}`;"},
+ // Plain string literal HTML.
+ {"string_literal_html", `x.innerHTML = "
Hello
";`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ findings := scanJS(tc.code)
+ if f := findByRule(findings, "BATOU-JSAST-002"); f != nil {
+ t.Errorf("real-world safe innerHTML FP fired: line %d — %s", f.LineNumber, f.MatchedText)
+ }
+ })
+ }
+}
+
+// JSAST-002 must STILL fire on the genuinely-dangerous innerHTML shapes that
+// appeared alongside the FPs in the same repos: bare variables, member
+// accesses, and templates that interpolate a plain variable (not a safe
+// builder). This is the proof that the FP gate tightened, not disabled, the
+// rule — the same sink with a tainted-shaped RHS keeps firing.
+func TestInnerHTML_RealWorld_TPStillFires(t *testing.T) {
+ cases := []struct {
+ name string
+ code string
+ }{
+ // Bare variable holding prior HTML. (Discourse lib/codeblock-buttons.js:
+ // `button.innerHTML = state`)
+ {"bare_variable_state", `function f(state){ button.innerHTML = state; }`},
+ // Cooked/rendered HTML variable. (Discourse lib/text.js: `div.innerHTML = cooked`)
+ {"cooked_variable", `function f(cooked){ div.innerHTML = cooked; }`},
+ // Member access onto node attrs. (Discourse onebox.js: `dom.innerHTML = node.attrs.html`)
+ {"member_access_attrs", `function f(node){ dom.innerHTML = node.attrs.html; }`},
+ // Template interpolating a plain variable next to a safe builder — the
+ // variable substitution is unsafe so it must fire.
+ // (Discourse static/prosemirror/extensions/hashtag.js:
+ // `domNode.innerHTML = ` + "`${hashtagIconHTML}${tagText}`")
+ {"template_with_var_subst", "function f(hashtagIconHTML, tagText){ domNode.innerHTML = `${hashtagIconHTML}${tagText}`; }"},
+ // Template that mixes a bare-variable substitution with HTML — the
+ // `${iconHTML}` operand is a plain identifier (not a safe-builder
+ // *call*), so the template is unsafe and fires. (Discourse
+ // lib/hashtag-decorator.js, full line.)
+ {"template_with_bare_ident_subst", "function f(iconHTML, data){ link.innerHTML = `${iconHTML}${data.text}`; }"},
+ // Concatenation that includes a user variable operand.
+ {"concat_with_var", `function f(input){ el.innerHTML = "
" + input + "
"; }`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ findings := scanJS(tc.code)
+ if findByRule(findings, "BATOU-JSAST-002") == nil {
+ t.Errorf("expected JSAST-002 to still fire on dangerous RHS, got none for %q", tc.code)
+ }
+ })
+ }
+}
diff --git a/batou-core/analyzer/ktast/ktast.go b/batou-core/analyzer/ktast/ktast.go
index 64e61cd..c0ff31f 100644
--- a/batou-core/analyzer/ktast/ktast.go
+++ b/batou-core/analyzer/ktast/ktast.go
@@ -14,12 +14,12 @@ func init() {
rules.Register(&KotlinASTAnalyzer{})
}
-func (k *KotlinASTAnalyzer) ID() string { return "BATOU-KT-AST" }
-func (k *KotlinASTAnalyzer) Name() string { return "Kotlin AST Security Analyzer" }
-func (k *KotlinASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
-func (k *KotlinASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangKotlin} }
+func (k *KotlinASTAnalyzer) ID() string { return "BATOU-KT-AST" }
+func (k *KotlinASTAnalyzer) Name() string { return "Kotlin AST Security Analyzer" }
+func (k *KotlinASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
+func (k *KotlinASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangKotlin} }
func (k *KotlinASTAnalyzer) Description() string {
- return "AST-based analysis of Kotlin/Android code for SQL injection, JavaScript interface exposure, sensitive data in SharedPreferences, and command injection."
+ return "AST-based analysis of Kotlin/Android code for SQL injection, JavaScript interface exposure, sensitive data in SharedPreferences, command injection, unsafe deserialization, server-side template injection, and SSRF."
}
func (k *KotlinASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
@@ -57,11 +57,164 @@ func (c *ktChecker) walk() {
c.checkAddJavascriptInterface(n)
c.checkSensitiveSharedPrefs(n)
c.checkRuntimeExec(n)
+ c.checkUnsafeDeserialization(n)
+ c.checkJacksonDefaultTyping(n)
+ c.checkSSTI(n)
+ c.checkSSRF(n)
}
return true
})
}
+// checkUnsafeDeserialization detects native Java/Kotlin deserialization sink
+// constructors that, by their nature, deserialize an arbitrary object graph from
+// an untrusted stream (CWE-502). These are gadget-chain RCE primitives and have
+// no safe variant when fed an attacker-controlled stream, so the constructor call
+// itself is the structural signal — independent of whether taint is proven.
+//
+// Detected: ObjectInputStream(...), XMLDecoder(...). The constructor is a
+// call_expression whose direct simple_identifier child is the class name (no
+// receiver navigation), e.g. `ObjectInputStream(req.inputStream)`.
+func (c *ktChecker) checkUnsafeDeserialization(n *ast.Node) {
+ ctorName := getKotlinConstructorName(n)
+ if ctorName != "ObjectInputStream" && ctorName != "XMLDecoder" {
+ return
+ }
+ // Require at least one argument (a stream/source); a no-arg call is unusual
+ // and not a meaningful sink.
+ if len(getKotlinCallArgs(n)) == 0 {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-KT-AST-005",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Unsafe deserialization via " + ctorName,
+ Description: ctorName + " deserializes an arbitrary object graph from the supplied stream. If the stream is attacker-controlled, gadget chains on the classpath can be triggered to achieve remote code execution.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Avoid native Java serialization for untrusted data. Use a safe data format (JSON with explicit types) and, if ObjectInputStream is unavoidable, install a strict resolveClass allowlist (e.g. ValidatingObjectInputStream / JEP 290 ObjectInputFilter).",
+ CWEID: "CWE-502",
+ OWASPCategory: "A08:2021-Software and Data Integrity Failures",
+ Language: rules.LangKotlin,
+ Confidence: "high",
+ Tags: []string{"deserialization", "rce", "insecure-deserialization"},
+ })
+}
+
+// checkJacksonDefaultTyping detects enabling Jackson polymorphic default typing,
+// which turns ObjectMapper.readValue into a deserialization gadget sink (CWE-502).
+// Calls like mapper.enableDefaultTyping() / mapper.activateDefaultTyping(...) are
+// the structural signal — once enabled, any subsequent readValue on untrusted
+// JSON is exploitable, so the enabling call itself is flagged.
+func (c *ktChecker) checkJacksonDefaultTyping(n *ast.Node) {
+ method := getKotlinMethodName(n)
+ if method != "enableDefaultTyping" && method != "activateDefaultTyping" {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-KT-AST-006",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Jackson polymorphic default typing enabled",
+ Description: "Enabling Jackson default typing (" + method + ") embeds concrete class names in JSON and instantiates them during deserialization. On untrusted input this is a known remote-code-execution vector via gadget chains.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Do not enable default typing on untrusted JSON. Use explicit @JsonTypeInfo with a closed @JsonSubTypes set, or a strict PolymorphicTypeValidator that allowlists only the expected types.",
+ CWEID: "CWE-502",
+ OWASPCategory: "A08:2021-Software and Data Integrity Failures",
+ Language: rules.LangKotlin,
+ Confidence: "high",
+ Tags: []string{"deserialization", "jackson", "rce"},
+ })
+}
+
+// checkSSTI detects server-side template injection (CWE-1336) where a template
+// engine renders a DYNAMICALLY built template source string. Static literal
+// template strings are safe; only a concatenated or string-interpolated template
+// argument is flagged, keeping the structural check specific.
+//
+// Sinks: templateEngine.process / .evaluate / Velocity.evaluate /
+// compileInline / compileTemplate where the first argument is the template source.
+func (c *ktChecker) checkSSTI(n *ast.Node) {
+ method := getKotlinMethodName(n)
+ var isSink bool
+ switch method {
+ case "process", "evaluate", "compileInline", "createTemplate", "renderTemplate":
+ isSink = true
+ }
+ if !isSink {
+ return
+ }
+ args := getKotlinCallArgs(n)
+ if len(args) == 0 {
+ return
+ }
+ // The template source is the first argument. Only a dynamically constructed
+ // template (concat or ${} interpolation) is an injection risk; a static
+ // string literal is safe.
+ if !argIsDynamicString(args[0]) {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-KT-AST-007",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Server-side template injection via " + method,
+ Description: "A dynamically built template string is passed to " + method + ". Template engines (Thymeleaf, Velocity, Freemarker, Handlebars) evaluate expressions in the template body, so concatenating untrusted input into the template source allows arbitrary expression evaluation / RCE.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Never build the template source from user input. Keep templates static and pass user data only as context/model variables that the engine escapes.",
+ CWEID: "CWE-1336",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangKotlin,
+ Confidence: "high",
+ Tags: []string{"ssti", "injection", "template-injection"},
+ })
+}
+
+// checkSSRF detects server-side request forgery (CWE-918) where a java.net.URL is
+// opened on a non-literal (variable / expression) target. The receiver chain of
+// the call is inspected for a `URL()` constructor whose argument is not a
+// static string literal. `.openConnection()`, `.openStream()`, `.readText()` and
+// `.getContent()` on such a URL all initiate the outbound request.
+func (c *ktChecker) checkSSRF(n *ast.Node) {
+ method := getKotlinMethodName(n)
+ switch method {
+ case "openConnection", "openStream", "readText", "getContent", "readBytes":
+ default:
+ return
+ }
+ // Inspect the navigation receiver for a URL(...) constructor with a
+ // non-literal argument.
+ recv := getKotlinNavReceiver(n)
+ if recv == nil {
+ return
+ }
+ if !receiverHasDynamicURL(recv) {
+ return
+ }
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-KT-AST-008",
+ Severity: rules.High,
+ SeverityLabel: rules.High.String(),
+ Title: "Server-side request forgery via URL." + method,
+ Description: "A java.net.URL constructed from a non-literal target is opened with " + method + "(). If the target is attacker-controlled, this enables SSRF — reaching internal services, cloud metadata endpoints, or arbitrary hosts.",
+ FilePath: c.filePath,
+ LineNumber: int(n.StartRow()) + 1,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: "Validate the URL against an allowlist of permitted hosts/schemes before opening it. Reject internal/loopback/link-local addresses and disable following redirects to them.",
+ CWEID: "CWE-918",
+ OWASPCategory: "A10:2021-Server-Side Request Forgery",
+ Language: rules.LangKotlin,
+ Confidence: "high",
+ Tags: []string{"ssrf", "request-forgery"},
+ })
+}
+
// checkRawQuery detects db.rawQuery("..." + var, ...) patterns.
func (c *ktChecker) checkRawQuery(n *ast.Node) {
methodName := getKotlinMethodName(n)
@@ -281,6 +434,112 @@ func containsKotlinConcatOrTemplate(n *ast.Node) bool {
return found
}
+// getKotlinConstructorName returns the class name when n is a constructor-style
+// call_expression whose callee is a bare simple_identifier (no navigation),
+// e.g. `ObjectInputStream(...)` -> "ObjectInputStream". Navigation calls
+// (`mapper.readValue(...)`) return "".
+func getKotlinConstructorName(n *ast.Node) string {
+ children := n.NamedChildren()
+ if len(children) == 0 {
+ return ""
+ }
+ // A constructor call's first named child is the simple_identifier callee,
+ // directly followed by a call_suffix. Navigation calls have a
+ // navigation_expression as the first child instead.
+ first := children[0]
+ if first.Type() == "simple_identifier" {
+ return first.Text()
+ }
+ return ""
+}
+
+// getKotlinNavReceiver returns the receiver node of a navigation call
+// (the left side of the final `.method`), e.g. for `URL(x).openConnection()`
+// it returns the `URL(x)` node. Returns nil for non-navigation calls.
+func getKotlinNavReceiver(n *ast.Node) *ast.Node {
+ for _, child := range n.NamedChildren() {
+ if child.Type() == "navigation_expression" {
+ nc := child.NamedChildren()
+ if len(nc) > 0 {
+ return nc[0]
+ }
+ }
+ }
+ return nil
+}
+
+// argIsDynamicString reports whether a value_argument's expression is a
+// dynamically constructed string: a concatenation (additive_expression) or a
+// string literal containing a Kotlin ${...} interpolation. A plain string
+// literal with only static content is NOT dynamic.
+func argIsDynamicString(arg *ast.Node) bool {
+ found := false
+ arg.Walk(func(child *ast.Node) bool {
+ if found {
+ return false
+ }
+ switch child.Type() {
+ case "additive_expression":
+ found = true
+ return false
+ case "interpolated_expression", "string_template_expression":
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// receiverHasDynamicURL reports whether the receiver chain contains a
+// `URL()` constructor whose first argument is NOT a static string literal
+// (i.e. a variable or expression), the structural signal for SSRF.
+func receiverHasDynamicURL(recv *ast.Node) bool {
+ found := false
+ recv.Walk(func(child *ast.Node) bool {
+ if found {
+ return false
+ }
+ if child.Type() != "call_expression" {
+ return true
+ }
+ if getKotlinConstructorName(child) != "URL" {
+ return true
+ }
+ args := getKotlinCallArgs(child)
+ if len(args) == 0 {
+ return true
+ }
+ if !argIsStaticStringLiteral(args[0]) {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// argIsStaticStringLiteral reports whether a value_argument is exactly a static
+// string literal with no interpolation (so `URL("http://x")` is treated as safe,
+// while `URL(target)` or `URL("$h/x")` is treated as dynamic/non-literal).
+func argIsStaticStringLiteral(arg *ast.Node) bool {
+ children := arg.NamedChildren()
+ if len(children) != 1 {
+ return false
+ }
+ lit := children[0]
+ if lit.Type() != "string_literal" {
+ return false
+ }
+ // A literal that embeds an interpolation is not static.
+ for _, lc := range lit.NamedChildren() {
+ if lc.Type() == "interpolated_expression" || lc.Type() == "interpolation" {
+ return false
+ }
+ }
+ return true
+}
+
func truncate(s string, max int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\t", " ")
diff --git a/batou-core/analyzer/ktast/ktast_test.go b/batou-core/analyzer/ktast/ktast_test.go
index 37fb3cc..5b02c3b 100644
--- a/batou-core/analyzer/ktast/ktast_test.go
+++ b/batou-core/analyzer/ktast/ktast_test.go
@@ -1,11 +1,10 @@
package ktast
import (
- "strings"
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "strings"
+ "testing"
)
func scanKt(t *testing.T, code string) []rules.Finding {
@@ -134,6 +133,138 @@ fun runCommand(cmd: String) {
}
}
+func hasRule(findings []rules.Finding, id string) bool {
+ for _, f := range findings {
+ if f.RuleID == id {
+ return true
+ }
+ }
+ return false
+}
+
+func TestUnsafeDeserialization(t *testing.T) {
+ code := `
+fun handle(req: HttpServletRequest): Any {
+ val ois = ObjectInputStream(req.inputStream)
+ return ois.readObject()
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-005") {
+ t.Error("expected CWE-502 finding for ObjectInputStream constructor")
+ }
+}
+
+func TestUnsafeDeserializationXMLDecoder(t *testing.T) {
+ code := `
+fun handle(req: HttpServletRequest): Any {
+ val dec = XMLDecoder(req.inputStream)
+ return dec.readObject()
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-005") {
+ t.Error("expected CWE-502 finding for XMLDecoder constructor")
+ }
+}
+
+func TestDeserializationNoFalsePositiveOnReadValue(t *testing.T) {
+ // A typed readValue without default typing is not flagged structurally.
+ code := `
+fun parse(mapper: ObjectMapper, data: String): MyDto {
+ return mapper.readValue(data, MyDto::class.java)
+}
+`
+ findings := scanKt(t, code)
+ if hasRule(findings, "BATOU-KT-AST-005") || hasRule(findings, "BATOU-KT-AST-006") {
+ t.Error("unexpected deserialization finding for safe typed readValue")
+ }
+}
+
+func TestJacksonDefaultTyping(t *testing.T) {
+ code := `
+fun config(mapper: ObjectMapper) {
+ mapper.enableDefaultTyping()
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-006") {
+ t.Error("expected CWE-502 finding for Jackson enableDefaultTyping")
+ }
+}
+
+func TestJacksonActivateDefaultTyping(t *testing.T) {
+ code := `
+fun config(mapper: ObjectMapper, ptv: PolymorphicTypeValidator) {
+ mapper.activateDefaultTyping(ptv)
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-006") {
+ t.Error("expected CWE-502 finding for Jackson activateDefaultTyping")
+ }
+}
+
+func TestSSTIConcat(t *testing.T) {
+ code := `
+fun render(engine: TemplateEngine, ctx: Context, name: String): String {
+ return engine.process("Hello " + name, ctx)
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-007") {
+ t.Error("expected CWE-1336 SSTI finding for concatenated template source")
+ }
+}
+
+func TestSSTIInterpolation(t *testing.T) {
+ code := `
+fun render(handlebars: Handlebars, name: String) {
+ handlebars.compileInline("Hi ${name}")
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-007") {
+ t.Error("expected CWE-1336 SSTI finding for interpolated template source")
+ }
+}
+
+func TestSSTISafeStaticTemplate(t *testing.T) {
+ code := `
+fun render(engine: TemplateEngine, ctx: Context): String {
+ return engine.process("welcome-page", ctx)
+}
+`
+ findings := scanKt(t, code)
+ if hasRule(findings, "BATOU-KT-AST-007") {
+ t.Error("unexpected SSTI finding for static literal template name")
+ }
+}
+
+func TestSSRFDynamicURL(t *testing.T) {
+ code := `
+fun fetch(target: String): String {
+ return URL(target).openConnection().getInputStream().bufferedReader().readText()
+}
+`
+ findings := scanKt(t, code)
+ if !hasRule(findings, "BATOU-KT-AST-008") {
+ t.Error("expected CWE-918 SSRF finding for URL built from variable")
+ }
+}
+
+func TestSSRFSafeLiteralURL(t *testing.T) {
+ code := `
+fun health(): String {
+ return URL("https://internal.example.com/health").readText()
+}
+`
+ findings := scanKt(t, code)
+ if hasRule(findings, "BATOU-KT-AST-008") {
+ t.Error("unexpected SSRF finding for hardcoded literal URL")
+ }
+}
+
func TestSafeCode(t *testing.T) {
code := `
fun greet(name: String): String {
diff --git a/batou-core/analyzer/luaast/luaast_test.go b/batou-core/analyzer/luaast/luaast_test.go
index 3c97946..a76b1d5 100644
--- a/batou-core/analyzer/luaast/luaast_test.go
+++ b/batou-core/analyzer/luaast/luaast_test.go
@@ -3,7 +3,6 @@ package luaast
import (
"strings"
"testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
)
diff --git a/batou-core/analyzer/perlast/perlast.go b/batou-core/analyzer/perlast/perlast.go
new file mode 100644
index 0000000..78d8926
--- /dev/null
+++ b/batou-core/analyzer/perlast/perlast.go
@@ -0,0 +1,736 @@
+// Package perlast provides Layer-2 (AST) structural security analysis for
+// Perl source code. Until now Perl leaned entirely on Layer-1 regex rules plus
+// the Layer-3 tsflow taint engine; it had no dedicated AST analyzer. This
+// package mirrors the structure of analyzer/luaast and analyzer/pyast: it
+// self-registers via init() -> rules.Register, gates on
+// ctx.Language == rules.LangPerl, walks the tree-sitter Perl tree, and emits
+// structural findings for the classic Perl injection sinks.
+//
+// EXTERNAL-ORIGIN GATING (the precision fix)
+//
+// An earlier purely-structural version of this analyzer fired on every
+// occurrence of a dangerous *shape* "independent of whether the data
+// originates from a recognised taint source". On a real Mojolicious checkout
+// that produced 6 findings, all false positives: list-form `open '-|', $^X,
+// ...` pipes (which never reach a shell) and an `s///e` whose replacement was a
+// fixed `_entity($1, $2, $attr)` call. The blind shape signal cannot tell a
+// SAFE local/constant operand from a user-controlled one, so it floods on the
+// overwhelmingly common safe uses.
+//
+// The fix mirrors how analyzer/pyast reasons about safety before emitting:
+// before flagging a flood-prone sink, the analyzer establishes (via a bounded
+// intra-file backward scan + a name/shape allowlist of known Perl external
+// sources) that the interpolated operand is plausibly EXTERNAL / user
+// controlled. If external origin cannot be established, the finding is
+// suppressed. This keeps the genuine catch — user input reaching a shell /
+// code-reparse sink, including through a hand-rolled %FORM hash the taint
+// catalog doesn't model — while dropping the safe-shape noise.
+//
+// TWO STRUCTURAL BUG FIXES that the flood exposed:
+// - open(): the LIST / exec form `open my $fh, '-|', $^X, $script` (3+ args,
+// or a literal '-|'/'|-' mode argument) does NOT pass through a shell and
+// is therefore NOT shell-injectable. It is never flagged. Only the SHELL
+// form is flagged: 2-arg piped open `open(FH, "cmd $var |")`.
+// - s///e: a replacement that is a FIXED function call over regex captures /
+// static arguments (e.g. `_entity($1, $2, $attr)`) is not eval injection —
+// $1/$2 are captures and the callee is static. Only an /e replacement that
+// interpolates a variable directly into code (e.g. `$expr`, `"do_$cmd()"`)
+// is flagged.
+//
+// Comment/POD text never reaches the analyzer because we only ever inspect real
+// AST sink nodes — never raw lines.
+package perlast
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// PerlASTAnalyzer performs AST-based structural security analysis of Perl code.
+type PerlASTAnalyzer struct{}
+
+func init() {
+ rules.Register(&PerlASTAnalyzer{})
+}
+
+func (p *PerlASTAnalyzer) ID() string { return "BATOU-PERL-AST" }
+func (p *PerlASTAnalyzer) Name() string { return "Perl AST Security Analyzer" }
+func (p *PerlASTAnalyzer) DefaultSeverity() rules.Severity { return rules.High }
+func (p *PerlASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangPerl} }
+func (p *PerlASTAnalyzer) Description() string {
+ return "AST-based structural analysis of Perl for OS command injection (system/exec/backticks/qx), " +
+ "path/command injection via open() (2-arg and piped forms), and code execution via the s///e substitution modifier. " +
+ "Findings are gated on plausibly-external (user-controlled) operands established by a bounded intra-file backward scan."
+}
+
+func (p *PerlASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
+ if ctx.Language != rules.LangPerl {
+ return nil
+ }
+ tree := ast.TreeFromContext(ctx)
+ if tree == nil {
+ return nil
+ }
+ content := ctx.Content
+ if content == "" {
+ content = ctx.OriginalContent
+ }
+ c := &perlChecker{
+ tree: tree,
+ filePath: ctx.FilePath,
+ content: content,
+ // externalHashes is the set of hash names (e.g. "FORM") that this file
+ // populates from an external source — the hand-rolled-request-hash
+ // idiom. Computed lazily on first external-origin check.
+ }
+ c.walk()
+ return c.findings
+}
+
+type perlChecker struct {
+ tree *ast.Tree
+ filePath string
+ content string
+ findings []rules.Finding
+
+ // externalHashes maps a Perl hash base-name ("FORM" for %FORM / $FORM{...})
+ // to true when this file assigns into that hash from an external source.
+ // nil until computed by ensureExternalHashes().
+ externalHashes map[string]bool
+}
+
+func (c *perlChecker) walk() {
+ root := c.tree.Root()
+ if root == nil {
+ return
+ }
+ root.Walk(func(n *ast.Node) bool {
+ switch n.Type() {
+ case "function_call_expression", "ambiguous_function_call_expression":
+ c.checkCommandFunc(n)
+ c.checkOpen(n)
+ case "command_string":
+ // Backticks `...` and qx// — command execution via the shell.
+ c.checkBacktick(n)
+ case "binary_expression":
+ // $x =~ s/.../.../e parses as a binary_expression whose right
+ // child is a substitution_regexp with an /e modifier.
+ c.checkSubstEval(n)
+ }
+ return true
+ })
+}
+
+// perlFuncName returns the called function/builtin name for a
+// function_call_expression / ambiguous_function_call_expression node.
+func perlFuncName(n *ast.Node) string {
+ fn := n.ChildByFieldName("function")
+ if fn != nil {
+ return strings.TrimSpace(fn.Text())
+ }
+ return ""
+}
+
+// argsNode returns the node holding the call's arguments, or nil. Perl wraps
+// multiple args in a list_expression; a single arg may be attached directly
+// (e.g. an interpolated_string_literal) under the "arguments" field.
+func argsNode(n *ast.Node) *ast.Node {
+ return n.ChildByFieldName("arguments")
+}
+
+// ---------------------------------------------------------------------------
+// External-origin gating
+// ---------------------------------------------------------------------------
+
+// reExternalSource is a name/shape allowlist of known Perl external (user
+// controlled) sources, mirroring the Perl taint source catalog
+// (taint/languages/perl_sources.go). A sink operand whose value flows from one
+// of these is treated as plausibly external. This is deliberately broad on the
+// "is it user-controlled" question because the alternative (the held analyzer)
+// was broad on the "is it a dangerous shape" question, which flooded.
+var reExternalSource = regexp.MustCompile(
+ // CGI.pm
+ `\$\w+->param\s*\(` + `|` + `->Vars\b` +
+ // Plack / PSGI / Dancer / Catalyst / Mojolicious request objects
+ `|->param\s*\(` + `|->params\b` + `|->parameters\b` +
+ `|->query_parameters\b` + `|->body_parameters\b` + `|->every_param\b` +
+ `|->req(?:uest)?->` + `|->res->` +
+ `|->body\b` + `|->raw_body\b` + `|->content\b` +
+ `|->cookies?\b` + `|->upload\b` + `|->uploads\b` +
+ `|->path(?:_info)?\b` + `|->request_uri\b` +
+ `|->referer\b` + `|->user_agent\b` + `|->header(?:s)?\b` +
+ `|\bparams->\{` + `|\bparam\s*\(` +
+ // CLI / stdin / environment
+ `|\@ARGV\b` + `|\$ARGV\[` + `|` + `|\bSTDIN\b` + `|\$ENV\{` +
+ // PSGI env hash
+ `|\$env->\{` +
+ // decoded request payloads
+ `|\bdecode_json\s*\(` + `|\bfrom_json\s*\(` +
+ // network reads
+ `|->recv\s*\(` + `|->decoded_content\s*\(`,
+)
+
+// hashAssignRe and friends recognise the hand-rolled request-hash idiom:
+//
+// foreach my $pair (split /&/, $ENV{'QUERY_STRING'}) { $FORM{$k} = $v }
+//
+// We treat %FORM as external when ANY assignment into it (or the split that
+// feeds it) is fed by an external source on the same line.
+var (
+ // `$FORM{...} = ...` (assignment INTO a hash element)
+ reHashElemAssign = regexp.MustCompile(`\$(\w+)\s*\{[^}]*\}\s*=`)
+ // `%FORM = ...` (whole-hash assignment, e.g. = $cgi->Vars / split ...)
+ reHashWholeAssign = regexp.MustCompile(`%(\w+)\s*=`)
+ // `my ($k,$v) = split /=/, $pair` style helpers don't matter; we look at the
+ // loop header `foreach ... ($ENV{'QUERY_STRING'})` separately via the line.
+)
+
+// scalarAssignRe captures `my $foo = RHS` / `$foo = RHS` so we can backward-scan
+// for the last assignment to a sink variable.
+func assignRHSFor(content, name string) (rhs string, found bool) {
+ // Match the LAST `[my] $name = ...` up to end of statement (`;` or newline).
+ re := regexp.MustCompile(`(?m)(?:^|[^>\w])(?:my\s+)?\$` + regexp.QuoteMeta(name) + `\s*=\s*([^;\n]*)`)
+ ms := re.FindAllStringSubmatch(content, -1)
+ if len(ms) == 0 {
+ return "", false
+ }
+ // last assignment wins
+ return strings.TrimSpace(ms[len(ms)-1][1]), true
+}
+
+// ensureExternalHashes scans the whole file once for hashes populated from an
+// external source (the %FORM idiom). A hash base-name lands in the set when an
+// assignment INTO it, or a foreach/while loop body around such an assignment,
+// references an external source on a nearby line.
+func (c *perlChecker) ensureExternalHashes() {
+ if c.externalHashes != nil {
+ return
+ }
+ c.externalHashes = map[string]bool{}
+ lines := strings.Split(c.content, "\n")
+ for i, ln := range lines {
+ // whole-hash assignment: %H = or %H = $cgi->Vars
+ if m := reHashWholeAssign.FindStringSubmatch(ln); m != nil {
+ if reExternalSource.MatchString(ln) {
+ c.externalHashes[m[1]] = true
+ }
+ }
+ // element assignment: $H{...} = ... — external if the RHS, or the
+ // enclosing loop header (a few lines up), reads an external source.
+ if m := reHashElemAssign.FindStringSubmatch(ln); m != nil {
+ window := ln
+ // look back up to 4 lines for a foreach/while loop header feeding it
+ for j := i - 1; j >= 0 && j >= i-4; j-- {
+ window += "\n" + lines[j]
+ t := strings.TrimSpace(lines[j])
+ if strings.HasPrefix(t, "foreach") || strings.HasPrefix(t, "for ") ||
+ strings.HasPrefix(t, "while") || strings.HasPrefix(t, "for(") {
+ break
+ }
+ }
+ if reExternalSource.MatchString(window) {
+ c.externalHashes[m[1]] = true
+ }
+ }
+ }
+}
+
+// exprIsExternal reports whether a raw expression string is (directly) an
+// external source: e.g. `$cgi->param('x')`, `$ENV{'Q'}`, `$ARGV[0]`, or an
+// element of a hash this file populated from an external source (`$FORM{'x'}`).
+func (c *perlChecker) exprIsExternal(expr string) bool {
+ if expr == "" {
+ return false
+ }
+ if reExternalSource.MatchString(expr) {
+ return true
+ }
+ // element of an externally-populated hash: $FORM{'host'}
+ if m := regexp.MustCompile(`\$(\w+)\s*\{`).FindStringSubmatch(expr); m != nil {
+ c.ensureExternalHashes()
+ if c.externalHashes[m[1]] {
+ return true
+ }
+ }
+ return false
+}
+
+// varIsExternal establishes, via a bounded intra-file backward scan, whether a
+// scalar variable named `name` (no leading $) plausibly holds external data.
+// depth guards against assignment cycles. It returns true when the variable's
+// last assignment RHS is an external source, the element of an externally
+// populated hash, or another variable that is itself external.
+func (c *perlChecker) varIsExternal(name string, depth int) bool {
+ if name == "" || depth > 4 {
+ return false
+ }
+ rhs, ok := assignRHSFor(c.content, name)
+ if !ok {
+ return false
+ }
+ if c.exprIsExternal(rhs) {
+ return true
+ }
+ // RHS is `$other` or interpolates `$other` / `$other->...`: follow it.
+ for _, m := range regexp.MustCompile(`\$(\w+)`).FindAllStringSubmatch(rhs, -1) {
+ next := m[1]
+ if next == name || isAllDigits(next) {
+ continue
+ }
+ if c.varIsExternal(next, depth+1) {
+ return true
+ }
+ }
+ return false
+}
+
+// nodeHasExternalOperand reports whether ANY scalar/array variable interpolated
+// inside the subtree n is plausibly external (user-controlled). This is the
+// gate the flood-prone rules consult before emitting. A bare external-source
+// expression spliced directly into the string (e.g. `$ENV{...}` inside the
+// command) also counts.
+func (c *perlChecker) nodeHasExternalOperand(n *ast.Node) bool {
+ if n == nil {
+ return false
+ }
+ // Direct splice of an external source into the node text (covers
+ // `system("ping $ENV{X}")` and similar where the source IS the operand).
+ if c.exprIsExternal(n.Text()) {
+ return true
+ }
+ external := false
+ n.Walk(func(d *ast.Node) bool {
+ if external {
+ return false
+ }
+ if d.Type() == "scalar" || d.Type() == "array" {
+ name := varBareName(d)
+ if name == "" || isAllDigits(name) {
+ return true
+ }
+ if c.varIsExternal(name, 0) {
+ external = true
+ return false
+ }
+ }
+ // hash/array element directly interpolated: $FORM{'host'}
+ if d.Type() == "hash_element_expression" || d.Type() == "array_element_expression" {
+ if c.exprIsExternal(d.Text()) {
+ external = true
+ return false
+ }
+ }
+ return true
+ })
+ return external
+}
+
+// hasInterpolatedVar reports whether the subtree rooted at n contains a Perl
+// variable interpolation (a scalar/array variable that is NOT a pure literal).
+func hasInterpolatedVar(n *ast.Node) bool {
+ if n == nil {
+ return false
+ }
+ found := false
+ n.Walk(func(inner *ast.Node) bool {
+ if found {
+ return false
+ }
+ switch inner.Type() {
+ case "scalar", "array", "hash_element_expression", "array_element_expression":
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+// hasStringInterpolatedVar reports whether n contains an interpolated_string_literal
+// or command_string that itself interpolates a real (non-digit) variable — i.e.
+// a shell/format string with a $var spliced in.
+func hasStringInterpolatedVar(n *ast.Node) bool {
+ if n == nil {
+ return false
+ }
+ found := false
+ n.Walk(func(inner *ast.Node) bool {
+ if found {
+ return false
+ }
+ if inner.Type() == "interpolated_string_literal" || inner.Type() == "command_string" {
+ inner.Walk(func(d *ast.Node) bool {
+ if found {
+ return false
+ }
+ if d.Type() == "scalar" || d.Type() == "array" {
+ name := varBareName(d)
+ if name != "" && !isAllDigits(name) {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ }
+ return true
+ })
+ return found
+}
+
+func varBareName(scalarNode *ast.Node) string {
+ vn := scalarNode.ChildByFieldName("name")
+ if vn == nil {
+ for _, c := range scalarNode.NamedChildren() {
+ if c.Type() == "varname" {
+ return strings.TrimSpace(c.Text())
+ }
+ }
+ return ""
+ }
+ return strings.TrimSpace(vn.Text())
+}
+
+func isAllDigits(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+// checkCommandFunc detects system()/exec() with a SHELL-interpolated, plausibly
+// EXTERNAL variable — the canonical Perl OS command injection (CWE-78).
+//
+// Only the shell form is flagged: a single command STRING that splices in a
+// variable. The list form system($prog, @args) does not go through a shell and
+// is never flagged (no shell-interpolated string). Additionally the
+// interpolated operand must be plausibly external (gate), so `system("cp
+// $src $dst")` over local paths is not flagged.
+func (c *perlChecker) checkCommandFunc(n *ast.Node) {
+ name := perlFuncName(n)
+ if name != "system" && name != "exec" {
+ return
+ }
+ args := argsNode(n)
+ if args == nil {
+ return
+ }
+ // Shell form requires a single interpolated command string. The list form
+ // (multiple comma-separated args) does not produce a shell-interpolated
+ // string and so is filtered here.
+ if !hasStringInterpolatedVar(args) {
+ return
+ }
+ // List form guard: if the call has more than one top-level argument, it is
+ // the safe exec form (system($prog, @args)) even if one happens to be a
+ // string — the shell is bypassed.
+ if isMultiArgList(args) {
+ return
+ }
+ // External-origin gate.
+ if !c.nodeHasExternalOperand(args) {
+ return
+ }
+ c.add(rules.Finding{
+ RuleID: "BATOU-PERL-AST-001",
+ Severity: rules.Critical,
+ Title: "OS command injection via " + name + "()",
+ Description: name + "() with a shell-interpolated, user-controlled variable passes the argument through /bin/sh. The interpolated value flows from an external source (CGI/Plack/Dancer request, @ARGV, %ENV, STDIN, or a request-populated hash), enabling arbitrary command execution.",
+ Suggestion: "Use the list form (" + name + " $prog, @args) which bypasses the shell, or validate/escape the variable. Never interpolate untrusted data into a single command string.",
+ CWEID: "CWE-78",
+ OWASPCategory: "A03:2021-Injection",
+ Tags: []string{"command-injection", "injection", "rce"},
+ }, n)
+}
+
+// isMultiArgList reports whether the call's argument node is a list with more
+// than one top-level argument (the safe list/exec form for system/exec).
+func isMultiArgList(args *ast.Node) bool {
+ if args.Type() != "list_expression" {
+ return false
+ }
+ return len(args.NamedChildren()) > 1
+}
+
+// checkBacktick detects backtick `...` and qx// command execution with a
+// plausibly-external interpolated variable (CWE-78).
+func (c *perlChecker) checkBacktick(n *ast.Node) {
+ if !backtickInterpolatesVar(n) {
+ return
+ }
+ if !c.nodeHasExternalOperand(n) {
+ return
+ }
+ c.add(rules.Finding{
+ RuleID: "BATOU-PERL-AST-002",
+ Severity: rules.Critical,
+ Title: "OS command injection via backticks/qx",
+ Description: "Backtick (`...`) and qx// command execution run the string through the shell. A user-controlled variable (flowing from a request/CLI/env source) interpolated into the command enables arbitrary command execution.",
+ Suggestion: "Avoid backticks/qx with untrusted data. Use the list form of system()/open() with explicit arguments, or validate and escape the input.",
+ CWEID: "CWE-78",
+ OWASPCategory: "A03:2021-Injection",
+ Tags: []string{"command-injection", "injection", "rce"},
+ }, n)
+}
+
+func backtickInterpolatesVar(n *ast.Node) bool {
+ found := false
+ n.Walk(func(d *ast.Node) bool {
+ if found {
+ return false
+ }
+ if d.Type() == "scalar" || d.Type() == "array" {
+ name := varBareName(d)
+ if name != "" && !isAllDigits(name) {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// checkOpen detects the SHELL-form dangerous open(): 2-arg piped open
+// ("cmd $var |" / "| cmd") and 2-arg open whose filename string carries the
+// mode and an interpolated variable (path traversal). The interpolated operand
+// must be plausibly external.
+//
+// The LIST / exec form is NEVER flagged:
+// - 3+ argument open (open my $fh, '-|', $^X, $prog, @args) — even when the
+// mode argument is a literal '-|'/'|-' pipe — does NOT pass through a shell.
+// It execs the program directly (list form), so it is not shell-injectable.
+// - the safe 3-arg open(my $fh, '<', $path) idiom — the mode is a separate
+// literal, so the filename can't smuggle a pipe; the taint layer covers
+// path-traversal on the clean cases.
+func (c *perlChecker) checkOpen(n *ast.Node) {
+ if perlFuncName(n) != "open" {
+ return
+ }
+ args := argsNode(n)
+ if args == nil {
+ return
+ }
+
+ var argList []*ast.Node
+ if args.Type() == "list_expression" {
+ argList = append(argList, args.NamedChildren()...)
+ } else {
+ argList = append(argList, args)
+ }
+ if len(argList) == 0 {
+ return
+ }
+
+ // LIST / exec form: 3+ arguments. The mode (2nd arg) is a SEPARATE literal,
+ // so the filename cannot smuggle a pipe and a literal '-|'/'|-' mode is the
+ // direct-exec list form that bypasses the shell. Never flag (this was the
+ // AST-003 flood: `open my $fh, '-|', $^X, $script`).
+ if len(argList) >= 3 {
+ return
+ }
+
+ // 2-arg (or 1-arg) open: the single filename string carries the mode AND can
+ // contain a leading/trailing pipe — the shell form.
+ for i := 0; i < len(argList); i++ {
+ arg := argList[i]
+ // Skip the bareword/lexical filehandle (FH, my $fh) — it's the handle.
+ if arg.Type() == "variable_declaration" || arg.Type() == "bareword" {
+ continue
+ }
+ if !hasInterpolatedVar(arg) {
+ continue
+ }
+ // External-origin gate.
+ if !c.nodeHasExternalOperand(arg) {
+ return
+ }
+ text := arg.Text()
+ pipe := strings.Contains(text, "|")
+ if pipe {
+ c.addOpen(n, "CWE-78", "command injection via 2-arg piped open()",
+ "2-arg open() with a pipe and an interpolated, user-controlled variable runs a shell command. External input enables command injection.")
+ } else {
+ c.addOpen(n, "CWE-22", "path traversal / mode injection via 2-arg open()",
+ "2-arg open() lets the filename string carry the access mode and shell pipes. A user-controlled variable enables path traversal (../) and mode/command injection.")
+ }
+ return
+ }
+}
+
+func (c *perlChecker) addOpen(n *ast.Node, cwe, title, desc string) {
+ sev := rules.High
+ if cwe == "CWE-78" {
+ sev = rules.Critical
+ }
+ owasp := "A01:2021-Broken Access Control"
+ tags := []string{"path-traversal", "traversal"}
+ if cwe == "CWE-78" {
+ owasp = "A03:2021-Injection"
+ tags = []string{"command-injection", "injection", "rce"}
+ }
+ c.add(rules.Finding{
+ RuleID: "BATOU-PERL-AST-003",
+ Severity: sev,
+ Title: title,
+ Description: desc,
+ Suggestion: "Use the 3-arg open(my $fh, '<', $path) form with an explicit literal mode, and validate $path against directory traversal. Never pass user input to a 2-arg open.",
+ CWEID: cwe,
+ OWASPCategory: owasp,
+ Tags: tags,
+ }, n)
+}
+
+// checkSubstEval detects the s///e substitution modifier (Perl's "evaluate the
+// replacement as code") where the replacement DYNAMICALLY builds code from a
+// variable — a classic Perl RCE vector (CWE-94).
+//
+// It does NOT flag a replacement that is a fixed function call applied to regex
+// captures / static arguments (e.g. `_entity($1, $2, $attr)`): $1/$2 are
+// captures, the callee is static, and the arguments are not interpolated into
+// new code. That was the AST-004 flood on Mojo::Util::_html.
+func (c *perlChecker) checkSubstEval(n *ast.Node) {
+ right := n.ChildByFieldName("right")
+ if right == nil || right.Type() != "substitution_regexp" {
+ return
+ }
+ hasEvalMod := false
+ var replacement *ast.Node
+ for _, ch := range right.NamedChildren() {
+ switch ch.Type() {
+ case "substitution_regexp_modifiers":
+ for _, r := range ch.Text() {
+ if r == 'e' {
+ hasEvalMod = true
+ }
+ }
+ case "replacement":
+ replacement = ch
+ }
+ }
+ if !hasEvalMod || replacement == nil {
+ return
+ }
+ // The replacement must interpolate a variable directly into code (dynamic
+ // code build) — NOT be a fixed function call over captures/static args.
+ if !replacementBuildsDynamicCode(replacement) {
+ return
+ }
+ // External-origin gate: the variable spliced into the /e code should be
+ // plausibly user-controlled.
+ if !c.nodeHasExternalOperand(replacement) {
+ return
+ }
+ c.add(rules.Finding{
+ RuleID: "BATOU-PERL-AST-004",
+ Severity: rules.Critical,
+ Title: "Code execution via s///e substitution",
+ Description: "The /e modifier evaluates the substitution replacement as Perl code. A user-controlled variable interpolated directly into the replacement lets attacker-controlled data execute as code.",
+ Suggestion: "Remove the /e modifier, or never interpolate untrusted data into an /e replacement. Use a code reference with validated inputs if dynamic replacement is required.",
+ CWEID: "CWE-94",
+ OWASPCategory: "A03:2021-Injection",
+ Tags: []string{"code-injection", "injection", "rce"},
+ }, n)
+}
+
+// reStaticFuncCall matches a replacement that is (entirely) a single function
+// call: `name(...)` possibly with leading/trailing whitespace. When the
+// replacement IS such a call, the callee is static and the dynamic-code risk
+// comes only from arguments that are themselves interpolated code — which is
+// rare. We treat a fixed-call replacement whose arguments are only captures /
+// simple scalars as NON-dynamic.
+var reStaticFuncCall = regexp.MustCompile(`^\s*&?\w[\w:]*\s*\([^)]*\)\s*;?\s*$`)
+
+// replacementBuildsDynamicCode reports whether an /e replacement interpolates a
+// variable directly into code, rather than being a fixed function call over
+// captures / static arguments.
+//
+// $expr -> dynamic (bare scalar IS the code)
+// "do_$cmd()" -> dynamic (string interpolation builds code)
+// $a + $b -> dynamic (expression over scalars, no fixed call)
+// _entity($1, $2, $attr) -> NOT dynamic (fixed callee, capture/scalar args)
+// uc($1) -> NOT dynamic
+// 2 + 2 -> NOT dynamic (no variable; handled by var check)
+func replacementBuildsDynamicCode(replacement *ast.Node) bool {
+ text := strings.TrimSpace(replacement.Text())
+
+ // A replacement that interpolates a variable INSIDE a string literal builds
+ // code dynamically: s/RE/"system('$cmd')"/e.
+ if hasStringInterpolatedVar(replacement) {
+ return true
+ }
+
+ // A bare scalar replacement ($expr) executes that variable's contents as
+ // code: this is the textbook s///e injection.
+ if named := replacement.NamedChildren(); len(named) == 1 {
+ ch := named[0]
+ if ch.Type() == "scalar" {
+ name := varBareName(ch)
+ if name != "" && !isAllDigits(name) {
+ return true
+ }
+ }
+ }
+
+ // A fixed function call over captures/args is NOT dynamic. _entity($1,$2,$x)
+ // has a static callee; the captures and $attr are data, not new code.
+ if reStaticFuncCall.MatchString(text) {
+ return false
+ }
+
+ // Otherwise: an expression that mixes a non-capture scalar into code
+ // (e.g. `$a . qx/$x/`, `$base * $n`) is treated as dynamic.
+ return replacementHasNonCaptureVar(replacement)
+}
+
+// replacementHasNonCaptureVar reports whether the replacement references a
+// scalar/array variable that is NOT a numeric regex capture ($1..$9).
+func replacementHasNonCaptureVar(n *ast.Node) bool {
+ found := false
+ n.Walk(func(d *ast.Node) bool {
+ if found {
+ return false
+ }
+ if d.Type() == "scalar" || d.Type() == "array" {
+ name := varBareName(d)
+ if name != "" && !isAllDigits(name) {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// add finalises a finding: it fills the position/match fields from the node and
+// appends to the result set.
+func (c *perlChecker) add(f rules.Finding, n *ast.Node) {
+ f.SeverityLabel = f.Severity.String()
+ f.FilePath = c.filePath
+ f.Language = rules.LangPerl
+ f.Confidence = "high"
+ if n != nil {
+ f.LineNumber = int(n.StartRow()) + 1
+ f.Column = int(n.StartCol()) + 1
+ f.MatchedText = truncate(n.Text(), 200)
+ }
+ c.findings = append(c.findings, f)
+}
+
+func truncate(s string, max int) string {
+ s = strings.ReplaceAll(s, "\n", " ")
+ s = strings.ReplaceAll(s, "\t", " ")
+ if len(s) > max {
+ return s[:max] + "..."
+ }
+ return s
+}
diff --git a/batou-core/analyzer/perlast/perlast_test.go b/batou-core/analyzer/perlast/perlast_test.go
new file mode 100644
index 0000000..0d9139f
--- /dev/null
+++ b/batou-core/analyzer/perlast/perlast_test.go
@@ -0,0 +1,272 @@
+package perlast
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+func scanPerl(t *testing.T, code string) []rules.Finding {
+ t.Helper()
+ tree := ast.Parse([]byte(code), rules.LangPerl)
+ ctx := &rules.ScanContext{
+ FilePath: "/app/cgi.pl",
+ Content: code,
+ Language: rules.LangPerl,
+ Tree: tree,
+ }
+ a := &PerlASTAnalyzer{}
+ return a.Scan(ctx)
+}
+
+func hasFinding(findings []rules.Finding, ruleID, cwe string) *rules.Finding {
+ for i := range findings {
+ if findings[i].RuleID == ruleID && findings[i].CWEID == cwe {
+ return &findings[i]
+ }
+ }
+ return nil
+}
+
+func TestSystemCommandInjection(t *testing.T) {
+ code := `my $host = $cgi->param("host");
+system("ping $host");
+`
+ f := hasFinding(scanPerl(t, code), "BATOU-PERL-AST-001", "CWE-78")
+ if f == nil {
+ t.Fatal("expected CWE-78 system() command injection finding")
+ }
+ if f.Severity != rules.Critical {
+ t.Errorf("expected Critical, got %s", f.Severity)
+ }
+}
+
+func TestExecCommandInjection(t *testing.T) {
+ // External origin: @ARGV is a CLI-argument source in the allowlist.
+ code := `my $p = $ARGV[0];
+exec("$p --run");
+`
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-001", "CWE-78") == nil {
+ t.Fatal("expected CWE-78 exec() command injection finding")
+ }
+}
+
+func TestBacktickInjection(t *testing.T) {
+ code := "my $cmd = $cgi->param('cmd');\nmy $out = `ls $cmd`;\n"
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-002", "CWE-78") == nil {
+ t.Fatal("expected CWE-78 backtick command injection finding")
+ }
+}
+
+func TestQxInjection(t *testing.T) {
+ code := "my $u = $req->param('user');\nmy $z = qx/whoami $u/;\n"
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-002", "CWE-78") == nil {
+ t.Fatal("expected CWE-78 qx// command injection finding")
+ }
+}
+
+func TestTwoArgOpenPathTraversal(t *testing.T) {
+ code := `my $f = $cgi->param("f");
+open(FH, "> $f");
+`
+ f := hasFinding(scanPerl(t, code), "BATOU-PERL-AST-003", "CWE-22")
+ if f == nil {
+ t.Fatal("expected CWE-22 2-arg open path traversal finding")
+ }
+}
+
+func TestTwoArgOpenVariableFilename(t *testing.T) {
+ code := `my $name = $cgi->param("name");
+open(my $fh, $name);
+`
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-003", "CWE-22") == nil {
+ t.Fatal("expected CWE-22 2-arg open finding for variable filename")
+ }
+}
+
+func TestPipedOpenCommandInjection(t *testing.T) {
+ code := `my $cmd = $cgi->param('cmd');
+open(P, "$cmd |");
+`
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-003", "CWE-78") == nil {
+ t.Fatal("expected CWE-78 piped open command injection finding")
+ }
+}
+
+func TestSubstEvalCodeExec(t *testing.T) {
+ code := `my $expr = $cgi->param('expr');
+my $t = "x";
+$t =~ s/(\w+)/$expr/ge;
+`
+ f := hasFinding(scanPerl(t, code), "BATOU-PERL-AST-004", "CWE-94")
+ if f == nil {
+ t.Fatal("expected CWE-94 s///e code execution finding")
+ }
+ if f.Severity != rules.Critical {
+ t.Errorf("expected Critical, got %s", f.Severity)
+ }
+}
+
+// --- False-positive guards ---
+
+func TestSafeThreeArgOpenNoFinding(t *testing.T) {
+ code := `my $path = $cgi->param("p");
+open(my $fh, "<", $path) or die;
+`
+ for _, f := range scanPerl(t, code) {
+ if strings.HasPrefix(f.RuleID, "BATOU-PERL-AST") {
+ t.Errorf("safe 3-arg open should not fire AST, got %s", f.RuleID)
+ }
+ }
+}
+
+func TestSafeListFormSystemNoFinding(t *testing.T) {
+ // List form bypasses the shell; no shell-interpolated string.
+ code := `my $p = $cgi->param("p");
+system("ping", "-c", "1", $p);
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-001" {
+ t.Errorf("list-form system() should not fire AST, got %s", f.RuleID)
+ }
+ }
+}
+
+func TestLiteralCommandNoFinding(t *testing.T) {
+ code := "system(\"ls -la\");\nmy $d = `date`;\n"
+ for _, f := range scanPerl(t, code) {
+ if strings.HasPrefix(f.RuleID, "BATOU-PERL-AST") {
+ t.Errorf("literal command should not fire AST, got %s", f.RuleID)
+ }
+ }
+}
+
+func TestSubstNoEvalNoFinding(t *testing.T) {
+ code := `my $x = $form{x};
+my $t = "abc";
+$t =~ s/(\w+)/[$x]/g;
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-004" {
+ t.Errorf("substitution without /e should not fire AST, got %s", f.RuleID)
+ }
+ }
+}
+
+func TestSubstEvalConstantReplacementNoFinding(t *testing.T) {
+ code := `my $t = "abc";
+$t =~ s/(\d+)/2+2/ge;
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-004" {
+ t.Errorf("s///e with constant replacement should not fire AST, got %s", f.RuleID)
+ }
+ }
+}
+
+func TestNonPerlLanguageIgnored(t *testing.T) {
+ code := `system("ping $host");`
+ tree := ast.Parse([]byte(code), rules.LangPerl)
+ ctx := &rules.ScanContext{
+ FilePath: "/app/x.py",
+ Content: code,
+ Language: rules.LangPython, // wrong language → analyzer must no-op
+ Tree: tree,
+ }
+ a := &PerlASTAnalyzer{}
+ if got := a.Scan(ctx); got != nil {
+ t.Errorf("analyzer must return nil for non-Perl language, got %d findings", len(got))
+ }
+}
+
+// --- External-origin gating (the precision rework) ---
+
+// External data flowing through a hand-rolled %FORM hash (populated from
+// $ENV{'QUERY_STRING'}) must still be recognised as external — the taint
+// catalog does not model %FORM, which is the gap this analyzer closes.
+func TestExternalViaHandRolledHash(t *testing.T) {
+ code := `my %FORM;
+foreach my $pair (split /&/, $ENV{'QUERY_STRING'} // '') {
+ my ($k, $v) = split /=/, $pair, 2;
+ $FORM{$k} = $v;
+}
+my $host = $FORM{'host'};
+system("ping -c 1 $host");
+`
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-001", "CWE-78") == nil {
+ t.Fatal("expected system() finding for external data via hand-rolled FORM hash")
+ }
+}
+
+// Local/constant data reaching the same dangerous shape must NOT fire — this is
+// the flood the rework eliminates. system() over a locally-derived path is safe.
+func TestLocalDataSystemNoFinding(t *testing.T) {
+ code := `my $prefix = "/opt/app";
+my $name = "report.txt";
+system("ls $prefix/$name");
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-001" {
+ t.Errorf("local-data system() must NOT fire after gating, got %s", f.RuleID)
+ }
+ }
+}
+
+// Regression for the AST-003 Mojo flood: the LIST / exec form
+// `open my $fh, '-|', $^X, $prog, @args` does NOT pass through a shell and must
+// NEVER be flagged — even though the '-|' mode arg contains a pipe.
+func TestListFormPipedOpenNoFinding(t *testing.T) {
+ code := `my $prefix = curfile->dirname->sibling('script');
+my $script = "app.pl";
+open my $start, '-|', $^X, "$prefix/hypnotoad", $script;
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-003" {
+ t.Errorf("list-form piped open() must NOT fire (not shell-injectable), got %s", f.RuleID)
+ }
+ }
+}
+
+// Even with external data, the LIST form is not shell-injectable and must not
+// fire — the safety is structural (direct exec), independent of origin.
+func TestListFormPipedOpenExternalNoFinding(t *testing.T) {
+ code := `my $script = $cgi->param('script');
+open my $start, '-|', $^X, "/opt/run", $script;
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-003" {
+ t.Errorf("list-form open() must NOT fire even with external arg, got %s", f.RuleID)
+ }
+ }
+}
+
+// Regression for the AST-004 Mojo flood (Mojo::Util::_html): an s///e whose
+// replacement is a FIXED function call over regex captures / static args
+// (_entity($1, $2, $attr)) is NOT eval injection and must NOT fire.
+func TestSubstEvalFixedFuncCallNoFinding(t *testing.T) {
+ code := `my $ENTITY_RE = qr/(\w+);/;
+sub _html {
+ my ($str, $attr) = @_;
+ $str =~ s/$ENTITY_RE/_entity($1, $2, $attr)/geo;
+ return $str;
+}
+`
+ for _, f := range scanPerl(t, code) {
+ if f.RuleID == "BATOU-PERL-AST-004" {
+ t.Errorf("s///e with a fixed function-call replacement must NOT fire, got %s", f.RuleID)
+ }
+ }
+}
+
+// A bare-scalar /e replacement that IS external code still fires.
+func TestSubstEvalBareScalarExternalFinding(t *testing.T) {
+ code := `my $expr = $ENV{'EXPR'};
+my $t = "input";
+$t =~ s/(\w+)/$expr/ge;
+`
+ if hasFinding(scanPerl(t, code), "BATOU-PERL-AST-004", "CWE-94") == nil {
+ t.Fatal("expected s///e finding for external bare-scalar replacement")
+ }
+}
diff --git a/batou-core/analyzer/phpast/phpast.go b/batou-core/analyzer/phpast/phpast.go
index 36dbf30..4e20223 100644
--- a/batou-core/analyzer/phpast/phpast.go
+++ b/batou-core/analyzer/phpast/phpast.go
@@ -19,7 +19,7 @@ func (p *PHPASTAnalyzer) Name() string { return "PHP AST Secu
func (p *PHPASTAnalyzer) DefaultSeverity() rules.Severity { return rules.Critical }
func (p *PHPASTAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangPHP} }
func (p *PHPASTAnalyzer) Description() string {
- return "AST-based analysis of PHP source for eval/exec/system/passthru injection, SQL concatenation injection, include/require path injection, unserialize deserialization, and preg_replace /e code execution."
+ return "AST-based analysis of PHP source for eval/exec/system/passthru injection, SQL concatenation injection, include/require path injection, unserialize deserialization, preg_replace /e code execution, and server-side template injection via Twig/Blade string-template compilation (createTemplate/compileString)."
}
func (p *PHPASTAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
@@ -137,6 +137,8 @@ func (c *phpChecker) walk() {
switch n.Type() {
case "function_call_expression":
c.checkFunctionCall(n)
+ case "member_call_expression":
+ c.checkMethodCall(n)
case "include_expression", "include_once_expression":
c.checkInclude(n, "include")
case "require_expression", "require_once_expression":
@@ -148,6 +150,66 @@ func (c *phpChecker) walk() {
})
}
+// sstiTemplateMethods maps framework method names that compile a string into a
+// template (the SSTI primitive — server-side template injection, CWE-1336) to
+// their metadata. These are method calls on a template-engine object, e.g.
+// $twig->createTemplate($userInput) or Blade compile helpers. Detected at the
+// AST tier so second-order / stored shapes that taint cannot trace to a source
+// (the template string arrives from a variable, DB row, or earlier assignment)
+// still surface structurally.
+var sstiTemplateMethods = map[string]struct {
+ engine string
+ suggestion string
+}{
+ "createTemplate": {
+ engine: "Twig\\Environment",
+ suggestion: "Never compile user input as a Twig template. Render a fixed template file and pass user data as context variables: $twig->render('page.html.twig', ['name' => $userInput]).",
+ },
+ "compileString": {
+ engine: "Laravel Blade",
+ suggestion: "Never compile user input as a Blade string. Render a fixed view and pass user data as data variables: view('page', ['name' => $userInput]).",
+ },
+}
+
+// checkMethodCall inspects $obj->method(arg) calls for framework template-injection
+// sinks that compile a string argument into an executable template (SSTI, CWE-1336).
+func (c *phpChecker) checkMethodCall(n *ast.Node) {
+ method := phpMethodName(n)
+ if method == "" {
+ return
+ }
+ info, ok := sstiTemplateMethods[method]
+ if !ok {
+ return
+ }
+ args := findChild(n, "arguments")
+ if args == nil {
+ return
+ }
+ firstArg := firstArgument(args)
+ // A literal template string is developer-authored, not user-controlled — skip it.
+ if firstArg == nil || isPHPLiteral(firstArg) {
+ return
+ }
+ line := int(n.StartRow()) + 1
+ c.findings = append(c.findings, rules.Finding{
+ RuleID: "BATOU-PHPAST-007",
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: "Server-side template injection via " + method + "()",
+ Description: info.engine + "::" + method + "() compiles its string argument into a template. When that string is user-controlled, an attacker can inject template expressions to execute arbitrary code on the server (SSTI).",
+ FilePath: c.filePath,
+ LineNumber: line,
+ MatchedText: truncate(n.Text(), 200),
+ Suggestion: info.suggestion,
+ CWEID: "CWE-1336",
+ OWASPCategory: "A03:2021-Injection",
+ Language: rules.LangPHP,
+ Confidence: "high",
+ Tags: []string{"ssti", "template-injection", "injection", "rce", "ast"},
+ })
+}
+
// checkFunctionCall inspects function calls for dangerous patterns.
func (c *phpChecker) checkFunctionCall(n *ast.Node) {
funcName := phpFuncName(n)
@@ -373,6 +435,28 @@ func phpFuncName(n *ast.Node) string {
return ""
}
+// phpMethodName extracts the invoked method name from a member_call_expression
+// ($obj->method(args)). Tree-sitter PHP shapes this as named children
+// [receiver, name(method), arguments]; the method name is the "name" node that
+// is not the receiver.
+func phpMethodName(n *ast.Node) string {
+ if n == nil || n.Type() != "member_call_expression" {
+ return ""
+ }
+ named := n.NamedChildren()
+ for i, ch := range named {
+ // Skip the receiver (first child) — the method name is the "name" node
+ // that follows it.
+ if i == 0 {
+ continue
+ }
+ if ch.Type() == "name" {
+ return ch.Text()
+ }
+ }
+ return ""
+}
+
func findChild(n *ast.Node, nodeType string) *ast.Node {
if n == nil {
return nil
diff --git a/batou-core/analyzer/phpast/phpast_test.go b/batou-core/analyzer/phpast/phpast_test.go
index 061c868..c795d99 100644
--- a/batou-core/analyzer/phpast/phpast_test.go
+++ b/batou-core/analyzer/phpast/phpast_test.go
@@ -1,10 +1,9 @@
package phpast
import (
- "testing"
-
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
+ "testing"
)
func scanPHP(code string) []rules.Finding {
@@ -174,6 +173,92 @@ function handler($input) {
}
}
+func TestTwigCreateTemplateSSTI(t *testing.T) {
+ code := `createTemplate($input);
+ return $template->render([]);
+}
+?>`
+ findings := scanPHP(code)
+ f := findByRule(findings, "BATOU-PHPAST-007")
+ if f == nil {
+ t.Error("expected SSTI finding for Twig createTemplate with variable")
+ for _, f := range findings {
+ t.Logf(" %s: %s (line %d)", f.RuleID, f.Title, f.LineNumber)
+ }
+ return
+ }
+ if f.CWEID != "CWE-1336" {
+ t.Errorf("expected CWE-1336, got %s", f.CWEID)
+ }
+}
+
+func TestTwigCreateTemplateSecondOrder(t *testing.T) {
+ // Template string arrives from a DB row — taint cannot trace it to a
+ // request source, but structurally it is still SSTI.
+ code := `query("SELECT body FROM tpls")->fetch();
+ $tpl = $row['body'];
+ $template = $twig->createTemplate($tpl);
+ return $template->render([]);
+}
+?>`
+ findings := scanPHP(code)
+ if findByRule(findings, "BATOU-PHPAST-007") == nil {
+ t.Error("expected SSTI finding for second-order Twig createTemplate")
+ for _, f := range findings {
+ t.Logf(" %s: %s (line %d)", f.RuleID, f.Title, f.LineNumber)
+ }
+ }
+}
+
+func TestBladeCompileStringSSTI(t *testing.T) {
+ code := `compileString($snippet);
+}
+?>`
+ findings := scanPHP(code)
+ if findByRule(findings, "BATOU-PHPAST-007") == nil {
+ t.Error("expected SSTI finding for Blade compileString with variable")
+ for _, f := range findings {
+ t.Logf(" %s: %s (line %d)", f.RuleID, f.Title, f.LineNumber)
+ }
+ }
+}
+
+func TestTwigCreateTemplateLiteralSafe(t *testing.T) {
+ // A developer-authored literal template string must NOT be flagged.
+ code := `createTemplate("Hello {{ name }}");
+?>`
+ findings := scanPHP(code)
+ for _, f := range findings {
+ if f.RuleID == "BATOU-PHPAST-007" {
+ t.Errorf("should not flag createTemplate with literal string: %s", f.Title)
+ }
+ }
+}
+
+func TestNonSSTIMethodCallSafe(t *testing.T) {
+ // An unrelated method named like a sink elsewhere must not trip PHPAST-007.
+ code := `createTemplate($input);
+$x->render($data);
+?>`
+ findings := scanPHP(code)
+ // createTemplate is the only SSTI method here; render alone is not a
+ // compile-string sink in this analyzer. createTemplate on a non-engine
+ // receiver still flags (conservative) — assert render() does not.
+ for _, f := range findings {
+ if f.RuleID == "BATOU-PHPAST-007" && f.LineNumber == 3 {
+ t.Errorf("render() should not be flagged as SSTI compile sink: %s", f.Title)
+ }
+ }
+}
+
func TestNilTree(t *testing.T) {
ctx := &rules.ScanContext{
FilePath: "/app/handler.php",
diff --git a/batou-core/analyzer/phpast/publicpage.go b/batou-core/analyzer/phpast/publicpage.go
new file mode 100644
index 0000000..4ca5e61
--- /dev/null
+++ b/batou-core/analyzer/phpast/publicpage.go
@@ -0,0 +1,707 @@
+// Package phpast — BATOU-OWNCLOUD-001
+//
+// Detects ownCloud / Nextcloud AppFramework controller methods that are
+// exposed as PUBLIC, UNAUTHENTICATED HTTP routes (via @PublicPage docblock
+// or #[PublicPage] PHP 8 attribute) whose parameters flow to HTTP-client /
+// file-system / process-execution sinks.
+//
+// Background
+// ----------
+// In the OCP AppFramework, a controller method annotated with @PublicPage
+// becomes callable by unauthenticated remote callers. Treating the method's
+// formal parameters as attacker-controlled is therefore correct. When such
+// a parameter flows directly into an outbound HTTP request, a file read or
+// write, or a shell command, that's a classic public-route sink:
+//
+// - HTTP client → SSRF (CWE-918)
+// - file_get_contents / fopen / include → LFI / path traversal (CWE-22)
+// - file_put_contents / fwrite / unlink → arbitrary file write (CWE-22)
+// - exec / shell_exec / system / popen / `…$param…` → RCE (CWE-78)
+//
+// The headline real-world miss this rule catches is
+// apps/files_sharing/.../Controllers/ExternalSharesController::testRemote($remote)
+// in ownCloud core, where `$remote` flows unvalidated into the federated
+// share probe (issued via the IClientService HTTP client).
+//
+// Scope
+// -----
+// This is an intentionally focused, single-rule heuristic that emulates a
+// "this param is a tainted source" entry-point inside its own scope. It is
+// NOT wired into the taint engine — see E9-T1 for the general entry-point
+// catalog that will subsume it.
+package phpast
+
+import (
+ "strings"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// PublicPageSinkAnalyzer flags @PublicPage / #[PublicPage] controller
+// methods whose parameters flow into dangerous sinks.
+type PublicPageSinkAnalyzer struct{}
+
+func init() {
+ rules.Register(&PublicPageSinkAnalyzer{})
+}
+
+func (p *PublicPageSinkAnalyzer) ID() string { return "BATOU-OWNCLOUD-AST-001" }
+func (p *PublicPageSinkAnalyzer) Name() string { return "ownCloud @PublicPage parameter to dangerous sink" }
+func (p *PublicPageSinkAnalyzer) Description() string {
+ return "Detects ownCloud / Nextcloud AppFramework controller methods marked @PublicPage (or #[PublicPage]) whose attacker-controlled parameters flow to HTTP clients (SSRF), filesystem sinks (LFI / arbitrary file write), or process execution (RCE)."
+}
+func (p *PublicPageSinkAnalyzer) DefaultSeverity() rules.Severity { return rules.Critical }
+func (p *PublicPageSinkAnalyzer) Languages() []rules.Language { return []rules.Language{rules.LangPHP} }
+
+// sinkCategory classifies the kind of dangerous sink a parameter reached.
+type sinkCategory int
+
+const (
+ sinkUnknown sinkCategory = iota
+ sinkHTTPClient
+ sinkFileRead
+ sinkFileWrite
+ sinkProcessExec
+)
+
+// sinkMeta carries the per-category metadata that lands in the finding.
+type sinkMeta struct {
+ category sinkCategory
+ categoryStr string // raw value for Finding.SinkCategory
+ cwe string
+ owasp string
+ titleNoun string // "HTTP client (SSRF)" etc — slotted into the title
+ fixHint string
+}
+
+func (m sinkMeta) tag() string {
+ switch m.category {
+ case sinkHTTPClient:
+ return "ssrf"
+ case sinkFileRead, sinkFileWrite:
+ return "lfi"
+ case sinkProcessExec:
+ return "rce"
+ }
+ return "publicpage"
+}
+
+// Built-in bare-name function sinks. Tree-sitter exposes these as
+// function_call_expression with a "name" child.
+var (
+ fileReadFuncs = map[string]bool{
+ "file_get_contents": true, // also a network sink, but we treat as file unless URL detected
+ "fopen": true,
+ "readfile": true,
+ "file": true,
+ "parse_ini_file": true,
+ "simplexml_load_file": true,
+ "glob": true,
+ "scandir": true,
+ "is_file": true,
+ "is_dir": true,
+ "is_readable": true,
+ "file_exists": true,
+ "realpath": true,
+ }
+ fileWriteFuncs = map[string]bool{
+ "file_put_contents": true,
+ "fwrite": true,
+ "move_uploaded_file": true,
+ "rename": true,
+ "unlink": true,
+ "mkdir": true,
+ "rmdir": true,
+ "touch": true,
+ "chmod": true,
+ "copy": true,
+ "link": true,
+ "symlink": true,
+ }
+ processExecFuncs = map[string]bool{
+ "exec": true,
+ "shell_exec": true,
+ "system": true,
+ "passthru": true,
+ "popen": true,
+ "proc_open": true,
+ "pcntl_exec": true,
+ }
+ // Methods invoked on an HTTP-client-looking object (Guzzle, IClient,
+ // curl wrappers, etc.).
+ httpClientMethods = map[string]bool{
+ "get": true,
+ "post": true,
+ "put": true,
+ "delete": true,
+ "patch": true,
+ "head": true,
+ "options": true,
+ "request": true,
+ "send": true,
+ "sendAsync": true,
+ }
+)
+
+// receiverLooksHTTPClient returns true if the receiver chain text contains
+// a recognisable HTTP-client identifier substring.
+func receiverLooksHTTPClient(text string) bool {
+ t := strings.ToLower(text)
+ return strings.Contains(t, "client") ||
+ strings.Contains(t, "http") ||
+ strings.Contains(t, "guzzle") ||
+ strings.Contains(t, "curl") ||
+ strings.Contains(t, "fetch")
+}
+
+// callArgsLookHTTPClient returns true if any argument text contains an
+// HTTP-client identifier substring (e.g. clientService passed alongside the
+// param, as in testRemote → testRemoteUrl($this->clientService, $remote)).
+func callArgsLookHTTPClient(args []string) bool {
+ for _, a := range args {
+ if receiverLooksHTTPClient(a) {
+ return true
+ }
+ }
+ return false
+}
+
+func (p *PublicPageSinkAnalyzer) Scan(ctx *rules.ScanContext) []rules.Finding {
+ if ctx.Language != rules.LangPHP {
+ return nil
+ }
+ tree := ast.TreeFromContext(ctx)
+ if tree == nil {
+ return nil
+ }
+ // Cheap pre-filter: if the file has no @PublicPage / #[PublicPage] at
+ // all, skip entirely. Keeps the per-file cost negligible.
+ if !strings.Contains(ctx.Content, "PublicPage") {
+ return nil
+ }
+
+ v := &publicPageVisitor{filePath: ctx.FilePath}
+ v.walk(tree.Root())
+ return v.findings
+}
+
+type publicPageVisitor struct {
+ filePath string
+ findings []rules.Finding
+}
+
+func (v *publicPageVisitor) walk(root *ast.Node) {
+ if root == nil {
+ return
+ }
+ root.Walk(func(n *ast.Node) bool {
+ if n.Type() == "method_declaration" {
+ v.checkMethod(n)
+ }
+ return true
+ })
+}
+
+// checkMethod inspects one method_declaration node.
+func (v *publicPageVisitor) checkMethod(method *ast.Node) {
+ if !methodIsPublicPage(method) {
+ return
+ }
+ params := extractParamNames(method) // e.g. ["$remote"]
+ if len(params) == 0 {
+ return
+ }
+ body := method.ChildByFieldName("body")
+ if body == nil {
+ return
+ }
+
+ // Intra-procedural single-pass taint propagation across local-variable
+ // assignments. Two-step expansion suffices for the common
+ // $url = "https://" . $remote . "/path";
+ // $client->get($url);
+ // pattern. Deeper aliasing chains are out of scope for this MVP —
+ // flagged as a known-false-negative in the rule description.
+ tainted := make(map[string]bool, len(params))
+ for _, p := range params {
+ tainted[p] = true
+ }
+ for pass := 0; pass < 3; pass++ {
+ grew := false
+ body.Walk(func(n *ast.Node) bool {
+ if n.Type() != "assignment_expression" {
+ return true
+ }
+ lhsNode := n.ChildByFieldName("left")
+ rhsNode := n.ChildByFieldName("right")
+ if lhsNode == nil || rhsNode == nil {
+ return true
+ }
+ if lhsNode.Type() != "variable_name" {
+ return true
+ }
+ lhs := lhsNode.Text()
+ if tainted[lhs] {
+ return true
+ }
+ rhsText := rhsNode.Text()
+ for v := range tainted {
+ if containsVariable(rhsText, v) {
+ tainted[lhs] = true
+ grew = true
+ break
+ }
+ }
+ return true
+ })
+ if !grew {
+ break
+ }
+ }
+
+ // Walk the body looking for calls that consume any tainted variable.
+ taintedList := make([]string, 0, len(tainted))
+ for t := range tainted {
+ taintedList = append(taintedList, t)
+ }
+ body.Walk(func(call *ast.Node) bool {
+ switch call.Type() {
+ case "function_call_expression", "member_call_expression", "scoped_call_expression":
+ v.checkCall(method, call, taintedList)
+ }
+ // continue descending — nested calls also count
+ return true
+ })
+}
+
+// methodIsPublicPage returns true iff `method` is annotated with @PublicPage
+// (PHPDoc comment immediately above) or #[PublicPage] (PHP 8 attribute).
+func methodIsPublicPage(method *ast.Node) bool {
+ // Attribute style: #[PublicPage] sits as an "attribute_list" child of
+ // the method (field "attributes" in tree-sitter-php).
+ for i := 0; i < method.ChildCount(); i++ {
+ c := method.Child(i)
+ if c.Type() != "attribute_list" {
+ continue
+ }
+ if attributeListHasPublicPage(c) {
+ return true
+ }
+ }
+ // Docblock style: the previous sibling at the parent (declaration_list)
+ // level is a "comment" node containing "@PublicPage".
+ parent := method.Parent()
+ if parent == nil {
+ return false
+ }
+ prev := previousNamedSibling(parent, method)
+ if prev != nil && prev.Type() == "comment" && commentMentionsPublicPage(prev.Text()) {
+ return true
+ }
+ return false
+}
+
+// attributeListHasPublicPage checks an attribute_list subtree for an
+// attribute named "PublicPage" (bare or namespaced).
+func attributeListHasPublicPage(list *ast.Node) bool {
+ found := false
+ list.Walk(func(n *ast.Node) bool {
+ if found {
+ return false
+ }
+ if n.Type() == "attribute" {
+ // `attribute` -> name (could be `name` or `qualified_name`)
+ for i := 0; i < n.ChildCount(); i++ {
+ c := n.Child(i)
+ if c.Type() == "name" || c.Type() == "qualified_name" {
+ if attributeNameIsPublicPage(c.Text()) {
+ found = true
+ return false
+ }
+ }
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// attributeNameIsPublicPage handles bare ("PublicPage") and fully-qualified
+// ("\\OCP\\AppFramework\\Http\\Attribute\\PublicPage") attribute names.
+func attributeNameIsPublicPage(name string) bool {
+ name = strings.TrimSpace(name)
+ name = strings.TrimPrefix(name, "\\")
+ // Compare the last namespace segment.
+ if idx := strings.LastIndex(name, "\\"); idx >= 0 {
+ name = name[idx+1:]
+ }
+ return name == "PublicPage"
+}
+
+// commentMentionsPublicPage returns true if a docblock contains @PublicPage
+// (case-sensitive — ownCloud uses exact casing).
+func commentMentionsPublicPage(comment string) bool {
+ if !strings.Contains(comment, "@PublicPage") {
+ return false
+ }
+ // Defend against accidental hits inside @param/@return descriptions —
+ // require the token to appear at the start of a comment line after the
+ // usual `*` or whitespace prefix.
+ for _, line := range strings.Split(comment, "\n") {
+ trimmed := strings.TrimLeft(line, " \t*/")
+ if strings.HasPrefix(trimmed, "@PublicPage") {
+ return true
+ }
+ }
+ return false
+}
+
+// previousNamedSibling returns the named sibling node that immediately
+// precedes `target` in `parent`'s child list, or nil if none.
+func previousNamedSibling(parent, target *ast.Node) *ast.Node {
+ var last *ast.Node
+ for i := 0; i < parent.ChildCount(); i++ {
+ c := parent.Child(i)
+ if c == target {
+ return last
+ }
+ if c.IsNamed() {
+ last = c
+ }
+ }
+ return nil
+}
+
+// extractParamNames returns the formal parameter names of the method as
+// "$name" strings (with the leading sigil).
+func extractParamNames(method *ast.Node) []string {
+ params := method.ChildByFieldName("parameters")
+ if params == nil {
+ return nil
+ }
+ var out []string
+ for i := 0; i < params.ChildCount(); i++ {
+ c := params.Child(i)
+ if c.Type() != "simple_parameter" &&
+ c.Type() != "variadic_parameter" &&
+ c.Type() != "property_promotion_parameter" {
+ continue
+ }
+ name := c.ChildByFieldName("name")
+ if name != nil {
+ out = append(out, name.Text())
+ }
+ }
+ return out
+}
+
+// checkCall inspects a single call expression for the public-page param
+// flowing to a recognised sink. Emits a finding if so.
+func (v *publicPageVisitor) checkCall(method, call *ast.Node, params []string) {
+ args := callArgumentNodes(call)
+ if len(args) == 0 {
+ return
+ }
+ usedParam, _ := callUsesParam(args, params)
+ if usedParam == "" {
+ return
+ }
+
+ meta, ok := classifyCall(call, args)
+ if !ok {
+ return
+ }
+
+ // Resolve the method name for the description.
+ methodName := ""
+ if n := method.ChildByFieldName("name"); n != nil {
+ methodName = n.Text()
+ }
+
+ line := int(call.StartRow()) + 1
+ matchedText := truncate(call.Text(), 200)
+ title := "Public route parameter flows to " + meta.titleNoun
+
+ desc := "Controller method `" + methodName + "` is marked @PublicPage / #[PublicPage] (publicly accessible without authentication). Parameter " + usedParam + " flows into a " + meta.titleNoun + " sink without observable validation. Untrusted callers can supply arbitrary values, enabling exploitation appropriate to the sink (e.g. SSRF, path traversal, RCE)."
+
+ suggestion := meta.fixHint
+
+ v.findings = append(v.findings, rules.Finding{
+ RuleID: v.id(),
+ Severity: rules.Critical,
+ SeverityLabel: rules.Critical.String(),
+ Title: title,
+ Description: desc,
+ FilePath: v.filePath,
+ LineNumber: line,
+ MatchedText: matchedText,
+ Suggestion: suggestion,
+ CWEID: meta.cwe,
+ OWASPCategory: meta.owasp,
+ Language: rules.LangPHP,
+ Confidence: "high",
+ ConfidenceScore: 0.85,
+ SourceCategory: "user_input",
+ SinkCategory: meta.categoryStr,
+ Tags: []string{"owncloud", "publicpage", meta.tag(), "ast"},
+ })
+}
+
+// id is the stable rule ID emitted on every finding.
+func (v *publicPageVisitor) id() string { return "BATOU-OWNCLOUD-AST-001" }
+
+// callArgumentNodes returns the named children of the call's arguments
+// node. For function_call_expression / member_call_expression both expose
+// `arguments` as a field; scoped_call_expression also uses field "arguments".
+func callArgumentNodes(call *ast.Node) []*ast.Node {
+ args := call.ChildByFieldName("arguments")
+ if args == nil {
+ // object_creation_expression has arguments as a positional child,
+ // but we don't currently flag that shape.
+ return nil
+ }
+ var out []*ast.Node
+ for i := 0; i < args.ChildCount(); i++ {
+ c := args.Child(i)
+ if !c.IsNamed() {
+ continue
+ }
+ if c.Type() == "argument" {
+ // unwrap one level
+ if inner := firstNamed(c); inner != nil {
+ out = append(out, inner)
+ continue
+ }
+ }
+ out = append(out, c)
+ }
+ return out
+}
+
+// firstNamed returns the first named child of n, or nil.
+func firstNamed(n *ast.Node) *ast.Node {
+ for i := 0; i < n.ChildCount(); i++ {
+ c := n.Child(i)
+ if c.IsNamed() {
+ return c
+ }
+ }
+ return nil
+}
+
+// callUsesParam returns the matching parameter name if any of `params`
+// appears in `args` (as a direct variable_name or anywhere in the
+// argument's text). Also returns the argument's text. Empty result means
+// none of the params is used.
+func callUsesParam(args []*ast.Node, params []string) (string, string) {
+ for _, a := range args {
+ text := a.Text()
+ // Cheap text scan first — handles `$remote` direct, `$remote . "..."`,
+ // `"https://" . $remote`, `$tmp = $remote; ... f($tmp)` is NOT handled
+ // (intermediate vars are explicitly out of scope for this MVP rule).
+ for _, p := range params {
+ if containsVariable(text, p) {
+ return p, text
+ }
+ }
+ }
+ return "", ""
+}
+
+// containsVariable returns true if `text` references the PHP variable
+// `varName` (e.g. "$remote") as a whole token — i.e. not as a prefix of a
+// longer identifier like "$remoteHost".
+func containsVariable(text, varName string) bool {
+ if !strings.HasPrefix(varName, "$") || len(varName) < 2 {
+ return false
+ }
+ for {
+ idx := strings.Index(text, varName)
+ if idx < 0 {
+ return false
+ }
+ end := idx + len(varName)
+ if end < len(text) {
+ next := text[end]
+ if (next >= 'a' && next <= 'z') ||
+ (next >= 'A' && next <= 'Z') ||
+ (next >= '0' && next <= '9') ||
+ next == '_' {
+ // it's the prefix of a longer identifier — keep scanning
+ text = text[end:]
+ continue
+ }
+ }
+ return true
+ }
+}
+
+// classifyCall returns a sinkMeta if the call shape matches a known
+// dangerous sink. The boolean is false when the call is harmless.
+func classifyCall(call *ast.Node, args []*ast.Node) (sinkMeta, bool) {
+ name := callName(call)
+
+ // 1. Bare-name function calls (function_call_expression).
+ if call.Type() == "function_call_expression" {
+ if processExecFuncs[name] {
+ return processExecMeta(), true
+ }
+ if fileWriteFuncs[name] {
+ return fileWriteMeta(), true
+ }
+ if fileReadFuncs[name] {
+ // file_get_contents with an http(s):// URL is SSRF, not LFI.
+ // Detect by inspecting the first arg's text.
+ if name == "file_get_contents" && firstArgLooksLikeURL(args) {
+ return httpClientMeta(), true
+ }
+ return fileReadMeta(), true
+ }
+ if name == "curl_setopt" {
+ // curl_setopt($ch, CURLOPT_URL, $param) — second positional
+ // constant is CURLOPT_URL.
+ if len(args) >= 3 && strings.Contains(args[1].Text(), "CURLOPT_URL") {
+ return httpClientMeta(), true
+ }
+ }
+ if name == "curl_init" || name == "curl_exec" {
+ return httpClientMeta(), true
+ }
+ // include/require are usually their own statement type but PHP
+ // sometimes parses them as function_call_expression depending on
+ // surrounding context.
+ if name == "include" || name == "include_once" || name == "require" || name == "require_once" {
+ return fileReadMeta(), true
+ }
+ }
+
+ // 2. Method calls — HTTP-client-shaped invocations.
+ if call.Type() == "member_call_expression" || call.Type() == "scoped_call_expression" {
+ recvText := callReceiverText(call)
+ argTexts := make([]string, 0, len(args))
+ for _, a := range args {
+ argTexts = append(argTexts, a.Text())
+ }
+ // Strongest signal: HTTP-method name on an HTTP-client receiver.
+ if httpClientMethods[name] && receiverLooksHTTPClient(recvText) {
+ return httpClientMeta(), true
+ }
+ // Weaker but very common: the call name itself signals an outbound
+ // HTTP fetch. We deliberately use specific tokens rather than a
+ // catch-all "request" substring — internal helper names like
+ // `validateRequest` would otherwise be flagged as SSRF.
+ lname := strings.ToLower(name)
+ if strings.Contains(lname, "http") ||
+ strings.Contains(lname, "fetchurl") ||
+ strings.Contains(lname, "fetchremote") ||
+ strings.Contains(lname, "geturl") ||
+ strings.Contains(lname, "sendrequest") ||
+ strings.Contains(lname, "makerequest") ||
+ strings.Contains(lname, "httprequest") {
+ return httpClientMeta(), true
+ }
+ // Pattern observed in ExternalSharesController::testRemote — a
+ // helper call (`testRemoteUrl`) that takes an explicit HTTP client
+ // as one of its arguments alongside the public param. The receiver
+ // looks innocuous but the argument list reveals the network flow.
+ if callArgsLookHTTPClient(argTexts) {
+ return httpClientMeta(), true
+ }
+ // Catch-all: explicit method names from our HTTP map, even on an
+ // unrecognised receiver (Symfony HttpClient, GuzzleHttp\Client,
+ // etc.). Lower confidence — handled by the same meta.
+ if httpClientMethods[name] && (strings.Contains(strings.ToLower(recvText), "send") || strings.Contains(strings.ToLower(recvText), "request")) {
+ return httpClientMeta(), true
+ }
+ }
+ return sinkMeta{}, false
+}
+
+func callName(call *ast.Node) string {
+ // function_call_expression: function field
+ if fn := call.ChildByFieldName("function"); fn != nil {
+ if fn.Type() == "name" || fn.Type() == "variable_name" {
+ return fn.Text()
+ }
+ if fn.Type() == "qualified_name" {
+ // Last name component
+ for i := fn.ChildCount() - 1; i >= 0; i-- {
+ c := fn.Child(i)
+ if c.Type() == "name" {
+ return c.Text()
+ }
+ }
+ }
+ }
+ // member_call_expression / scoped_call_expression: name field
+ if n := call.ChildByFieldName("name"); n != nil {
+ return n.Text()
+ }
+ return ""
+}
+
+func callReceiverText(call *ast.Node) string {
+ if o := call.ChildByFieldName("object"); o != nil {
+ return o.Text()
+ }
+ if s := call.ChildByFieldName("scope"); s != nil {
+ return s.Text()
+ }
+ return ""
+}
+
+// firstArgLooksLikeURL returns true if the first argument's textual form
+// contains "http://" or "https://" — strong signal that the file_get_contents
+// call is actually a network fetch (SSRF), not a local-file read.
+func firstArgLooksLikeURL(args []*ast.Node) bool {
+ if len(args) == 0 {
+ return false
+ }
+ t := args[0].Text()
+ return strings.Contains(t, "http://") || strings.Contains(t, "https://") || strings.Contains(t, "ftp://")
+}
+
+func httpClientMeta() sinkMeta {
+ return sinkMeta{
+ category: sinkHTTPClient,
+ categoryStr: "http_client",
+ cwe: "CWE-918",
+ owasp: "A03:2021-Injection",
+ titleNoun: "HTTP client (SSRF)",
+ fixHint: "Validate the URL/host: parse with parse_url(), reject private/loopback IPs (CIDRs 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1/128, fc00::/7), and apply a scheme allowlist (https only). Consider an explicit hostname allowlist for federated peers.",
+ }
+}
+
+func fileReadMeta() sinkMeta {
+ return sinkMeta{
+ category: sinkFileRead,
+ categoryStr: "file_read",
+ cwe: "CWE-22",
+ owasp: "A01:2021-Broken Access Control",
+ titleNoun: "filesystem read (path traversal / LFI)",
+ fixHint: "Canonicalize the path with realpath() and verify it is contained within an expected base directory; basename() the param if only a filename is expected. Never pass an unvalidated user-controlled path to include / require.",
+ }
+}
+
+func fileWriteMeta() sinkMeta {
+ return sinkMeta{
+ category: sinkFileWrite,
+ categoryStr: "file_write",
+ cwe: "CWE-22",
+ owasp: "A01:2021-Broken Access Control",
+ titleNoun: "filesystem write",
+ fixHint: "Confirm the target path is inside an allowed directory after realpath() canonicalization; reject absolute paths and \"..\" segments. Use basename() if only a filename is expected.",
+ }
+}
+
+func processExecMeta() sinkMeta {
+ return sinkMeta{
+ category: sinkProcessExec,
+ categoryStr: "process_exec",
+ cwe: "CWE-78",
+ owasp: "A03:2021-Injection",
+ titleNoun: "process execution (RCE)",
+ fixHint: "Avoid shell pipelines with user input entirely; if you must, escape with escapeshellarg() for each individual argument and never use escapeshellcmd() on the whole command line. Prefer pcntl_exec() with an argv array.",
+ }
+}
diff --git a/batou-core/analyzer/phpast/publicpage_test.go b/batou-core/analyzer/phpast/publicpage_test.go
new file mode 100644
index 0000000..61b6545
--- /dev/null
+++ b/batou-core/analyzer/phpast/publicpage_test.go
@@ -0,0 +1,365 @@
+package phpast
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/turenlabs/batou-core/ast"
+ "github.com/turenlabs/batou-rules/rules"
+)
+
+// scanPublicPage runs the PublicPageSinkAnalyzer in isolation against the
+// given PHP source, returning all findings it produced.
+func scanPublicPage(t *testing.T, code string) []rules.Finding {
+ t.Helper()
+ tree := ast.Parse([]byte(code), rules.LangPHP)
+ if tree == nil {
+ t.Fatal("ast.Parse returned nil tree")
+ }
+ ctx := &rules.ScanContext{
+ FilePath: "/app/Controller.php",
+ Content: code,
+ Language: rules.LangPHP,
+ Tree: tree,
+ }
+ a := &PublicPageSinkAnalyzer{}
+ return a.Scan(ctx)
+}
+
+func hasFinding(findings []rules.Finding, ruleID string) bool {
+ for _, f := range findings {
+ if f.RuleID == ruleID {
+ return true
+ }
+ }
+ return false
+}
+
+func findingsForRule(findings []rules.Finding, ruleID string) []rules.Finding {
+ var out []rules.Finding
+ for _, f := range findings {
+ if f.RuleID == ruleID {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+// --- AT 1: Docblock @PublicPage + direct HTTP-client sink fires. ---
+func TestPublicPage_Docblock_HTTPClient(t *testing.T) {
+ code := `client->get($url);
+ return $response->getBody();
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if !hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("expected BATOU-OWNCLOUD-AST-001, got %d findings", len(findings))
+ }
+ for _, f := range findings {
+ if f.RuleID != "BATOU-OWNCLOUD-AST-001" {
+ continue
+ }
+ if f.Severity != rules.Critical {
+ t.Errorf("expected Critical, got %s", f.Severity)
+ }
+ if f.CWEID != "CWE-918" {
+ t.Errorf("expected CWE-918 (SSRF), got %s", f.CWEID)
+ }
+ if f.SinkCategory != "http_client" {
+ t.Errorf("expected SinkCategory http_client, got %q", f.SinkCategory)
+ }
+ if f.SourceCategory != "user_input" {
+ t.Errorf("expected SourceCategory user_input, got %q", f.SourceCategory)
+ }
+ if f.ConfidenceScore < 0.8 {
+ t.Errorf("expected ConfidenceScore >= 0.8, got %f", f.ConfidenceScore)
+ }
+ }
+}
+
+// --- AT 2: PHP-8 #[PublicPage] attribute + HTTP-client sink fires. ---
+func TestPublicPage_Attribute_HTTPClient(t *testing.T) {
+ code := `httpClient->post("https://" . $host . "/probe");
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if !hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("expected BATOU-OWNCLOUD-AST-001 on #[PublicPage], got: %v", findings)
+ }
+}
+
+// --- Fully-qualified attribute name. ---
+func TestPublicPage_QualifiedAttribute_HTTPClient(t *testing.T) {
+ code := `client->get($url);
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if !hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("expected BATOU-OWNCLOUD-AST-001 on qualified attribute, got: %v", findings)
+ }
+}
+
+// --- AT 3: Method WITHOUT @PublicPage with the same SSRF pattern does NOT fire. ---
+func TestPublicPage_NotPublic_NoFire(t *testing.T) {
+ code := `client->get($url);
+ return $response;
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("did not expect BATOU-OWNCLOUD-AST-001 on non-public method, got: %+v", findings)
+ }
+}
+
+// --- AT 4: NoAdminRequired alone should NOT count as @PublicPage. ---
+func TestPublicPage_NoAdminRequired_Alone_NoFire(t *testing.T) {
+ code := `client->get($remote);
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("did not expect BATOU-OWNCLOUD-AST-001 on @NoAdminRequired-only method")
+ }
+}
+
+// --- Public-page method using non-parameter variable is ignored. ---
+func TestPublicPage_NoParamFlow_NoFire(t *testing.T) {
+ code := `client->get("https://example.com/status");
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("did not expect a finding when param doesn't flow to sink")
+ }
+}
+
+// --- File-read sink (file_get_contents on local path) fires with CWE-22. ---
+func TestPublicPage_FileRead_CWE22(t *testing.T) {
+ code := `externalManager->testRemoteUrl($this->clientService, $remote);
+ return $response;
+ }
+}`
+ findings := scanPublicPage(t, code)
+ frs := findingsForRule(findings, "BATOU-OWNCLOUD-AST-001")
+ if len(frs) == 0 {
+ t.Fatalf("expected BATOU-OWNCLOUD-AST-001 on testRemote helper-call pattern; got: %v", findings)
+ }
+ if !strings.Contains(frs[0].Description, "testRemote") {
+ t.Errorf("description should name the method, got: %s", frs[0].Description)
+ }
+}
+
+// --- File without @PublicPage anywhere is fast-pathed (no findings). ---
+func TestPublicPage_FastPath_NoAnnotation(t *testing.T) {
+ code := `client->get($x);
+ shell_exec("ls " . $x);
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("expected no findings when @PublicPage absent")
+ }
+}
+
+// --- Variable name boundary: $remote should not match $remoteHost. ---
+func TestPublicPage_VariableBoundary(t *testing.T) {
+ code := `client->get($xLong);
+ }
+}`
+ findings := scanPublicPage(t, code)
+ if hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("expected no findings — $x not used (prefix-match guard)")
+ }
+}
+
+// --- @PublicPage appearing only as a textual mention in a @param description
+// should not count. ---
+func TestPublicPage_TextualMentionInOtherTag(t *testing.T) {
+ code := `client->get($remote);
+ }
+}`
+ // We *do* expect this NOT to fire: the comment line doesn't start with
+ // @PublicPage, only mentions it inside a @param description.
+ findings := scanPublicPage(t, code)
+ if hasFinding(findings, "BATOU-OWNCLOUD-AST-001") {
+ t.Fatalf("expected no finding when @PublicPage is only textually mentioned in a @param description")
+ }
+}
diff --git a/batou-core/analyzer/pyast/pyast.go b/batou-core/analyzer/pyast/pyast.go
index 0128dd8..c7afe4d 100644
--- a/batou-core/analyzer/pyast/pyast.go
+++ b/batou-core/analyzer/pyast/pyast.go
@@ -1,12 +1,20 @@
package pyast
import (
+ "regexp"
"strings"
"github.com/turenlabs/batou-core/ast"
"github.com/turenlabs/batou-rules/rules"
)
+// sqlKeywordRe matches SQL keywords that begin a statement (after the string's
+// opening quote / Python prefix, or after a `;` clause separator inside the
+// string). This avoids substring matches like `DELETE` in `delete_selected`,
+// HTML `