diff --git a/compliance-agent/rules/cra_semgrep.yaml b/compliance-agent/rules/cra_semgrep.yaml new file mode 100644 index 0000000..bba2648 --- /dev/null +++ b/compliance-agent/rules/cra_semgrep.yaml @@ -0,0 +1,117 @@ +# Custom semgrep rules for CRA controls that no off-the-shelf ruleset digs out. +# Each rule id is `cra-ai--` and is keyed back to its control via the +# `control-map` LUT (by rule-id suffix, so semgrep's path prefix on check_id does +# not matter). Detection here is deterministic; the grounded LLM judge downstream +# only confirms/refutes — it never detects. Keep patterns tight: a false positive +# that the judge refutes marks the whole finding a false positive. +rules: + # --- cra-ai-1: Secure-by-Default-Konfiguration ------------------------------- + - id: cra-ai-1-flask-debug-enabled + languages: [python] + severity: WARNING + message: Flask app started with debug=True — ships an interactive debugger / code execution in production (secure-by-default violation). + metadata: + cwe: ["CWE-489: Active Debug Code"] + control: cra-ai-1 + patterns: + - pattern: '$APP.run(..., debug=True, ...)' + + - id: cra-ai-1-django-debug-true + languages: [python] + severity: WARNING + message: Django DEBUG = True — leaks stack traces / settings in production (secure-by-default violation). + metadata: + cwe: ["CWE-489: Active Debug Code"] + control: cra-ai-1 + patterns: + - pattern: 'DEBUG = True' + + - id: cra-ai-1-tls-verify-disabled + languages: [python] + severity: ERROR + message: TLS certificate verification disabled (verify=False) — defeats transport security by default. + metadata: + cwe: ["CWE-295: Improper Certificate Validation"] + control: cra-ai-1 + patterns: + - pattern: 'requests.$M(..., verify=False, ...)' + + - id: cra-ai-1-cors-wildcard + languages: [javascript, typescript] + severity: WARNING + message: CORS Access-Control-Allow-Origin set to "*" — opens the API to any origin by default. + metadata: + cwe: ["CWE-942: Permissive Cross-domain Policy with Untrusted Domains"] + control: cra-ai-1 + patterns: + - pattern-either: + - pattern: '$RES.header("Access-Control-Allow-Origin", "*")' + - pattern: '$RES.setHeader("Access-Control-Allow-Origin", "*")' + + # --- cra-ai-7: Starke Authentifizierung (weak password hashing) -------------- + - id: cra-ai-7-weak-password-hash + languages: [python] + severity: ERROR + message: Password/secret hashed with a fast, broken digest (md5/sha1) — use a password KDF (bcrypt/scrypt/argon2). + metadata: + cwe: ["CWE-916: Use of Password Hash With Insufficient Computational Effort"] + control: cra-ai-7 + patterns: + - pattern-either: + - pattern: 'hashlib.md5($PW)' + - pattern: 'hashlib.sha1($PW)' + - metavariable-regex: + metavariable: $PW + regex: '(?i).*(pass|pwd|secret|cred|token).*' + + # --- cra-ai-10: Sitzungsmanagement (insecure session cookies) ---------------- + - id: cra-ai-10-session-cookie-insecure + languages: [python] + severity: ERROR + message: Session cookie hardened flag explicitly disabled (Secure/HttpOnly = False) — session token exposed to theft. + metadata: + cwe: ["CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute"] + control: cra-ai-10 + patterns: + - pattern-either: + - pattern: 'SESSION_COOKIE_SECURE = False' + - pattern: 'SESSION_COOKIE_HTTPONLY = False' + + - id: cra-ai-10-express-cookie-insecure + languages: [javascript, typescript] + severity: ERROR + message: Express cookie set with secure/httpOnly = false — session token exposed to interception / XSS theft. + metadata: + cwe: ["CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute"] + control: cra-ai-10 + patterns: + - pattern-either: + - pattern: '$RES.cookie($NAME, $VAL, {..., secure: false, ...})' + - pattern: '$RES.cookie($NAME, $VAL, {..., httpOnly: false, ...})' + + # --- cra-ai-14: Speicher-Schutz / Data at Rest (weak cipher) ----------------- + - id: cra-ai-14-python-weak-cipher + languages: [python] + severity: ERROR + message: Data-at-rest encrypted with a broken cipher/mode (ECB, DES, 3DES) — provides no real confidentiality. + metadata: + cwe: ["CWE-327: Use of a Broken or Risky Cryptographic Algorithm"] + control: cra-ai-14 + patterns: + - pattern-either: + - pattern: 'AES.new($K, AES.MODE_ECB, ...)' + - pattern: 'DES.new(...)' + - pattern: 'DES3.new(...)' + + - id: cra-ai-14-node-weak-cipher + languages: [javascript, typescript] + severity: ERROR + message: Data-at-rest encrypted with a broken cipher (DES / deprecated createCipher) — provides no real confidentiality. + metadata: + cwe: ["CWE-327: Use of a Broken or Risky Cryptographic Algorithm"] + control: cra-ai-14 + patterns: + - pattern-either: + - pattern: 'crypto.createCipheriv("des-ecb", ...)' + - pattern: 'crypto.createCipheriv("des", ...)' + - pattern: 'crypto.createCipher(...)' diff --git a/compliance-agent/src/controls/triage.rs b/compliance-agent/src/controls/triage.rs index c1b0998..e34658e 100644 --- a/compliance-agent/src/controls/triage.rs +++ b/compliance-agent/src/controls/triage.rs @@ -44,10 +44,14 @@ impl ControlTriage { /// Triage one tool finding. `region` is the code around the finding, used as /// the grounding evidence for the judge. pub async fn triage(&self, finding: &Finding, region: &CandidateRegion) -> TriageOutcome { - let Some(cwe) = finding.cwe.as_deref() else { - return TriageOutcome::Unmapped; - }; - let mapped = self.map.controls_for(&finding.scanner, cwe); + // Match by CWE (off-the-shelf findings) and/or rule id (our custom + // detectors, which carry no LUT-bound CWE). A finding with neither is + // simply unmapped. + let mapped = self.map.controls_for_finding( + &finding.scanner, + finding.cwe.as_deref(), + finding.rule_id.as_deref(), + ); if mapped.is_empty() { return TriageOutcome::Unmapped; } @@ -162,6 +166,52 @@ mod tests { assert_eq!(out, TriageOutcome::FalsePositive); } + #[tokio::test] + async fn custom_rule_finding_without_cwe_is_confirmed() { + // A custom detector finding carries a rule id but no LUT-bound CWE; it must + // still map (by rule id) and confirm. + let mut specs = specs(); + specs.insert( + "cra-ai-1".to_string(), + ControlCheckSpec { + control_id: "cra-ai-1".into(), + title: "Secure-by-Default".into(), + requirement: "Ship secure defaults".into(), + default_cwe: None, + severity: Severity::Medium, + }, + ); + let triage = ControlTriage::new( + StubJudge { + verdict: LlmVerdict { + violates: true, + snippet: "app.run(debug=True)".into(), + cwe: None, + confidence: 0.9, + }, + }, + ControlMap::cra().unwrap(), + specs, + ); + let mut f = Finding::new( + "repo".into(), + "fp".into(), + "semgrep".into(), + ScanType::Sast, + "flask debug".into(), + "desc".into(), + Severity::Medium, + ); + f.rule_id = Some("tmp.x.cra-ai-1-flask-debug-enabled".into()); // no cwe + let region = CandidateRegion { + file: "app.py".into(), + start_line: 1, + content: "app.run(debug=True)\n".into(), + }; + let out = triage.triage(&f, ®ion).await; + assert_eq!(out, TriageOutcome::Confirmed(vec!["cra-ai-1".to_string()])); + } + #[tokio::test] async fn unmapped_cwe_is_left_untagged() { let triage = ControlTriage::new( diff --git a/compliance-agent/src/pipeline/semgrep.rs b/compliance-agent/src/pipeline/semgrep.rs index 6d28205..052d8f4 100644 --- a/compliance-agent/src/pipeline/semgrep.rs +++ b/compliance-agent/src/pipeline/semgrep.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use compliance_core::models::{Finding, ScanType, Severity}; use compliance_core::traits::{ScanOutput, Scanner}; @@ -6,6 +6,30 @@ use compliance_core::CoreError; use crate::pipeline::dedup; +/// Custom CRA-control detectors bundled into the binary and staged to a temp file +/// at scan time so semgrep can `--config` them alongside the auto ruleset. These +/// cover controls no off-the-shelf rule digs out (secure defaults, weak password +/// hashing, insecure session cookies, weak data-at-rest ciphers); each rule id is +/// keyed back to its control by the `control-map` LUT. +const CRA_RULES: &str = include_str!("../../rules/cra_semgrep.yaml"); + +/// Write the bundled CRA rules to a stable temp path (atomic: unique tmp + +/// rename). Returns `None` on failure — the scan then runs with auto rules only. +async fn stage_cra_rules() -> Option { + let dir = std::env::temp_dir(); + let path = dir.join("compliance-cra-semgrep.yaml"); + let tmp = dir.join(format!("compliance-cra-semgrep.{}.tmp", std::process::id())); + if let Err(e) = tokio::fs::write(&tmp, CRA_RULES).await { + tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only"); + return None; + } + if let Err(e) = tokio::fs::rename(&tmp, &path).await { + tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only"); + return None; + } + Some(path) +} + pub struct SemgrepScanner; impl Scanner for SemgrepScanner { @@ -19,30 +43,26 @@ impl Scanner for SemgrepScanner { #[tracing::instrument(skip_all)] async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { - let output = tokio::time::timeout( - std::time::Duration::from_secs(600), - tokio::process::Command::new("semgrep") - .args([ - "--config=auto", - "--json", - "--quiet", - "--max-memory", - "500", - "--jobs", - "1", - ]) - .arg(repo_path) - .output(), - ) - .await - .map_err(|_| CoreError::Scanner { - scanner: "semgrep".to_string(), - source: "timed out after 10 minutes".into(), - })? - .map_err(|e| CoreError::Scanner { - scanner: "semgrep".to_string(), - source: Box::new(e), - })?; + let cra_rules = stage_cra_rules().await; + let mut command = tokio::process::Command::new("semgrep"); + command.arg("--config=auto"); + if let Some(path) = &cra_rules { + command.arg(format!("--config={}", path.display())); + } + command + .args(["--json", "--quiet", "--max-memory", "500", "--jobs", "1"]) + .arg(repo_path); + + let output = tokio::time::timeout(std::time::Duration::from_secs(600), command.output()) + .await + .map_err(|_| CoreError::Scanner { + scanner: "semgrep".to_string(), + source: "timed out after 10 minutes".into(), + })? + .map_err(|e| CoreError::Scanner { + scanner: "semgrep".to_string(), + source: Box::new(e), + })?; if !output.status.success() && output.stdout.is_empty() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/control-map/data/cra_control_map.json b/control-map/data/cra_control_map.json index 3b238c5..b03961c 100644 --- a/control-map/data/cra_control_map.json +++ b/control-map/data/cra_control_map.json @@ -5,9 +5,21 @@ { "control": "cra-ai-1", "title": "Secure-by-Default-Konfiguration", - "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", - "status": "needs_tooling" + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [], + "rules": [ + "cra-ai-1-flask-debug-enabled", + "cra-ai-1-django-debug-true", + "cra-ai-1-tls-verify-disabled", + "cra-ai-1-cors-wildcard" + ] + } + ], + "note": null, + "status": "covered" }, { "control": "cra-ai-2", @@ -47,9 +59,18 @@ { "control": "cra-ai-7", "title": "Starke Authentifizierung", - "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", - "status": "needs_tooling" + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [], + "rules": [ + "cra-ai-7-weak-password-hash" + ] + } + ], + "note": null, + "status": "covered" }, { "control": "cra-ai-8", @@ -100,9 +121,19 @@ { "control": "cra-ai-10", "title": "Sitzungsmanagement", - "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", - "status": "needs_tooling" + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [], + "rules": [ + "cra-ai-10-session-cookie-insecure", + "cra-ai-10-express-cookie-insecure" + ] + } + ], + "note": null, + "status": "covered" }, { "control": "cra-ai-11", @@ -138,9 +169,19 @@ { "control": "cra-ai-14", "title": "Speicher-Schutz (Data at Rest)", - "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", - "status": "needs_tooling" + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [], + "rules": [ + "cra-ai-14-python-weak-cipher", + "cra-ai-14-node-weak-cipher" + ] + } + ], + "note": null, + "status": "covered" }, { "control": "cra-ai-15", diff --git a/control-map/src/lib.rs b/control-map/src/lib.rs index dfbb63a..8db0ede 100644 --- a/control-map/src/lib.rs +++ b/control-map/src/lib.rs @@ -66,6 +66,14 @@ pub struct ControlMap { const CRA_MAP_JSON: &str = include_str!("../data/cra_control_map.json"); +/// Whether an authored rule id `bound` matches a scanner's emitted rule id +/// `actual`. semgrep prefixes local-rule check_ids with a path +/// (`tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled`), so match the final +/// id segment rather than requiring exact equality. +fn rule_id_matches(bound: &str, actual: &str) -> bool { + actual == bound || actual.ends_with(&format!(".{bound}")) +} + impl ControlMap { /// Load the built-in CRA control map (the embedded, authored LUT). pub fn cra() -> Result { @@ -80,12 +88,28 @@ impl ControlMap { /// Controls whose bindings include the given `tool` + `cwe` — used to attach a /// raw tool finding back to the control(s) it's evidence for. pub fn controls_for(&self, tool: &str, cwe: &str) -> Vec<&ControlEntry> { + self.controls_for_finding(tool, Some(cwe), None) + } + + /// Controls a tool finding is evidence for, matched by CWE and/or the specific + /// rule id that fired. Off-the-shelf findings bind by CWE; our custom detectors + /// bind by rule id (precise — a broad CWE would over-attribute and then the + /// grounded judge could drop a genuine finding as a control false positive). + pub fn controls_for_finding( + &self, + tool: &str, + cwe: Option<&str>, + rule_id: Option<&str>, + ) -> Vec<&ControlEntry> { self.controls .iter() .filter(|c| { - c.scans - .iter() - .any(|s| s.tool == tool && s.cwe.iter().any(|w| w == cwe)) + c.scans.iter().any(|s| { + s.tool == tool + && (cwe.is_some_and(|w| s.cwe.iter().any(|x| x == w)) + || rule_id + .is_some_and(|r| s.rules.iter().any(|b| rule_id_matches(b, r)))) + }) }) .collect() } @@ -160,4 +184,46 @@ mod tests { assert!(s.needs_tooling > 0); assert!(s.not_code_checkable > 0); } + + #[test] + fn rule_id_matching_handles_semgrep_path_prefix() { + let bound = "cra-ai-1-flask-debug-enabled"; + assert!(rule_id_matches(bound, bound)); // exact + assert!(rule_id_matches( + bound, + "tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled" + )); // semgrep path prefix + assert!(!rule_id_matches( + bound, + "cra-ai-1-flask-debug-enabled-extra" + )); // not a suffix segment + assert!(!rule_id_matches( + bound, + "python.lang.security.exec-detected" + )); // unrelated + } + + #[test] + fn custom_rule_finding_attaches_to_control_by_rule_id() { + let map = ControlMap::cra().unwrap(); + // cra-ai-1 is now tool-covered by custom rules. + assert_eq!(map.coverage("cra-ai-1").unwrap().status, Coverage::Covered); + // A prefixed check_id still maps back to cra-ai-1 by rule id. + let hits = + map.controls_for_finding("semgrep", None, Some("tmp.x.cra-ai-1-tls-verify-disabled")); + assert!(hits.iter().any(|c| c.control == "cra-ai-1")); + } + + #[test] + fn custom_rule_controls_do_not_bind_by_broad_cwe() { + let map = ControlMap::cra().unwrap(); + // cra-ai-1 rules emit CWE-489 in metadata, but the LUT binds by rule id + // only (cwe: []) — so a stray CWE-489 finding must NOT attach to it. + assert!(map.controls_for("semgrep", "CWE-489").is_empty()); + // The CWE path for off-the-shelf findings is unchanged. + assert!(map + .controls_for("semgrep", "CWE-798") + .iter() + .any(|c| c.control == "cra-ai-8")); + } }