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
71 changes: 71 additions & 0 deletions docs/doctoring/ci-attack-evidence-battery.md
Original file line number Diff line number Diff line change
@@ -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 <id>`, 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.
31 changes: 22 additions & 9 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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`.
205 changes: 193 additions & 12 deletions src/coraza_abi_stub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Expand Down Expand Up @@ -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: "<script",
rule_id: 941100,
message: XSS_MESSAGE,
},
BatteryEntry {
needle: "%3cscript",
rule_id: 941100,
message: XSS_MESSAGE,
},
BatteryEntry {
needle: "onerror=alert",
rule_id: 941100,
message: XSS_MESSAGE,
},
// RCE and Log4j entries precede traversal entries because command
// fixtures such as `; cat /etc/passwd` also contain traversal-looking
// substrings; first-match ordering keeps rule attribution deterministic.
BatteryEntry {
needle: "; cat ",
rule_id: 932100,
message: RCE_MESSAGE,
},
BatteryEntry {
needle: "%3b%20cat%20",
rule_id: 932100,
message: RCE_MESSAGE,
},
BatteryEntry {
needle: "/bin/sh",
rule_id: 932100,
message: RCE_MESSAGE,
},
BatteryEntry {
needle: "$(whoami)",
rule_id: 932100,
message: RCE_MESSAGE,
},
BatteryEntry {
needle: "${jndi",
rule_id: 944120,
message: LOG4J_MESSAGE,
},
BatteryEntry {
needle: "%24%7bjndi",
rule_id: 944120,
message: LOG4J_MESSAGE,
},
BatteryEntry {
needle: "../",
rule_id: 930100,
message: TRAVERSAL_MESSAGE,
},
BatteryEntry {
needle: "..%2f",
rule_id: 930100,
message: TRAVERSAL_MESSAGE,
},
BatteryEntry {
needle: "..%252f",
rule_id: 930100,
message: TRAVERSAL_MESSAGE,
},
BatteryEntry {
needle: "etc/passwd",
rule_id: 930100,
message: TRAVERSAL_MESSAGE,
},
BatteryEntry {
needle: "etc%2fpasswd",
rule_id: 930100,
message: TRAVERSAL_MESSAGE,
},
];
Comment thread
seonghobae marked this conversation as resolved.

fn contains_ignore_case(haystack: &str, needle: &str) -> 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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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,
Expand Down
Loading
Loading