diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..b4b832cc --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,74 @@ +name: Fuzz + +# Coverage-guided fuzzing of the two parsers of zone bytes (docs/design/broker.md), weekly on a pinned nightly. + +on: + schedule: + - cron: '0 4 * * 0' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzz: + name: Fuzz ${{ matrix.target }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - target: broker_request + dir: compartments/kryptikd + seeds: compartments/kryptikd/fuzz-corpus/broker-requests + max_len: 4096 + - target: wire + dir: compositor/wlproxy + seeds: '' + max_len: 8192 + env: + # A dated nightly, so a new finding is never a new compiler's. + RUSTUP_TOOLCHAIN: nightly-2026-09-28 + FUZZ_TARGET: ${{ matrix.target }} + CRATE_DIR: ${{ matrix.dir }} + SEEDS: ${{ matrix.seeds }} + MAX_LEN: ${{ matrix.max_len }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Nightly Rust and cargo-fuzz + run: | + rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal + cargo +stable install cargo-fuzz --version 0.13.2 --locked + + # libfuzzer-sys comes from the fuzz crate's own lockfile, never from what ships. + - name: The fuzz crate's lockfile holds + run: cargo fetch --locked --manifest-path "$CRATE_DIR/fuzz/Cargo.toml" + + - name: Seeds from the corpus in the tree + run: | + mkdir -p "$RUNNER_TEMP/corpus" + # The tree keeps one request per line; libFuzzer takes one input per file. + if [ -n "$SEEDS" ]; then + n=0 + while IFS= read -r line || [ -n "$line" ]; do + n=$((n + 1)) + printf '%s\n' "$line" > "$RUNNER_TEMP/corpus/seed-$n" + done < "$SEEDS" + fi + echo "$(find "$RUNNER_TEMP/corpus" -type f | wc -l) seeds" + + # Safe Rust throughout, so no sanitizer: panics, overflow checks and the harness's asserts are the oracle. + - name: Twenty minutes of fuzzing + working-directory: ${{ matrix.dir }} + run: cargo fuzz run --sanitizer none "$FUZZ_TARGET" "$RUNNER_TEMP/corpus" -- -max_total_time=1200 -max_len="$MAX_LEN" -timeout=10 -print_final_stats=1 + + - name: Inputs that broke the parser + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: fuzz-${{ matrix.target }} + path: ${{ matrix.dir }}/fuzz/artifacts/ + retention-days: 30 + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index f2be4355..6560196f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ # Rust and Python /compartments/kryptikd/target/ compositor/target/ +**/fuzz/target/ +**/fuzz/corpus/ +**/fuzz/artifacts/ +**/fuzz/coverage/ __pycache__/ # Editor / OS noise diff --git a/compartments/kryptikd/Cargo.toml b/compartments/kryptikd/Cargo.toml index df31a6b5..c3220108 100644 --- a/compartments/kryptikd/Cargo.toml +++ b/compartments/kryptikd/Cargo.toml @@ -10,6 +10,10 @@ license = "GPL-2.0-or-later" [dependencies] libc = "0.2" +# Set only by cargo fuzz, which builds main.rs from fuzz/Cargo.toml. +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] } + [profile.release] opt-level = 2 lto = true diff --git a/compartments/kryptikd/fuzz/Cargo.lock b/compartments/kryptikd/fuzz/Cargo.lock new file mode 100644 index 00000000..9d691662 --- /dev/null +++ b/compartments/kryptikd/fuzz/Cargo.lock @@ -0,0 +1,90 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "kryptikd-fuzz" +version = "0.0.0" +dependencies = [ + "libc", + "libfuzzer-sys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/compartments/kryptikd/fuzz/Cargo.toml b/compartments/kryptikd/fuzz/Cargo.toml new file mode 100644 index 00000000..a1fbf9fa --- /dev/null +++ b/compartments/kryptikd/fuzz/Cargo.toml @@ -0,0 +1,33 @@ +# The broker's request parser under libFuzzer, run weekly by +# .github/workflows/fuzz.yml. A package and lockfile of its own, so +# libfuzzer-sys never reaches kryptikd's dependencies or Cargo.lock (ADR-010). +[package] +name = "kryptikd-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libc = "0.2" +libfuzzer-sys = "0.4" + +# The daemon itself: under cfg(fuzzing) main.rs takes broker_request.rs in place of main. +[[bin]] +name = "broker_request" +path = "../src/main.rs" +test = false +doc = false +bench = false + +# The release build's overflow checks, and debug assertions as well. +[profile.release] +debug-assertions = true +overflow-checks = true + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] } + +[workspace] diff --git a/compartments/kryptikd/fuzz/broker_request.rs b/compartments/kryptikd/fuzz/broker_request.rs new file mode 100644 index 00000000..3012a511 --- /dev/null +++ b/compartments/kryptikd/fuzz/broker_request.rs @@ -0,0 +1,27 @@ +//! The broker's request parser under libFuzzer: any header line a zone can +//! send, and whatever it accepts held to the bounds its refusals enforce. + +use libfuzzer_sys::fuzz_target; + +use crate::broker::{check_transfer_name, parse_request, Request, CLIPBOARD_MAX, MIME_TYPES}; +use crate::update::{POINTER_MAX, PUT_MAX}; + +fuzz_target!(|data: &[u8]| { + // The header as serve_connection takes it: up to the first newline, decoded lossily. + let Some(nl) = data.iter().position(|b| *b == b'\n') else { return }; + let header = String::from_utf8_lossy(&data[..nl]); + match parse_request(&header) { + Err(_) | Ok(Request::Version | Request::ClipboardGet | Request::UpdatePoll) => {} + Ok(Request::ClipboardSet { mime, len }) => assert!(MIME_TYPES.contains(&mime.as_str()) && len <= CLIPBOARD_MAX), + Ok(Request::Transfer { dest, name }) => { + assert!((1..=12).contains(&dest.len())); + assert!(dest.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')); + assert!(check_transfer_name(&name).is_ok()); + } + Ok(Request::TimeOffset(c)) => assert!(c.offset.is_finite() && c.offset.abs() <= 1e10 && (1..=16).contains(&c.sources)), + Ok(Request::UpdateLatest { plen, slen }) => assert!((1..=POINTER_MAX).contains(&plen) && (1..=POINTER_MAX).contains(&slen)), + Ok(Request::UpdatePut { name, len, .. }) => assert!(check_transfer_name(&name).is_ok() && (1..=PUT_MAX).contains(&len)), + Ok(Request::NotAZoneVerb(v)) => assert_eq!(v, "clipboard-move"), + Ok(Request::Unknown(v)) => assert!(!v.is_empty() && !v.contains(char::is_whitespace)), + } +}); diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index d2f54db7..ab7b5b9e 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -4,6 +4,9 @@ //! process that creates zones or moves data between them. Anything not built //! is refused with an error, never a silent no-op. +// cargo fuzz builds this file with cfg(fuzzing), and libFuzzer brings its own main. +#![cfg_attr(fuzzing, no_main, allow(dead_code))] + mod broker; mod caps; mod cgroup; @@ -24,6 +27,10 @@ mod update; mod volume; mod wifi; mod zone; +// The parser is private to the daemon, so its fuzz target compiles inside it (fuzz/Cargo.toml). +#[cfg(fuzzing)] +#[path = "../fuzz/broker_request.rs"] +mod fuzz; use std::path::{Path, PathBuf}; use std::process::ExitCode; diff --git a/compositor/wlproxy/fuzz/Cargo.lock b/compositor/wlproxy/fuzz/Cargo.lock new file mode 100644 index 00000000..30cd514d --- /dev/null +++ b/compositor/wlproxy/fuzz/Cargo.lock @@ -0,0 +1,89 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "wlproxy-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", +] diff --git a/compositor/wlproxy/fuzz/Cargo.toml b/compositor/wlproxy/fuzz/Cargo.toml new file mode 100644 index 00000000..44a0c655 --- /dev/null +++ b/compositor/wlproxy/fuzz/Cargo.toml @@ -0,0 +1,29 @@ +# kryptik-wlproxy's wire parser under libFuzzer, run weekly by +# .github/workflows/fuzz.yml. A package, workspace and lockfile of its own, so +# libfuzzer-sys never reaches the compositor's dependencies or Cargo.lock +# (ADR-010). +[package] +name = "wlproxy-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[[bin]] +name = "wire" +path = "wire.rs" +test = false +doc = false +bench = false + +# The release build's overflow checks, and debug assertions as well. +[profile.release] +debug-assertions = true +overflow-checks = true + +[workspace] diff --git a/compositor/wlproxy/fuzz/wire.rs b/compositor/wlproxy/fuzz/wire.rs new file mode 100644 index 00000000..5938f201 --- /dev/null +++ b/compositor/wlproxy/fuzz/wire.rs @@ -0,0 +1,49 @@ +//! kryptik-wlproxy's wire parser under libFuzzer: message framing, every +//! signature in the generated tables, and the writer the rewrites use. These +//! three files name nothing else in the proxy, so they compile here as they are. + +#![no_main] +#![allow(dead_code)] + +#[path = "../src/protocol.rs"] +mod protocol; +#[path = "../src/protocol_tables.rs"] +mod protocol_tables; +#[path = "../src/wire.rs"] +mod wire; + +use libfuzzer_sys::fuzz_target; +use protocol::{decode, Arg, Message}; +use wire::{ArgReader, Header, MessageWriter, HEADER_LEN}; + +/// Every request and event in the tables. +fn messages() -> &'static [&'static Message] { + static ALL: std::sync::OnceLock> = std::sync::OnceLock::new(); + ALL.get_or_init(|| protocol_tables::INTERFACES.iter().flat_map(|i| i.requests.iter().chain(i.events)).collect()) +} + +fuzz_target!(|data: &[u8]| { + let all = messages(); + let mut rest = data; + // A stream of messages, each read against the signature its object id picks. + while let Ok(h) = Header::parse(rest) { + assert_eq!(Header::parse(&h.encode()), Ok(h)); + let size = h.size as usize; + let Some(body) = rest.get(HEADER_LEN..size) else { break }; + let m = all[h.object as usize % all.len()]; + if let Ok(d) = decode(m, body) { + assert_eq!(d.new_object.is_some(), m.args.iter().any(|a| matches!(a, Arg::NewId { .. }))); + assert_eq!(d.bind_version.is_some(), m.args.contains(&Arg::NewId { iface: None })); + for (at, s) in &d.strings { + // The offset a rewrite trusts, and what the proxy would write in its place. + assert_eq!(&body[at + 4..at + 4 + s.len()], s.as_bytes()); + assert_eq!(body[at + 4 + s.len()], 0); + if let Some(out) = MessageWriter::new(h.object, h.opcode).string(s).finish() { + assert_eq!(Header::parse(&out).map(|w| w.size as usize), Ok(out.len())); + assert_eq!(ArgReader::new(&out[HEADER_LEN..]).string(), Ok(Some(*s))); + } + } + } + rest = &rest[size..]; + } +}); diff --git a/docs/design/broker.md b/docs/design/broker.md index 50ff3345..b8ef5d72 100755 --- a/docs/design/broker.md +++ b/docs/design/broker.md @@ -195,7 +195,14 @@ title to `[zone] ...`, from which the compositor draws the zone's border. the body, only exact parses accepted) and feeds a damaged opening through a live session in arbitrary fragments (only whole messages reach the compositor). Inputs that ever break a parser join the corpus. - Coverage-guided fuzzing needs nightly Rust and belongs in a scheduled job. +- `.github/workflows/fuzz.yml` fuzzes both parsers under libFuzzer weekly, + twenty minutes each, on a nightly pinned by date. `compartments/kryptikd/fuzz` + takes the request parser, seeded from that corpus, and holds every request + it accepts to the bounds its refusals enforce; `compositor/wlproxy/fuzz` + takes the framing, every signature in the tables and the writer the + rewrites use. An input that breaks one is kept as the run's artifact. Each + is a package with a lockfile of its own, so libfuzzer-sys is in nothing that + ships. ## Not built