diff --git a/Cargo.lock b/Cargo.lock index 57648f2..e34b538 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[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" @@ -761,6 +777,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[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" @@ -1085,6 +1107,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.40" @@ -1306,6 +1341,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 = "1.0.69" @@ -1606,6 +1654,7 @@ dependencies = [ "serde_json", "sha2", "subtle", + "tempfile", "thiserror 1.0.69", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 00f7c52..3434fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,9 @@ thiserror = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "io-util"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } + +[dev-dependencies] +# `tempfile` powers test fixtures that need a real on-disk audit log. +# Confined to dev-dependencies so it does not bloat the production +# binary. +tempfile = "3" diff --git a/src/audit.rs b/src/audit.rs index 9307215..8e742d8 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -1,122 +1,223 @@ //! Audit Logging for Vyrox Proxy //! -//! This module provides append-only audit logging for all containment -//! actions executed by the proxy. Audit logs are critical for: +//! Append-only audit log for every containment action the proxy +//! executes. Two properties make this log compliance-grade: //! -//! - Compliance (SOC 2, HIPAA, GDPR) -//! - Incident investigation and forensics -//! - Detecting unauthorized or anomalous actions -//! - Tenant isolation verification +//! 1. **Append-only on disk.** Files are opened with the `append` +//! flag and never overwritten. Even a programming bug here cannot +//! rewrite history. +//! 2. **SHA-256 hash chain across entries.** Every entry carries the +//! hash of its predecessor plus its own canonical-JSON hash. A +//! single tampered byte breaks the chain at that point, and an +//! auditor can detect the break by recomputing the chain. //! -//! ## Log Format +//! The hash chain is the property the Python side (`shared/audit.py`) +//! has always carried. Until 2026-05-23 the Rust proxy wrote +//! unchained JSONL, so `/audit/export` outputs from the proxy were +//! not tamper-evident and a compliance team auditing a SOC 2 +//! evidence sample would (correctly) reject them. This module +//! brings the two sides into agreement. +//! +//! ## On-disk format +//! +//! Each line is a single JSON object: //! -//! Logs are stored in JSONL format (one JSON object per line): //! ```json -//! {"timestamp":1699999999,"tenant_id":"abc123","action_type":"HOST_ISOLATION","host":"workstation-01","approved_by":"analyst@company.com","dry_run":false} +//! { +//! "timestamp": 1700000000, +//! "tenant_id": "abc123", +//! "action_type": "HOST_ISOLATION", +//! "host": "workstation-01", +//! "approved_by": "analyst@company.com", +//! "dry_run": false, +//! "previous_hash": "0000...0000", +//! "hash": "e3b0c4..." +//! } //! ``` //! -//! ## Security Properties +//! - `previous_hash` is the `hash` of the most recently written +//! entry, or 64 zeros for the very first entry (the "genesis" +//! entry). +//! - `hash` is the SHA-256 of the bytes +//! `previous_hash || canonical_json(payload_fields)`, where +//! `payload_fields` is everything in the entry except `hash` +//! itself. The canonical JSON form uses sorted keys and no +//! whitespace so the chain is reproducible across writers and +//! platforms. +//! +//! ## Chain continuity across restarts //! -//! - Append-only: Files are opened with append flag, never overwritten -//! - Tenant-scoped: Each entry includes tenant_id for isolation -//! - Timestamped: All entries include UTC timestamps -//! - Non-blocking: Async I/O prevents blocking request handling +//! The chain state lives in `ChainState`, which the binary +//! constructs at startup by reading the last hash from the most +//! recent log file. A clean restart picks up exactly where the +//! previous process left off. If the log file does not exist yet +//! (fresh deploy), the chain starts at the genesis hash. + +use std::sync::Arc; use chrono::Utc; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +/// Sentinel value used as `previous_hash` for the first entry in a +/// brand-new log file. Sixty-four ASCII zeros — chosen because the +/// Python side uses the same convention so both chains agree on what +/// "no predecessor" looks like. +pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; -/// Single audit log entry representing one action execution. +/// One entry on disk. /// -/// This struct is serialized to JSON for storage in the audit log. -/// Each field captures important context for investigation. +/// Wire-stable: every field appears in the on-disk JSON object, +/// including `previous_hash` and `hash`. Adding or removing a field +/// here breaks any tooling that consumes the JSONL stream, so treat +/// the layout as a compatibility contract. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AuditEntry { - /// Unix timestamp (UTC) when the action was recorded. - /// Use this for chronological ordering and time-based queries. + /// Unix timestamp (UTC) when the entry was recorded. pub timestamp: i64, /// Tenant identifier for multi-tenant isolation. - /// This field enables per-tenant audit log filtering and export. pub tenant_id: String, - /// Type of containment action (HOST_ISOLATION, KILL_PROCESS, etc). - /// Corresponds to the actions::ActionType enum. + /// Type of containment action (HOST_ISOLATION, KILL_PROCESS, ...). pub action_type: String, - /// Target hostname or IP address of the action. - /// This is the endpoint where the action was applied. + /// Target hostname or device ID. pub host: String, /// Discord username who approved this action. - /// Used for accountability - who made the decision to contain. pub approved_by: String, - /// Whether this was a dry-run (no actual execution). - /// In development mode, actions are logged but not executed. + /// Whether this was a dry-run. pub dry_run: bool, + + /// SHA-256 hash of the previous entry (or `GENESIS_HASH` for + /// the very first entry). Together with `hash` this forms the + /// tamper-evident chain — see module docs. + #[serde(default = "default_genesis")] + pub previous_hash: String, + + /// SHA-256 hash of `previous_hash || canonical_json(payload)`. + /// Computed by `append_audit` immediately before write; the + /// caller does not set this field. + #[serde(default)] + pub hash: String, } -/// Append an audit entry to the log file. -/// -/// This function opens the audit log in append mode and writes -/// a single JSON-formatted entry. It never modifies existing content, -/// ensuring the log is append-only. -/// -/// ## Arguments -/// -/// - `path`: File path to the audit log (JSONL format) -/// - `entry`: AuditEntry to append +fn default_genesis() -> String { + GENESIS_HASH.to_string() +} + +/// Running state for the hash chain. /// -/// ## Returns +/// The state is shared (via `Arc>`) so concurrent +/// `append_audit` calls serialize at the chain boundary. The mutex +/// is held only for the brief window between "read last hash" and +/// "write new entry," which is microseconds — there is no real +/// contention at our request rates. +#[derive(Clone)] +pub struct ChainState { + inner: Arc>, +} + +struct ChainStateInner { + last_hash: String, +} + +impl ChainState { + /// Build a chain state initialized to the genesis hash. + /// + /// Useful for tests and for the first-boot path when no audit + /// log exists yet. + #[allow(dead_code)] + pub fn genesis() -> Self { + Self { + inner: Arc::new(Mutex::new(ChainStateInner { + last_hash: GENESIS_HASH.to_string(), + })), + } + } + + /// Build a chain state seeded from the most recent entry in an + /// existing log file. + /// + /// Reads the file, finds the last well-formed entry, and uses its + /// `hash` as the seed. If the file does not exist or is empty, + /// the chain starts at `GENESIS_HASH`. Errors fall through to + /// genesis as well — better to start fresh than to refuse to + /// boot. If you want strict mode, swap in a fail-loud helper. + pub async fn from_file(path: &str) -> Self { + let last = read_last_hash(path) + .await + .unwrap_or_else(|_| GENESIS_HASH.to_string()); + Self { + inner: Arc::new(Mutex::new(ChainStateInner { last_hash: last })), + } + } +} + +/// Append an audit entry to the log file, linking it into the chain. /// -/// - `Ok(())` on successful write -/// - `Err(std::io::Error)` if the file cannot be opened or written +/// The caller passes a partially-populated `AuditEntry` (whatever +/// `build_entry` returned, with `previous_hash`/`hash` left at their +/// default values). This function: /// -/// ## Notes +/// 1. Locks the chain state so concurrent appends serialize. +/// 2. Stamps `previous_hash` from the chain state. +/// 3. Computes `hash` as SHA-256 of `previous_hash` plus the +/// canonical JSON of the entry's payload fields (everything +/// except `hash` itself). +/// 4. Writes the full entry as one JSONL line to disk. +/// 5. Advances the chain state to the new entry's hash. /// -/// - Creates the file if it doesn't exist -/// - Flushes after write to ensure durability -/// - Thread-safe for concurrent writes (OS handles locking) -pub async fn append_audit(path: &str, entry: AuditEntry) -> Result<(), std::io::Error> { - // Open file in append mode - creates if not exists - // The append flag ensures we never overwrite existing entries +/// Any I/O failure is propagated to the caller, which currently +/// releases the nonce and surfaces HTTP 500 so the bot can retry. +pub async fn append_audit( + path: &str, + state: &ChainState, + mut entry: AuditEntry, +) -> Result<(), std::io::Error> { + let mut guard = state.inner.lock().await; + + // Stamp the chain link before computing the hash so the hash + // covers the linkage too. + entry.previous_hash = guard.last_hash.clone(); + entry.hash = String::new(); // explicit — hash never participates in its own input + + let computed = compute_entry_hash(&entry); + entry.hash = computed.clone(); + + let line = serde_json::to_string(&entry) + .map_err(|err| std::io::Error::other(format!("serialize entry: {err}")))?; + + // `create(true).append(true)` opens for append and creates the + // file if it does not exist. The `append` flag is honoured by + // the kernel, so two appenders cannot stomp each other. let mut file = OpenOptions::new() .create(true) .append(true) .open(path) .await?; - // Serialize entry to JSON with no extra whitespace for efficiency - let line = serde_json::to_string(&entry).expect("audit entry serialization should not fail"); - - // Write the JSON line followed by newline file.write_all(line.as_bytes()).await?; file.write_all(b"\n").await?; - - // Flush to ensure data is written to disk (not just buffered) + // fsync the file before we declare the entry durable. Audit + // logs are infrequent; the cost is negligible and the alternative + // is losing the entry on a power cut between write() and the + // kernel's writeback. file.flush().await?; + file.sync_data().await?; + guard.last_hash = computed; Ok(()) } -/// Build a new audit entry with current timestamp. -/// -/// This is a convenience constructor that captures the current time -/// and packages all action details into an AuditEntry. -/// -/// ## Arguments -/// -/// - `tenant_id`: Tenant identifier -/// - `action_type`: Type of action from actions::ActionType -/// - `host`: Target hostname -/// - `approved_by`: Username who approved -/// - `dry_run`: Whether this is a simulation (no actual execution) -/// -/// ## Returns -/// -/// A fully-populated AuditEntry with current timestamp +/// Construct a fresh `AuditEntry` with the current UTC timestamp +/// and the chain-link / hash fields left blank for `append_audit` +/// to fill in. pub fn build_entry( tenant_id: String, action_type: String, @@ -131,47 +232,193 @@ pub fn build_entry( host, approved_by, dry_run, + previous_hash: GENESIS_HASH.to_string(), + hash: String::new(), } } /// Read and parse audit log entries from file. /// -/// This function reads the entire audit log file and parses each -/// line as an AuditEntry. It is used by the /audit/export endpoint -/// to retrieve filtered audit logs. -/// -/// ## Arguments -/// -/// - `path`: File path to the audit log -/// -/// ## Returns -/// -/// - `Ok(Vec)` containing all parsed entries -/// - `Err(std::io::Error)` if the file cannot be read -/// -/// ## Notes -/// -/// - Silently skips malformed lines (defensive parsing) -/// - Memory-intensive for large logs - consider pagination for production +/// Used by `GET /audit/export`. Silently skips malformed lines so a +/// single bad entry does not block the whole file from being read. +/// Returns an empty vec if the file does not exist (the request +/// authenticated successfully but the tenant has no history yet). pub async fn read_audit_logs(path: &str) -> Result, std::io::Error> { - // Read entire file into memory - let content = tokio::fs::read_to_string(path).await?; + let content = match tokio::fs::read_to_string(path).await { + Ok(c) => c, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err), + }; - // Parse each line as a JSON audit entry - // Using simple loop rather than iterator for clarity let mut entries = Vec::new(); for line in content.lines() { - // Skip empty lines if line.is_empty() { continue; } - - // Parse JSON, skip malformed entries - // This defensive parsing ensures one bad entry doesn't fail the whole file if let Ok(entry) = serde_json::from_str::(line) { entries.push(entry); } } - Ok(entries) } + +/// Read the hash of the most recently written entry from a log file. +/// +/// Used at startup to seed the chain state. Returns the file's last +/// valid entry's `hash`. If no valid entry exists (empty file, all +/// lines malformed, file not found), returns `GENESIS_HASH`. +pub async fn read_last_hash(path: &str) -> Result { + let entries = read_audit_logs(path).await?; + Ok(entries + .last() + .map(|e| e.hash.clone()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| GENESIS_HASH.to_string())) +} + +/// Compute the SHA-256 hash of an audit entry's payload fields, +/// linked to the previous entry via `previous_hash`. +/// +/// The "canonical payload" is a sorted-key, no-whitespace JSON +/// object containing every field EXCEPT `hash` (which is the output +/// of this function and cannot participate in its own input). Using +/// canonical JSON makes the hash reproducible byte-for-byte across +/// platforms and across the Python / Rust split. +fn compute_entry_hash(entry: &AuditEntry) -> String { + // We serialise a manual struct so we can control field ordering + // independently of the `Serialize` derive on `AuditEntry`. + // `serde_json` already sorts keys alphabetically when given a + // BTreeMap, but constructing one inline keeps the code obvious. + let payload = serde_json::json!({ + "action_type": entry.action_type, + "approved_by": entry.approved_by, + "dry_run": entry.dry_run, + "host": entry.host, + "previous_hash": entry.previous_hash, + "tenant_id": entry.tenant_id, + "timestamp": entry.timestamp, + }); + let canonical = serde_json::to_vec(&payload).expect("payload always serialises"); + + let mut hasher = Sha256::new(); + hasher.update(entry.previous_hash.as_bytes()); + hasher.update(b"|"); + hasher.update(&canonical); + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn fresh_chain_starts_at_genesis() { + let state = ChainState::genesis(); + let guard = state.inner.lock().await; + assert_eq!(guard.last_hash, GENESIS_HASH); + } + + #[tokio::test] + async fn append_links_previous_hash() { + let tmp = NamedTempFile::new().expect("tmp"); + let path = tmp.path().to_str().unwrap().to_string(); + let state = ChainState::genesis(); + + let entry_a = build_entry( + "t1".into(), + "HOST_ISOLATION".into(), + "host-a".into(), + "alice".into(), + true, + ); + append_audit(&path, &state, entry_a) + .await + .expect("append a"); + + let entry_b = build_entry( + "t1".into(), + "HOST_ISOLATION".into(), + "host-b".into(), + "bob".into(), + true, + ); + append_audit(&path, &state, entry_b) + .await + .expect("append b"); + + let entries = read_audit_logs(&path).await.expect("read"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].previous_hash, GENESIS_HASH); + assert_eq!(entries[1].previous_hash, entries[0].hash); + assert_ne!(entries[0].hash, entries[1].hash); + assert!(!entries[1].hash.is_empty()); + } + + #[tokio::test] + async fn chain_survives_restart() { + let tmp = NamedTempFile::new().expect("tmp"); + let path = tmp.path().to_str().unwrap().to_string(); + + // "First boot" — write one entry. + let state1 = ChainState::genesis(); + let entry_a = build_entry( + "t1".into(), + "HOST_ISOLATION".into(), + "host-a".into(), + "alice".into(), + true, + ); + append_audit(&path, &state1, entry_a).await.expect("a"); + + // Capture the hash we wrote. + let entries = read_audit_logs(&path).await.expect("read"); + let saved_hash = entries[0].hash.clone(); + + // "Restart" — load chain state from the existing file. + let state2 = ChainState::from_file(&path).await; + { + let guard = state2.inner.lock().await; + assert_eq!(guard.last_hash, saved_hash, "chain seed should match"); + } + + // Append one more and confirm the link is preserved. + let entry_b = build_entry( + "t1".into(), + "HOST_ISOLATION".into(), + "host-b".into(), + "bob".into(), + true, + ); + append_audit(&path, &state2, entry_b).await.expect("b"); + + let entries = read_audit_logs(&path).await.expect("read"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[1].previous_hash, saved_hash); + } + + #[tokio::test] + async fn tampering_breaks_chain() { + let tmp = NamedTempFile::new().expect("tmp"); + let path = tmp.path().to_str().unwrap().to_string(); + let state = ChainState::genesis(); + + let entry_a = build_entry( + "t1".into(), + "HOST_ISOLATION".into(), + "host-a".into(), + "alice".into(), + true, + ); + append_audit(&path, &state, entry_a).await.expect("a"); + + // Re-read, mutate `host` in the on-disk entry, and confirm + // recomputing the hash produces a different value than what + // is stored. + let entries = read_audit_logs(&path).await.expect("read"); + let mut tampered = entries[0].clone(); + tampered.host = "host-evil".into(); + let recomputed = compute_entry_hash(&tampered); + assert_ne!(recomputed, entries[0].hash, "tamper must be detectable"); + } +} diff --git a/src/edr.rs b/src/edr.rs index 71f7eca..c1db3d3 100644 --- a/src/edr.rs +++ b/src/edr.rs @@ -118,8 +118,9 @@ impl EdrClient { EdrClient::Noop } "crowdstrike" => { - let client = CrowdstrikeClient::from_env() - .expect("CROWDSTRIKE_CLIENT_ID/SECRET must be set when EDR_PROVIDER=crowdstrike"); + let client = CrowdstrikeClient::from_env().expect( + "CROWDSTRIKE_CLIENT_ID/SECRET must be set when EDR_PROVIDER=crowdstrike", + ); info!("EDR provider: crowdstrike"); EdrClient::Crowdstrike(Arc::new(client)) } @@ -359,8 +360,17 @@ mod tests { #[tokio::test] async fn noop_client_succeeds_for_all_actions() { let client = EdrClient::Noop; - assert!(client.dispatch(ActionType::HostIsolation, "h-1").await.is_ok()); - assert!(client.dispatch(ActionType::ProcessKill, "h-1").await.is_ok()); - assert!(client.dispatch(ActionType::NetworkQuarantine, "h-1").await.is_ok()); + assert!(client + .dispatch(ActionType::HostIsolation, "h-1") + .await + .is_ok()); + assert!(client + .dispatch(ActionType::ProcessKill, "h-1") + .await + .is_ok()); + assert!(client + .dispatch(ActionType::NetworkQuarantine, "h-1") + .await + .is_ok()); } } diff --git a/src/main.rs b/src/main.rs index 25ce98d..8c4d230 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,11 @@ struct AppState { /// EDR client implementation. Currently dispatches to the configured /// EDR (CrowdStrike Falcon for v0.1-alpha pilot). See `edr.rs`. edr: edr::EdrClient, + + /// SHA-256 hash chain state for the audit log. Seeded at boot from + /// the last entry on disk so restarts do not break the chain. + /// See `audit::ChainState`. + audit_chain: audit::ChainState, } /// Request payload for `POST /execute`. @@ -202,7 +207,8 @@ async fn execute( // Only after the HMAC passes do we trust the body enough to parse // it. Parsing before verification would expose any serde panic / // pathological input to unauthenticated callers. - let payload: ExecuteRequest = serde_json::from_slice(&body).map_err(|_| StatusCode::BAD_REQUEST)?; + let payload: ExecuteRequest = + serde_json::from_slice(&body).map_err(|_| StatusCode::BAD_REQUEST)?; if payload.request_id.trim().is_empty() { return Err(StatusCode::BAD_REQUEST); @@ -247,7 +253,7 @@ async fn execute( payload.approved_by.clone(), state.dry_run, ); - if let Err(err) = audit::append_audit(&state.audit_log_path, entry).await { + if let Err(err) = audit::append_audit(&state.audit_log_path, &state.audit_chain, entry).await { // Audit log failure is fatal — we don't proceed without a // forensic trail. Release the nonce so the bot can retry once // the underlying issue (disk full, perm error) is fixed. @@ -294,7 +300,8 @@ async fn execute( // ─ Step 7: Cache the response for future retries. ──────────────── // // Serialize once so subsequent replays return byte-identical output. - let cache_payload = serde_json::to_string(&response).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let cache_payload = + serde_json::to_string(&response).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; state .nonces .record_response(&payload.request_id, cache_payload); @@ -308,13 +315,76 @@ async fn execute( /// server-side so a misbehaving caller cannot read another tenant's /// entries by post-processing. /// -/// Note for production: this reads the entire log into memory on every -/// call. Fine for pilot scale (10s of MB max); for SaaS we'll move to a -/// streaming JSONL response and per-tenant log shards. +/// ## Authentication +/// +/// The export endpoint is HMAC-protected. Callers must send: +/// +/// `X-Vyrox-Signature: sha256=` — HMAC-SHA256 of the canonical +/// message `":"`. +/// `X-Vyrox-Timestamp: ` — UTC unix timestamp used in +/// the canonical message. +/// +/// The signature is computed over `format!("{tenant_id}:{timestamp}")` +/// using the same `VYROX_HMAC_SECRET` that protects `/execute`. The +/// timestamp is rejected if it falls outside the replay window. This +/// gives `/audit/export` parity with `/execute` for auth + replay +/// protection without needing a request body. +/// +/// Without this check, anyone who reaches the proxy can dump any +/// tenant's containment history just by passing a tenant_id query +/// parameter. That was the SEV-2 leak we shipped in the original +/// pilot build; this commit closes it. +/// +/// ## Production notes +/// +/// This reads the entire log into memory on every call. Fine for +/// pilot scale (10s of MB max); for SaaS we'll move to a streaming +/// JSONL response and per-tenant log shards. async fn export_audit( State(state): State, Query(query): Query, + headers: HeaderMap, ) -> Result>, StatusCode> { + // ─ Step 1: Pull and validate the auth headers. ───────────────── + let signature = headers + .get("X-Vyrox-Signature") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let timestamp_str = headers + .get("X-Vyrox-Timestamp") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let timestamp: i64 = timestamp_str + .trim() + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + // ─ Step 2: Replay window. ───────────────────────────────────── + // + // Same window as /execute. A stale `X-Vyrox-Timestamp` cannot be + // used to repeatedly fetch a tenant's audit log forever. + check_replay_window(timestamp).map_err(|_| StatusCode::UNAUTHORIZED)?; + + // ─ Step 3: HMAC verify on the canonical message. ────────────── + // + // The canonical message is `":"`. It binds + // the request to (a) the tenant being queried, so an attacker + // cannot swap the tenant_id query parameter without invalidating + // the signature, and (b) the timestamp, so a replay outside the + // window is impossible. + let canonical = format!("{}:{}", query.tenant_id, timestamp); + if let Err(err) = hmac::verify_signature( + state.hmac_secret.as_bytes(), + canonical.as_bytes(), + signature, + ) { + warn!(error = %err, "audit export signature verification failed"); + return Err(StatusCode::UNAUTHORIZED); + } + + // ─ Step 4: Actual export. ───────────────────────────────────── let entries = audit::read_audit_logs(&state.audit_log_path) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -364,8 +434,7 @@ async fn main() { warn!("VYROX_HMAC_SECRET is shorter than 32 bytes; consider rotating to a longer key"); } - let audit_log_path = - env::var("AUDIT_LOG_PATH").unwrap_or_else(|_| "./audit.jsonl".to_string()); + let audit_log_path = env::var("AUDIT_LOG_PATH").unwrap_or_else(|_| "./audit.jsonl".to_string()); // Safe-by-default: DRY_RUN is TRUE unless explicitly turned off. // Operators who want real execution must opt in. @@ -375,12 +444,18 @@ async fn main() { // contract — secrets are read from env there, not here. let edr = edr::EdrClient::from_env(); + // Seed the audit chain from the existing log file so a restart + // continues the chain instead of branching from genesis. New + // deployments with no log file fall through to the genesis hash. + let audit_chain = audit::ChainState::from_file(&audit_log_path).await; + let state = AppState { hmac_secret, audit_log_path, dry_run, nonces: nonce::NonceStore::new(), edr, + audit_chain, }; let app = Router::new() diff --git a/src/nonce.rs b/src/nonce.rs index 807face..549d9c3 100644 --- a/src/nonce.rs +++ b/src/nonce.rs @@ -305,7 +305,11 @@ mod tests { matches!(s.claim_or_replay("hot-key"), Outcome::FreshClaim) })); } - let fresh_wins: usize = handles.into_iter().filter_map(|h| h.join().ok()).filter(|b| *b).count(); + let fresh_wins: usize = handles + .into_iter() + .filter_map(|h| h.join().ok()) + .filter(|b| *b) + .count(); assert_eq!(fresh_wins, 1, "exactly one claim must win FreshClaim"); } }