From 77bfa6c96218ed90b441770c8e8a8cc3cdc4f522 Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Thu, 11 Jun 2026 18:02:48 +0530 Subject: [PATCH 1/7] fix(proxy): honest EDR action mapping, no silent downgrade PROCESS_KILL on CrowdStrike used to quietly map to host containment with only a log line: the operator approved a surgical kill and the proxy quarantined the whole host. SentinelOne ignored the action type entirely and disconnected the host for every action. Both are gone. - crowdstrike_action_name and sentinelone_action_path now map each (ActionType, ActionDirection) pair to exactly one vendor call or fail loudly with EdrError::Unsupported. A true CrowdStrike kill needs an RTR session plus a process id, and an S1 kill via threat mitigation needs an S1 threat id; the signed request carries neither, so the proxy refuses instead of substituting a different action. - HOST_ISOLATION and NETWORK_QUARANTINE map to contain/lift_containment (CS) and disconnect/connect (S1) because those ARE the vendors' network containment primitives, not stand-ins. - The unsupported check runs before any network traffic, so zero EDR calls happen for a refused action. DRY_RUN still short-circuits before dispatch, unchanged. - /execute and /rollback return 501 Not Implemented for Unsupported (502 stays for transport/EDR failures) and append a FAILED_ audit entry next to the intent entry, so the trail records that the approved action did NOT happen. - Tests: mapping units for both providers, no-call-attempted dispatch tests, an S1 loopback mock asserting the exact endpoint per action and zero hits for PROCESS_KILL, and HTTP-level 501 + failure-audit tests. --- src/edr.rs | 373 +++++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 159 +++++++++++++++++++++- 2 files changed, 490 insertions(+), 42 deletions(-) diff --git a/src/edr.rs b/src/edr.rs index 39e72f0..33948b0 100644 --- a/src/edr.rs +++ b/src/edr.rs @@ -45,12 +45,31 @@ //! credentials differ per tenant). SentinelOne uses a static API token passed //! as `ApiToken` in the `Authorization` header. Tokens are never logged. //! +//! ## Action mapping (honest by construction) +//! +//! Each `(ActionType, ActionDirection)` pair maps to exactly one vendor +//! call, or fails loudly with `EdrError::Unsupported`. The proxy NEVER +//! substitutes a different action for the one the operator approved. The +//! one mapping that looks like a merge is genuine vendor semantics: +//! CrowdStrike's `contain` and SentinelOne's `disconnect` are network +//! containment primitives, so HOST_ISOLATION and NETWORK_QUARANTINE both +//! map to them because that IS the vendor's isolation action, not a +//! stand-in for something else. +//! +//! PROCESS_KILL is unsupported on both providers today and fails loudly: +//! a true CrowdStrike kill needs a Real Time Response session plus a +//! process id, and a SentinelOne kill via threat mitigation needs an S1 +//! threat id. The signed request carries neither (only the host / agent +//! identifier), so the proxy refuses rather than quietly quarantining a +//! whole host the operator never approved. +//! //! ## Error mapping //! //! All transport, parsing, and HTTP-status errors collapse into the -//! `EdrError` enum. The caller (in `main`) maps the error to `502 Bad -//! Gateway` (for `/execute`) or pages a human (for `/rollback`) and releases -//! the nonce so a retry can run on fresh state. +//! `EdrError` enum. The caller (in `main`) maps `Unsupported` to `501 Not +//! Implemented` and every other variant to `502 Bad Gateway` (for +//! `/execute`) or pages a human (for `/rollback`), and releases the nonce +//! so a retry can run on fresh state. use std::env; use std::sync::Arc; @@ -60,7 +79,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::Mutex; -use tracing::{info, warn}; +use tracing::info; use crate::actions::{ActionDirection, ActionType}; @@ -69,9 +88,11 @@ const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Errors that can happen during an EDR dispatch. /// -/// Callers should treat every variant the same on the wire (502 Bad -/// Gateway) so failure modes are not externally distinguishable. The -/// variants are for logging, metrics, and tests. +/// Callers treat the transport-shaped variants the same on the wire (502 +/// Bad Gateway) so EDR failure modes are not externally distinguishable. +/// `Unsupported` is the exception: it maps to 501 Not Implemented so the +/// caller can tell "the EDR is down, retry" apart from "this action does +/// not exist for this provider, do not retry". #[derive(Debug, Error)] pub enum EdrError { /// EDR was contacted but rejected the request (4xx). @@ -98,6 +119,18 @@ pub enum EdrError { /// production call fails loudly. #[error("edr misconfigured: {0}")] Misconfigured(String), + + /// The approved action has no faithful implementation on this + /// provider. Fails loudly INSTEAD of substituting a different action: + /// the operator approved a specific containment, and quietly doing + /// something broader (or narrower) would put an action nobody approved + /// on a production host and a lie in the audit trail. + #[error("{action:?} not supported for provider {provider} yet: {detail}. Refusing to substitute a different action")] + Unsupported { + action: ActionType, + provider: &'static str, + detail: &'static str, + }, } /// Which EDR a per-tenant credential targets. @@ -441,7 +474,9 @@ impl CrowdstrikeClient { direction: ActionDirection, host: &str, ) -> Result<(), EdrError> { - let action_name = crowdstrike_action_name(action, direction, host); + // Resolve the vendor action BEFORE any network traffic. An + // unsupported action must fail here, with zero EDR calls made. + let action_name = crowdstrike_action_name(action, direction)?; let token = self.bearer_token().await?; @@ -495,27 +530,34 @@ impl CrowdstrikeClient { } /// Map our internal `(ActionType, ActionDirection)` to the CrowdStrike -/// action name on the query string. +/// action name on the query string, or fail loudly if there is none. /// -/// Apply = "contain", Reverse = "lift_containment". The mapping is -/// documented in CrowdStrike's "Hosts" API reference. PROCESS_KILL falls -/// back to host containment for v0.1-alpha (RTR scripting is post-pilot); -/// we log that explicitly so it is never silent. +/// The mapping is documented in CrowdStrike's "Hosts" API reference. +/// `contain` is CrowdStrike's network containment primitive, so it is the +/// faithful vendor call for BOTH `HOST_ISOLATION` and `NETWORK_QUARANTINE` +/// (it is the same operation in Falcon, not a substitution). +/// +/// `PROCESS_KILL` has no faithful mapping here: a real kill needs a Real +/// Time Response session plus a process id, and the signed request carries +/// neither. Until the wire contract grows a process identifier and an RTR +/// client lands, the action fails loudly. It previously downgraded to host +/// containment with only a log line, which executed an action the operator +/// never approved; that downgrade is exactly what this function now forbids. fn crowdstrike_action_name( action: ActionType, direction: ActionDirection, - host: &str, -) -> &'static str { - if action == ActionType::ProcessKill { - warn!( - host, - ?direction, - "PROCESS_KILL maps to HOST_ISOLATION for v0.1-alpha (RTR scripting is post-pilot)" - ); - } - match direction { - ActionDirection::Apply => "contain", - ActionDirection::Reverse => "lift_containment", +) -> Result<&'static str, EdrError> { + match action { + ActionType::HostIsolation | ActionType::NetworkQuarantine => Ok(match direction { + ActionDirection::Apply => "contain", + ActionDirection::Reverse => "lift_containment", + }), + ActionType::ProcessKill => Err(EdrError::Unsupported { + action, + provider: "crowdstrike", + detail: "a true kill needs a Real Time Response session and a process id, \ + and the signed request carries neither", + }), } } @@ -560,17 +602,13 @@ impl SentinelOneClient { async fn dispatch( &self, - _action: ActionType, + action: ActionType, direction: ActionDirection, host: &str, ) -> Result<(), EdrError> { - // SentinelOne network containment: connect = network on (rollback), - // disconnect = network off (contain). The agents action endpoints - // are `/web/api/v2.1/agents/actions/{disconnect|connect}`. - let action_path = match direction { - ActionDirection::Apply => "disconnect", - ActionDirection::Reverse => "connect", - }; + // Resolve the vendor action BEFORE any network traffic. An + // unsupported action must fail here, with zero EDR calls made. + let action_path = sentinelone_action_path(action, direction)?; let url = format!( "{}/web/api/v2.1/agents/actions/{}", self.base_url.trim_end_matches('/'), @@ -617,6 +655,41 @@ impl SentinelOneClient { } } +/// Map our internal `(ActionType, ActionDirection)` to the SentinelOne +/// agents-action path segment, or fail loudly if there is none. +/// +/// SentinelOne's network containment is `disconnect` (network off) and the +/// rollback is `connect` (network on), at +/// `/web/api/v2.1/agents/actions/{disconnect|connect}`. Disconnect IS the +/// vendor's isolation primitive, so it is the faithful call for both +/// `HOST_ISOLATION` and `NETWORK_QUARANTINE` (same operation, not a +/// substitution). Previously the action type was ignored entirely and +/// every action became a disconnect; this mapping makes that decision +/// explicit and rejects what cannot be honoured. +/// +/// `PROCESS_KILL` has no faithful mapping: S1 kills via threat mitigation +/// (`/threats/mitigate/kill`), which needs an S1 threat id, and the signed +/// request carries only the agent uuid. Until the wire contract carries a +/// threat id, the action fails loudly instead of disconnecting a host the +/// operator never asked to disconnect. +fn sentinelone_action_path( + action: ActionType, + direction: ActionDirection, +) -> Result<&'static str, EdrError> { + match action { + ActionType::HostIsolation | ActionType::NetworkQuarantine => Ok(match direction { + ActionDirection::Apply => "disconnect", + ActionDirection::Reverse => "connect", + }), + ActionType::ProcessKill => Err(EdrError::Unsupported { + action, + provider: "sentinelone", + detail: "killing via threat mitigation needs a SentinelOne threat id, \ + and the signed request carries only the agent uuid", + }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -650,13 +723,139 @@ mod tests { #[test] fn crowdstrike_action_name_maps_direction() { assert_eq!( - crowdstrike_action_name(ActionType::HostIsolation, ActionDirection::Apply, "h"), + crowdstrike_action_name(ActionType::HostIsolation, ActionDirection::Apply) + .expect("supported"), "contain" ); assert_eq!( - crowdstrike_action_name(ActionType::HostIsolation, ActionDirection::Reverse, "h"), + crowdstrike_action_name(ActionType::HostIsolation, ActionDirection::Reverse) + .expect("supported"), "lift_containment" ); + // NETWORK_QUARANTINE is the same Falcon primitive (network + // containment), so it maps to the same vendor action honestly. + assert_eq!( + crowdstrike_action_name(ActionType::NetworkQuarantine, ActionDirection::Apply) + .expect("supported"), + "contain" + ); + assert_eq!( + crowdstrike_action_name(ActionType::NetworkQuarantine, ActionDirection::Reverse) + .expect("supported"), + "lift_containment" + ); + } + + #[test] + fn crowdstrike_process_kill_is_unsupported_never_downgraded() { + // The old behaviour mapped PROCESS_KILL to "contain" with only a + // log line: the operator approved a surgical kill and got a full + // host quarantine. The mapping must now refuse, both directions. + for dir in [ActionDirection::Apply, ActionDirection::Reverse] { + let err = crowdstrike_action_name(ActionType::ProcessKill, dir) + .expect_err("PROCESS_KILL must not map to any CrowdStrike action"); + assert!( + matches!( + err, + EdrError::Unsupported { + action: ActionType::ProcessKill, + provider: "crowdstrike", + .. + } + ), + "expected Unsupported, got {err:?}" + ); + } + } + + #[test] + fn sentinelone_action_path_maps_each_action_type() { + assert_eq!( + sentinelone_action_path(ActionType::HostIsolation, ActionDirection::Apply) + .expect("supported"), + "disconnect" + ); + assert_eq!( + sentinelone_action_path(ActionType::HostIsolation, ActionDirection::Reverse) + .expect("supported"), + "connect" + ); + assert_eq!( + sentinelone_action_path(ActionType::NetworkQuarantine, ActionDirection::Apply) + .expect("supported"), + "disconnect" + ); + assert_eq!( + sentinelone_action_path(ActionType::NetworkQuarantine, ActionDirection::Reverse) + .expect("supported"), + "connect" + ); + } + + #[test] + fn sentinelone_process_kill_is_unsupported_never_downgraded() { + for dir in [ActionDirection::Apply, ActionDirection::Reverse] { + let err = sentinelone_action_path(ActionType::ProcessKill, dir) + .expect_err("PROCESS_KILL must not map to any SentinelOne action"); + assert!( + matches!( + err, + EdrError::Unsupported { + action: ActionType::ProcessKill, + provider: "sentinelone", + .. + } + ), + "expected Unsupported, got {err:?}" + ); + } + } + + #[tokio::test] + async fn crowdstrike_process_kill_fails_before_any_edr_call() { + // The credential points at an unreachable address: if the client + // attempted ANY network call (even the token fetch) we would see a + // Transport error. Getting Unsupported proves the dispatch failed + // loudly before a single byte left the proxy. + let fallback = EdrClient::Noop; + let creds = cs_creds(); + let err = dispatch( + &fallback, + Some(&creds), + ActionType::ProcessKill, + ActionDirection::Apply, + "device-1", + ) + .await + .expect_err("PROCESS_KILL on CrowdStrike must fail"); + assert!( + matches!(err, EdrError::Unsupported { .. }), + "expected Unsupported (no call attempted), got {err:?}" + ); + } + + #[tokio::test] + async fn sentinelone_process_kill_fails_before_any_edr_call() { + let fallback = EdrClient::Noop; + let creds = EdrCredentials { + provider: EdrProvider::Sentinelone, + api_key: String::new(), + api_secret: Some("tok".to_string()), + base_url: Some("http://127.0.0.1:1".to_string()), + }; + let err = dispatch( + &fallback, + Some(&creds), + ActionType::ProcessKill, + ActionDirection::Apply, + "agent-1", + ) + .await + .expect_err("PROCESS_KILL on SentinelOne must fail"); + assert!( + matches!(err, EdrError::Unsupported { .. }), + "expected Unsupported (no call attempted), got {err:?}" + ); } #[test] @@ -751,4 +950,108 @@ mod tests { .expect_err("S1 with no base_url must be Misconfigured"); assert!(matches!(err, EdrError::Misconfigured(_))); } + + // ── SentinelOne endpoint-level test against a loopback mock ──────── + // + // Proves the S1 client hits the CORRECT agents-action endpoint for + // each supported action type and direction, and that an unsupported + // action produces zero calls. + + use std::net::SocketAddr; + use std::sync::Mutex as StdMutex; + + /// Start a SentinelOne-shaped mock recording every action path hit. + async fn spawn_mock_s1() -> (String, Arc>>) { + use axum::extract::{Path, State}; + use axum::response::Json; + use axum::Router; + + let hits: Arc>> = Arc::new(StdMutex::new(Vec::new())); + + async fn agents_action( + State(hits): State>>>, + Path(action): Path, + ) -> Json { + hits.lock().expect("mock s1 state poisoned").push(action); + Json(serde_json::json!({"data": {"affected": 1}})) + } + + let app = Router::new() + .route( + "/web/api/v2.1/agents/actions/:action", + axum::routing::post(agents_action), + ) + .with_state(hits.clone()); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock s1"); + let addr: SocketAddr = listener.local_addr().expect("mock s1 addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock s1 serve"); + }); + (format!("http://{addr}"), hits) + } + + #[tokio::test] + async fn sentinelone_hits_correct_endpoint_per_action_and_direction() { + let (base_url, hits) = spawn_mock_s1().await; + let fallback = EdrClient::Noop; + let creds = EdrCredentials { + provider: EdrProvider::Sentinelone, + api_key: String::new(), + api_secret: Some("tok".to_string()), + base_url: Some(base_url), + }; + + // Isolation: disconnect on apply, connect on rollback. + dispatch( + &fallback, + Some(&creds), + ActionType::HostIsolation, + ActionDirection::Apply, + "agent-1", + ) + .await + .expect("isolation apply"); + dispatch( + &fallback, + Some(&creds), + ActionType::HostIsolation, + ActionDirection::Reverse, + "agent-1", + ) + .await + .expect("isolation rollback"); + + // Network quarantine: the same S1 primitive, honestly. + dispatch( + &fallback, + Some(&creds), + ActionType::NetworkQuarantine, + ActionDirection::Apply, + "agent-1", + ) + .await + .expect("quarantine apply"); + + // Process kill: unsupported, must NOT add a hit. + let err = dispatch( + &fallback, + Some(&creds), + ActionType::ProcessKill, + ActionDirection::Apply, + "agent-1", + ) + .await + .expect_err("process kill must fail loudly"); + assert!(matches!(err, EdrError::Unsupported { .. })); + + let recorded = hits.lock().expect("mock s1 state poisoned").clone(); + assert_eq!( + recorded, + vec!["disconnect", "connect", "disconnect"], + "S1 must receive exactly the approved actions, in order, and nothing for PROCESS_KILL" + ); + } } diff --git a/src/main.rs b/src/main.rs index 1fed0f4..34908f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -504,7 +504,7 @@ async fn run_action( }; let entry = audit::build_entry( payload.tenant_id.clone(), - audit_action, + audit_action.clone(), payload.host.clone(), payload.approved_by.clone(), state.dry_run, @@ -542,9 +542,12 @@ async fn run_action( } else { // Real dispatch. The per-tenant credentials on the request take // precedence over the global env fallback (E7). The EDR client - // owns its own retries, timeouts, and error mapping. Any error is - // a 502 Bad Gateway and we release the nonce so a retry runs on - // fresh state. + // owns its own retries, timeouts, and error mapping. An action the + // provider cannot faithfully perform is 501 Not Implemented (never + // silently substituted with a different action); every other + // failure is 502 Bad Gateway. Either way we append a failure audit + // entry so the trail records that the intent did NOT happen, then + // release the nonce so a retry runs on fresh state. match edr::dispatch( &state.edr, payload.edr_credentials.as_ref(), @@ -559,14 +562,41 @@ async fn run_action( dry_run: false, }, Err(err) => { + let status = match err { + edr::EdrError::Unsupported { .. } => StatusCode::NOT_IMPLEMENTED, + _ => StatusCode::BAD_GATEWAY, + }; warn!( request_id = %payload.request_id, ?direction, error = %err, - "EDR dispatch failed; releasing nonce claim" + status = status.as_u16(), + "EDR dispatch failed; auditing failure and releasing nonce claim" ); + // The step-5 entry recorded the INTENT. Without this + // companion entry the trail would read as if the action + // happened. Best-effort: the failure status is returned + // regardless, and a write error is logged, not swallowed + // into a fake success. + let failure_entry = audit::build_entry( + payload.tenant_id.clone(), + format!("FAILED_{audit_action}"), + payload.host.clone(), + payload.approved_by.clone(), + state.dry_run, + ); + if let Err(audit_err) = + audit::append_audit(&state.audit_log_path, &state.audit_chain, failure_entry) + .await + { + warn!( + request_id = %payload.request_id, + error = %audit_err, + "failed to write failure audit entry" + ); + } release_nonce(&state.nonces, &payload.request_id).await; - return Err(StatusCode::BAD_GATEWAY); + return Err(status); } } }; @@ -1092,12 +1122,32 @@ mod tests { tenant_id: &str, request_id: &str, creds: Option, + ) -> Vec { + action_body_full(tenant_id, request_id, "HOST_ISOLATION", creds) + } + + /// As `action_body` but with an explicit action_type, for the + /// unsupported-action tests. + fn action_body_with_type( + action_type: &str, + request_id: &str, + creds: Option, + ) -> Vec { + action_body_full("tenant-a", request_id, action_type, creds) + } + + /// Fully parameterised request-body builder backing the helpers above. + fn action_body_full( + tenant_id: &str, + request_id: &str, + action_type: &str, + creds: Option, ) -> Vec { let mut obj = json!({ "request_id": request_id, "tenant_id": tenant_id, "alert_id": "alert-1", - "action_type": "HOST_ISOLATION", + "action_type": action_type, "host": "device-1", "approved_by": "analyst-jane", "approved_at": now_secs(), @@ -1205,6 +1255,101 @@ mod tests { ); } + #[tokio::test] + async fn process_kill_unsupported_returns_501_and_audits_failure() { + // The operator approves a PROCESS_KILL; CrowdStrike has no faithful + // mapping for it. The proxy must fail loudly (501, not a silent + // host quarantine) and the audit trail must record both the intent + // and the failure. The credential points at an unreachable address: + // 501 (not 502 Transport) proves no EDR call was even attempted. + let (router, dir) = test_router(false, edr::EdrClient::Noop); + let creds = json!({ + "provider": "crowdstrike", + "api_key": "tenant-a-id", + "api_secret": "tenant-a-secret", + "base_url": "http://127.0.0.1:1", + }); + let body = action_body_with_type("PROCESS_KILL", "req-kill-unsupported", Some(creds)); + let (status, _) = post_json(&router, "/execute", body).await; + assert_eq!( + status, + StatusCode::NOT_IMPLEMENTED, + "unsupported action must be 501, never silently substituted" + ); + + let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); + assert!( + log.contains("\"ProcessKill\""), + "intent entry must be audited" + ); + assert!( + log.contains("\"FAILED_ProcessKill\""), + "failure entry must be audited so the trail does not read as executed" + ); + } + + #[tokio::test] + async fn process_kill_unsupported_on_sentinelone_returns_501() { + let (router, _dir) = test_router(false, edr::EdrClient::Noop); + let creds = json!({ + "provider": "sentinelone", + "api_key": "", + "api_secret": "tenant-b-token", + "base_url": "http://127.0.0.1:1", + }); + let body = action_body_with_type("PROCESS_KILL", "req-kill-s1", Some(creds)); + let sig = sign_body(&body); + let status = post(&router, "/execute", body, Some(sig)).await; + assert_eq!(status, StatusCode::NOT_IMPLEMENTED); + } + + #[tokio::test] + async fn process_kill_dry_run_short_circuits_before_any_edr_logic() { + // DRY_RUN must completely prevent EDR calls (Rule #5), including + // for actions the provider cannot perform: the short-circuit runs + // before dispatch, so even PROCESS_KILL reports dry_run rather than + // touching the EDR client. The unreachable credential proves no + // call could have succeeded silently. + let (router, dir) = test_router(true, edr::EdrClient::Noop); + let creds = json!({ + "provider": "crowdstrike", + "api_key": "tenant-a-id", + "api_secret": "tenant-a-secret", + "base_url": "http://127.0.0.1:1", + }); + let body = action_body_with_type("PROCESS_KILL", "req-kill-dry", Some(creds)); + let (status, value) = post_json(&router, "/execute", body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(value["status"], "dry_run"); + assert_eq!(value["dry_run"], true); + // The intent is still audited, marked dry_run, with no failure + // entry because nothing was attempted. + let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); + assert!(log.contains("\"ProcessKill\"")); + assert!(!log.contains("FAILED_")); + } + + #[tokio::test] + async fn failed_dispatch_audits_failure_entry() { + // A reachable-looking but dead EDR (transport failure) must leave a + // FAILED_ entry next to the intent entry, so an auditor reading the + // trail can tell intent from outcome. + let (router, dir) = test_router(false, edr::EdrClient::Noop); + let creds = json!({ + "provider": "crowdstrike", + "api_key": "tenant-a-id", + "api_secret": "tenant-a-secret", + "base_url": "http://127.0.0.1:1", + }); + let body = action_body("req-iso-fail", Some(creds)); + let sig = sign_body(&body); + let status = post(&router, "/execute", body, Some(sig)).await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); + assert!(log.contains("\"HostIsolation\"")); + assert!(log.contains("\"FAILED_HostIsolation\"")); + } + #[tokio::test] async fn missing_signature_is_unauthorized() { let (router, _dir) = test_router(true, edr::EdrClient::Noop); From 14b8ce368bdcde3cb42ad5cd3b422c5a7ab25e85 Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Thu, 11 Jun 2026 22:02:15 +0530 Subject: [PATCH 2/7] fix(proxy): check action supportability before dry-run short-circuit --- src/edr.rs | 84 +++++++++++++++++++++ src/main.rs | 209 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 217 insertions(+), 76 deletions(-) diff --git a/src/edr.rs b/src/edr.rs index 33948b0..c5ac072 100644 --- a/src/edr.rs +++ b/src/edr.rs @@ -333,6 +333,38 @@ pub async fn dispatch( } } +/// Pure supportability check: would `dispatch` have a faithful mapping for +/// this action on the provider this request would target? +/// +/// Mirrors `dispatch`'s provider selection (per-tenant credential first, +/// global env fallback otherwise) but only consults the pure action-name +/// mappers. It makes ZERO network calls and builds no HTTP client, so the +/// request handler can run it BEFORE the DRY_RUN short-circuit without +/// violating Rule #5. That ordering is the point: a dry-run rehearsal of an +/// action the provider cannot perform must predict the production 501, not +/// report dry-run success. +/// +/// `Noop` supports everything by construction: it performs nothing, so there +/// is no mapping to be unfaithful to. +pub fn check_supported( + fallback: &EdrClient, + creds: Option<&EdrCredentials>, + action: ActionType, + direction: ActionDirection, +) -> Result<(), EdrError> { + let provider = match creds { + Some(c) if c.is_usable() => c.provider, + _ => match fallback { + EdrClient::Noop => return Ok(()), + EdrClient::Crowdstrike(_) => EdrProvider::Crowdstrike, + }, + }; + match provider { + EdrProvider::Crowdstrike => crowdstrike_action_name(action, direction).map(|_| ()), + EdrProvider::Sentinelone => sentinelone_action_path(action, direction).map(|_| ()), + } +} + // ---------------------------------------------------------------------- // CrowdStrike Falcon implementation // ---------------------------------------------------------------------- @@ -720,6 +752,58 @@ mod tests { } } + #[test] + fn check_supported_mirrors_dispatch_provider_selection() { + // Per-tenant CrowdStrike credential: PROCESS_KILL is unsupported, + // containment actions pass. No client is built, no call leaves. + let creds = cs_creds(); + for dir in [ActionDirection::Apply, ActionDirection::Reverse] { + let err = check_supported(&EdrClient::Noop, Some(&creds), ActionType::ProcessKill, dir) + .expect_err("PROCESS_KILL has no faithful CrowdStrike mapping"); + assert!(matches!(err, EdrError::Unsupported { .. })); + check_supported( + &EdrClient::Noop, + Some(&creds), + ActionType::HostIsolation, + dir, + ) + .expect("host isolation is supported"); + } + } + + #[test] + fn check_supported_treats_noop_fallback_as_supporting_everything() { + // No usable credential and a Noop fallback: nothing real would run, + // so nothing is unsupported. Mirrors dispatch, which returns Ok. + for action in [ + ActionType::HostIsolation, + ActionType::ProcessKill, + ActionType::NetworkQuarantine, + ] { + check_supported(&EdrClient::Noop, None, action, ActionDirection::Apply) + .expect("noop fallback supports every action"); + } + } + + #[test] + fn check_supported_unusable_credential_falls_back() { + // A blank credential is "not configured": supportability follows the + // fallback (Noop here), exactly like dispatch's routing. + let creds = EdrCredentials { + provider: EdrProvider::Crowdstrike, + api_key: " ".to_string(), + api_secret: None, + base_url: None, + }; + check_supported( + &EdrClient::Noop, + Some(&creds), + ActionType::ProcessKill, + ActionDirection::Apply, + ) + .expect("unusable credential routes to the Noop fallback"); + } + #[test] fn crowdstrike_action_name_maps_direction() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 34908f6..83e776b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -520,84 +520,100 @@ async fn run_action( // ─ Step 6: Execute (or skip in DRY_RUN). ────────────────────────── // - // DRY_RUN short-circuits the EDR call for BOTH directions exactly the - // same way: no live call leaves the proxy in dev (Rule #5). + // The supportability check runs BEFORE the DRY_RUN short-circuit, on + // purpose: a dry-run rehearsal of an action the provider cannot + // faithfully perform (e.g. PROCESS_KILL on CrowdStrike) must predict + // the production 501, not report dry-run success. The check is pure + // (action-name mappers only, no client, no network), so Rule #5 holds: + // DRY_RUN still short-circuits the EDR call for BOTH directions and no + // live call leaves the proxy in dev. let success_status = match direction { actions::ActionDirection::Apply => "executed", actions::ActionDirection::Reverse => "rolled_back", }; - let response = if state.dry_run { - info!( - request_id = %payload.request_id, - tenant_id = %payload.tenant_id, - action = ?payload.action_type, - ?direction, - host = %payload.host, - "DRY_RUN: skipping EDR call" - ); - ExecuteResponse { - status: "dry_run".to_string(), - dry_run: true, + let supported = edr::check_supported( + &state.edr, + payload.edr_credentials.as_ref(), + payload.action_type, + direction, + ); + let outcome: Result = match supported { + Err(err) => Err(err), + Ok(()) if state.dry_run => { + info!( + request_id = %payload.request_id, + tenant_id = %payload.tenant_id, + action = ?payload.action_type, + ?direction, + host = %payload.host, + "DRY_RUN: skipping EDR call" + ); + Ok(ExecuteResponse { + status: "dry_run".to_string(), + dry_run: true, + }) } - } else { - // Real dispatch. The per-tenant credentials on the request take - // precedence over the global env fallback (E7). The EDR client - // owns its own retries, timeouts, and error mapping. An action the - // provider cannot faithfully perform is 501 Not Implemented (never - // silently substituted with a different action); every other - // failure is 502 Bad Gateway. Either way we append a failure audit - // entry so the trail records that the intent did NOT happen, then - // release the nonce so a retry runs on fresh state. - match edr::dispatch( - &state.edr, - payload.edr_credentials.as_ref(), - payload.action_type, - direction, - &payload.host, - ) - .await - { - Ok(()) => ExecuteResponse { + Ok(()) => { + // Real dispatch. The per-tenant credentials on the request take + // precedence over the global env fallback (E7). The EDR client + // owns its own retries, timeouts, and error mapping. + edr::dispatch( + &state.edr, + payload.edr_credentials.as_ref(), + payload.action_type, + direction, + &payload.host, + ) + .await + .map(|()| ExecuteResponse { status: success_status.to_string(), dry_run: false, - }, - Err(err) => { - let status = match err { - edr::EdrError::Unsupported { .. } => StatusCode::NOT_IMPLEMENTED, - _ => StatusCode::BAD_GATEWAY, - }; + }) + } + }; + let response = match outcome { + Ok(response) => response, + // An action the provider cannot faithfully perform is 501 Not + // Implemented (never silently substituted with a different action), + // in dry-run and live alike; every other failure is 502 Bad + // Gateway. Either way we append a failure audit entry so the trail + // records that the intent did NOT happen, then release the nonce so + // a retry runs on fresh state. + Err(err) => { + let status = match err { + edr::EdrError::Unsupported { .. } => StatusCode::NOT_IMPLEMENTED, + _ => StatusCode::BAD_GATEWAY, + }; + warn!( + request_id = %payload.request_id, + ?direction, + error = %err, + status = status.as_u16(), + "EDR action failed; auditing failure and releasing nonce claim" + ); + // The step-5 entry recorded the INTENT. Without this + // companion entry the trail would read as if the action + // happened. Best-effort: the failure status is returned + // regardless, and a write error is logged, not swallowed + // into a fake success. + let failure_entry = audit::build_entry( + payload.tenant_id.clone(), + format!("FAILED_{audit_action}"), + payload.host.clone(), + payload.approved_by.clone(), + state.dry_run, + ); + if let Err(audit_err) = + audit::append_audit(&state.audit_log_path, &state.audit_chain, failure_entry).await + { warn!( request_id = %payload.request_id, - ?direction, - error = %err, - status = status.as_u16(), - "EDR dispatch failed; auditing failure and releasing nonce claim" - ); - // The step-5 entry recorded the INTENT. Without this - // companion entry the trail would read as if the action - // happened. Best-effort: the failure status is returned - // regardless, and a write error is logged, not swallowed - // into a fake success. - let failure_entry = audit::build_entry( - payload.tenant_id.clone(), - format!("FAILED_{audit_action}"), - payload.host.clone(), - payload.approved_by.clone(), - state.dry_run, + error = %audit_err, + "failed to write failure audit entry" ); - if let Err(audit_err) = - audit::append_audit(&state.audit_log_path, &state.audit_chain, failure_entry) - .await - { - warn!( - request_id = %payload.request_id, - error = %audit_err, - "failed to write failure audit entry" - ); - } - release_nonce(&state.nonces, &payload.request_id).await; - return Err(status); } + release_nonce(&state.nonces, &payload.request_id).await; + return Err(status); } }; @@ -1304,12 +1320,13 @@ mod tests { } #[tokio::test] - async fn process_kill_dry_run_short_circuits_before_any_edr_logic() { - // DRY_RUN must completely prevent EDR calls (Rule #5), including - // for actions the provider cannot perform: the short-circuit runs - // before dispatch, so even PROCESS_KILL reports dry_run rather than - // touching the EDR client. The unreachable credential proves no - // call could have succeeded silently. + async fn process_kill_dry_run_predicts_production_501() { + // The supportability check runs BEFORE the dry-run short-circuit, + // so a rehearsal of an action the provider cannot perform returns + // the same 501 production would. The check is pure (action-name + // mappers only) and the credential points at an unreachable + // address: 501 (not 502 Transport) proves no EDR call was even + // attempted, so Rule #5 still holds. let (router, dir) = test_router(true, edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", @@ -1318,14 +1335,54 @@ mod tests { "base_url": "http://127.0.0.1:1", }); let body = action_body_with_type("PROCESS_KILL", "req-kill-dry", Some(creds)); + let (status, _) = post_json(&router, "/execute", body).await; + assert_eq!( + status, + StatusCode::NOT_IMPLEMENTED, + "dry-run must predict the production 501 for unsupported actions" + ); + // The trail mirrors production too: intent entry plus a FAILED_ + // companion so the rehearsal does not read as a success. + let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); + assert!(log.contains("\"ProcessKill\"")); + assert!(log.contains("\"FAILED_ProcessKill\"")); + } + + #[tokio::test] + async fn process_kill_dry_run_on_sentinelone_returns_501() { + let (router, _dir) = test_router(true, edr::EdrClient::Noop); + let creds = json!({ + "provider": "sentinelone", + "api_key": "", + "api_secret": "tenant-b-token", + "base_url": "http://127.0.0.1:1", + }); + let body = action_body_with_type("PROCESS_KILL", "req-kill-dry-s1", Some(creds)); + let sig = sign_body(&body); + let status = post(&router, "/execute", body, Some(sig)).await; + assert_eq!(status, StatusCode::NOT_IMPLEMENTED); + } + + #[tokio::test] + async fn host_isolation_dry_run_with_creds_still_short_circuits() { + // Supported actions are untouched by the reorder: dry-run still + // returns dry-run success with ZERO network calls. The credential + // points at an unreachable address, so anything other than + // dry_run success would mean a call left the proxy (Rule #5). + let (router, dir) = test_router(true, edr::EdrClient::Noop); + let creds = json!({ + "provider": "crowdstrike", + "api_key": "tenant-a-id", + "api_secret": "tenant-a-secret", + "base_url": "http://127.0.0.1:1", + }); + let body = action_body("req-iso-dry-creds", Some(creds)); let (status, value) = post_json(&router, "/execute", body).await; assert_eq!(status, StatusCode::OK); assert_eq!(value["status"], "dry_run"); assert_eq!(value["dry_run"], true); - // The intent is still audited, marked dry_run, with no failure - // entry because nothing was attempted. let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); - assert!(log.contains("\"ProcessKill\"")); + assert!(log.contains("\"HostIsolation\"")); assert!(!log.contains("FAILED_")); } From 8e43ff508c18f7f08c039121d7ae88c9dd9970da Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Fri, 12 Jun 2026 16:25:20 +0530 Subject: [PATCH 3/7] feat(proxy): remove DRY_RUN, add simulated flag, canonical hash, fail-closed nonce Proxy always dispatches now; the simulated honesty flag is read, audited, and echoed (audit field renamed dry_run->simulated). PRX-01 missing-Redis is a hard prod boot error (VYROX_PROXY_ALLOW_EPHEMERAL_NONCE opt-in for dev). PRX-02 canonical BTreeMap serializer cross-verified with Python (shared sha256 fixture). PRX-03 poison-safe lock, PRX-04 streaming audit export, PRX-05 EDR-body scrubber. Verify side mirrors VYROX_PROXY_SECRET (fallback VYROX_HMAC_SECRET). --- src/audit.rs | 289 ++++++++++++++++++++++--- src/edr.rs | 16 +- src/main.rs | 583 ++++++++++++++++++++++++++++++++++++++------------- src/nonce.rs | 18 +- 4 files changed, 720 insertions(+), 186 deletions(-) diff --git a/src/audit.rs b/src/audit.rs index 2a0d0ef..9dfecb1 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -29,7 +29,7 @@ //! "action_type": "HOST_ISOLATION", //! "host": "workstation-01", //! "approved_by": "analyst@company.com", -//! "dry_run": false, +//! "simulated": false, //! "previous_hash": "0000...0000", //! "hash": "e3b0c4..." //! } @@ -58,8 +58,8 @@ 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::fs::{File, OpenOptions}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Mutex; /// Sentinel value used as `previous_hash` for the first entry in a @@ -91,8 +91,13 @@ pub struct AuditEntry { /// Discord username who approved this action. pub approved_by: String, - /// Whether this was a dry-run. - pub dry_run: bool, + /// Honesty label recording that the action targeted a demo/mock fleet + /// rather than a real customer EDR. Set from the tenant's `is_demo` flag + /// on the Python side and carried inside the signed request body. It is a + /// label ONLY: the proxy still performs the real EDR call (which, for a + /// demo tenant, lands on the bundled mock EDR), so this records WHAT was + /// targeted, not WHETHER an action ran. + pub simulated: bool, /// SHA-256 hash of the previous entry (or `GENESIS_HASH` for /// the very first entry). Together with `hash` this forms the @@ -223,7 +228,7 @@ pub fn build_entry( action_type: String, host: String, approved_by: String, - dry_run: bool, + simulated: bool, ) -> AuditEntry { AuditEntry { timestamp: Utc::now().timestamp(), @@ -231,18 +236,22 @@ pub fn build_entry( action_type, host, approved_by, - dry_run, + simulated, previous_hash: GENESIS_HASH.to_string(), hash: String::new(), } } -/// Read and parse audit log entries from file. +/// Read and parse ALL audit log entries from file into memory. /// -/// 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). +/// 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. +/// +/// This loads the entire file into RAM. The `GET /audit/export` path uses the +/// streaming, tenant-filtered `read_tenant_entries_streaming` instead (PRX-04) +/// so file-size x concurrent exports cannot become an unbounded-memory +/// amplifier. This whole-file reader stays for the tests and the chain-seed +/// helper, where the input is small and bounded. pub async fn read_audit_logs(path: &str) -> Result, std::io::Error> { let content = match tokio::fs::read_to_string(path).await { Ok(c) => c, @@ -262,6 +271,44 @@ pub async fn read_audit_logs(path: &str) -> Result, std::io::Err Ok(entries) } +/// Stream the audit log line-by-line, returning only the entries for one +/// tenant (PRX-04). +/// +/// Unlike `read_audit_logs`, this never holds the whole file in memory: it +/// reads one line at a time through a `BufReader` and keeps only the entries +/// whose `tenant_id` matches. Peak memory is one line plus the filtered result, +/// not the full file, so a large log and many concurrent `/audit/export` calls +/// no longer multiply into an unbounded-memory amplifier. (The matched result +/// is still materialised because the endpoint returns a JSON array; a tenant's +/// own slice is bounded by that tenant's history, not by the global file.) +/// +/// Malformed lines are skipped, mirroring `read_audit_logs`. A missing file +/// yields an empty vec (authenticated request, tenant has no history yet). +pub async fn read_tenant_entries_streaming( + path: &str, + tenant_id: &str, +) -> Result, std::io::Error> { + let file = match File::open(path).await { + Ok(f) => f, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err), + }; + + let mut lines = BufReader::new(file).lines(); + let mut entries = Vec::new(); + while let Some(line) = lines.next_line().await? { + if line.is_empty() { + continue; + } + if let Ok(entry) = serde_json::from_str::(&line) { + if entry.tenant_id == tenant_id { + 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 @@ -284,21 +331,27 @@ pub async fn read_last_hash(path: &str) -> Result { /// 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. +/// +/// ## Canonical form (must match `shared/audit.py` EXACTLY) +/// +/// The tamper-evident chain only cross-verifies if both languages hash the +/// same bytes. The spec, pinned by the cross-language fixture test below: +/// +/// - keys sorted ascending by Unicode code point, +/// - compact separators (`,` and `:`, no spaces), +/// - UTF-8, non-ASCII NOT escaped (`é` is the two raw bytes `0xC3 0xA9`, +/// never `é`). +/// +/// We build the payload from a `BTreeMap`, which guarantees the sorted-key +/// ordering by construction rather than by hand-written field order. The +/// previous code used `serde_json::json!{}` (an insertion-ordered Map), so the +/// canonical form rested on the author keeping the literal alphabetical, one +/// reorder away from silently diverging the two chains (PRX-02). `serde_json`'s +/// default compact serializer already emits raw UTF-8 (it does not escape +/// non-ASCII), so the spec holds without any extra flags. The cross-language +/// fixture `canonical_hash_matches_python_fixture` is the regression guard. 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 canonical = canonical_payload_bytes(entry); let mut hasher = Sha256::new(); hasher.update(entry.previous_hash.as_bytes()); @@ -307,6 +360,31 @@ fn compute_entry_hash(entry: &AuditEntry) -> String { hex::encode(hasher.finalize()) } +/// Serialize an entry's payload fields into the canonical byte form the hash +/// is computed over: every field except `hash`, keys sorted ascending by code +/// point, compact separators, raw UTF-8. +/// +/// Factored out so the canonical-form spec lives in one place and the +/// cross-language fixture test can exercise the exact serializer the hash uses. +/// A `BTreeMap` enforces the sorted-key ordering by type, not by discipline. +fn canonical_payload_bytes(entry: &AuditEntry) -> Vec { + use serde_json::Value; + use std::collections::BTreeMap; + + let mut payload: BTreeMap = BTreeMap::new(); + payload.insert("action_type".into(), Value::from(entry.action_type.clone())); + payload.insert("approved_by".into(), Value::from(entry.approved_by.clone())); + payload.insert("host".into(), Value::from(entry.host.clone())); + payload.insert( + "previous_hash".into(), + Value::from(entry.previous_hash.clone()), + ); + payload.insert("simulated".into(), Value::from(entry.simulated)); + payload.insert("tenant_id".into(), Value::from(entry.tenant_id.clone())); + payload.insert("timestamp".into(), Value::from(entry.timestamp)); + serde_json::to_vec(&payload).expect("payload always serialises") +} + #[cfg(test)] mod tests { use super::*; @@ -397,6 +475,165 @@ mod tests { assert_eq!(entries[1].previous_hash, saved_hash); } + /// Cross-language canonical-serializer fixture (PRX-02, theme 2). + /// + /// The Rust and Python audit chains are only cross-verifiable, the core + /// tamper-evidence claim, if both hash the same canonical bytes. This pins + /// the exact serializer to the spec: keys sorted ascending by code point, + /// compact separators (`,`/`:`, no spaces), raw UTF-8 (non-ASCII NOT + /// escaped). The constant is asserted by the Python side too, so if either + /// serializer drifts, this test (and its Python twin) goes red. + /// + /// Input `{"a":"x","m":1,"z":"é"}` must serialise to the exact bytes + /// `{"a":"x","m":1,"z":"é"}` (with `é` as the two UTF-8 bytes 0xC3 0xA9, + /// not the escape `é`), whose sha256 is the constant below. + #[test] + fn canonical_hash_matches_python_fixture() { + use serde_json::Value; + use std::collections::BTreeMap; + + let mut payload: BTreeMap = BTreeMap::new(); + // Insert OUT of order on purpose: the BTreeMap must sort them. + payload.insert("z".into(), Value::from("é")); + payload.insert("a".into(), Value::from("x")); + payload.insert("m".into(), Value::from(1)); + + let bytes = serde_json::to_vec(&payload).expect("serialise fixture"); + + // Byte-exact canonical form: sorted keys, compact, raw UTF-8 (é is the + // two bytes 0xC3 0xA9, never an escaped é). + assert_eq!( + bytes, b"{\"a\":\"x\",\"m\":1,\"z\":\"\xc3\xa9\"}", + "canonical bytes must match the Python ensure_ascii=False sort_keys form" + ); + + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let digest = hex::encode(hasher.finalize()); + assert_eq!( + digest, "766b45d54caad1b76f358687b74b45108a81ea40f94cfa6d048eb1efd77583e9", + "canonical sha256 must equal the constant the Python audit chain asserts" + ); + } + + /// The entry payload serializer (the one the chain hash covers) must emit + /// sorted keys, the `simulated` field, and raw non-ASCII bytes. Guards the + /// dry_run->simulated rename and the BTreeMap canonicalisation together. + #[test] + fn canonical_payload_is_sorted_compact_and_uses_simulated() { + let mut entry = build_entry( + "tén".into(), + "HostIsolation".into(), + "host-é".into(), + "alice".into(), + true, + ); + entry.timestamp = 1_700_000_000; + entry.previous_hash = GENESIS_HASH.to_string(); + + let bytes = canonical_payload_bytes(&entry); + let text = String::from_utf8(bytes.clone()).expect("utf-8"); + + // Compact (no spaces after separators) and key-sorted: action_type, + // approved_by, host, previous_hash, simulated, tenant_id, timestamp. + assert!( + text.starts_with("{\"action_type\":"), + "first key sorted: {text}" + ); + assert!( + text.contains("\"simulated\":true"), + "uses simulated, not dry_run" + ); + assert!(!text.contains("dry_run"), "old field name must be gone"); + assert!(!text.contains(": "), "compact separators, no spaces"); + // Raw UTF-8, not escaped: the é in host-é stays two bytes, not é. + assert!(text.contains("host-é"), "non-ASCII must not be escaped"); + assert!(!text.contains("\\u00e9"), "non-ASCII must not be escaped"); + } + + #[tokio::test] + async fn streaming_export_filters_by_tenant_and_skips_other_tenants() { + // PRX-04: the streaming reader must return only the requested tenant's + // entries (tenant isolation enforced in the read, not after) and must + // not choke on a malformed line. Also covers the missing-file path. + let tmp = NamedTempFile::new().expect("tmp"); + let path = tmp.path().to_str().unwrap().to_string(); + let state = ChainState::genesis(); + + append_audit( + &path, + &state, + build_entry( + "t1".into(), + "HostIsolation".into(), + "h-1".into(), + "a".into(), + false, + ), + ) + .await + .expect("t1 entry"); + append_audit( + &path, + &state, + build_entry( + "t2".into(), + "HostIsolation".into(), + "h-2".into(), + "b".into(), + true, + ), + ) + .await + .expect("t2 entry"); + // A malformed line in the middle must be skipped, not abort the read. + { + use tokio::io::AsyncWriteExt as _; + let mut f = OpenOptions::new().append(true).open(&path).await.unwrap(); + f.write_all(b"{ not valid json\n").await.unwrap(); + f.flush().await.unwrap(); + } + append_audit( + &path, + &state, + build_entry( + "t1".into(), + "HostIsolation".into(), + "h-3".into(), + "c".into(), + false, + ), + ) + .await + .expect("second t1 entry"); + + let t1 = read_tenant_entries_streaming(&path, "t1") + .await + .expect("t1 read"); + assert_eq!(t1.len(), 2, "only t1 entries, malformed line skipped"); + assert!(t1.iter().all(|e| e.tenant_id == "t1")); + + let t2 = read_tenant_entries_streaming(&path, "t2") + .await + .expect("t2 read"); + assert_eq!(t2.len(), 1); + assert!( + t2[0].simulated, + "t2 entry carried simulated=true through the read" + ); + + // No history for an unknown tenant, and a missing file is empty, not an + // error. + let none = read_tenant_entries_streaming(&path, "nope") + .await + .expect("empty"); + assert!(none.is_empty()); + let missing = read_tenant_entries_streaming("/nonexistent/audit.jsonl", "t1") + .await + .expect("missing file is empty, not an error"); + assert!(missing.is_empty()); + } + #[tokio::test] async fn tampering_breaks_chain() { let tmp = NamedTempFile::new().expect("tmp"); diff --git a/src/edr.rs b/src/edr.rs index c5ac072..aafd10d 100644 --- a/src/edr.rs +++ b/src/edr.rs @@ -217,9 +217,11 @@ impl EdrClient { /// Build an `EdrClient` from environment variables. /// /// See module-level docs for the env-var contract. Defaults to - /// `Noop` if no provider is configured, which is safe-by-default - /// (mirrors `DRY_RUN=true` as a default). This is the dev/sandbox - /// fallback only; production routes per-tenant credentials instead. + /// `Noop` if no provider is configured, which is safe-by-default: the + /// proxy always dispatches, and with no global credential the Noop + /// fallback performs nothing rather than calling a real EDR. This is the + /// dev/sandbox fallback only; production routes per-tenant credentials + /// instead. pub fn from_env() -> Self { let provider = env::var("EDR_PROVIDER").unwrap_or_else(|_| "noop".to_string()); match provider.trim().to_ascii_lowercase().as_str() { @@ -339,10 +341,10 @@ pub async fn dispatch( /// Mirrors `dispatch`'s provider selection (per-tenant credential first, /// global env fallback otherwise) but only consults the pure action-name /// mappers. It makes ZERO network calls and builds no HTTP client, so the -/// request handler can run it BEFORE the DRY_RUN short-circuit without -/// violating Rule #5. That ordering is the point: a dry-run rehearsal of an -/// action the provider cannot perform must predict the production 501, not -/// report dry-run success. +/// request handler can run it BEFORE dispatching to fail an unsupported action +/// loudly with a 501 instead of substituting a different action: the operator +/// approved a specific containment, and the proxy refuses to do something +/// broader. /// /// `Noop` supports everything by construction: it performs nothing, so there /// is no mapping to be unfaithful to. diff --git a/src/main.rs b/src/main.rs index 83e776b..3d04529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,11 @@ //! 6. **Audit before action** - write an audit entry recording the //! intent BEFORE invoking the EDR (`audit::append_audit`). This way //! a crash mid-action still leaves a forensic trail. -//! 7. **Execute via the EDR client** (or return early if DRY_RUN). +//! 7. **Execute via the configured EDR client.** The proxy ALWAYS dispatches +//! to the EDR (the global DRY_RUN kill-switch is gone). For a demo/mock +//! tenant the request carries `simulated=true` and the per-tenant +//! credential points the same real call at the bundled mock EDR, so the +//! execute/rollback path runs end to end against a simulated fleet. //! 8. **Cache the response** in the nonce store so retries are idempotent. //! //! ## Endpoints @@ -39,7 +43,7 @@ //! `/execute` and `/rollback` share one lifecycle (`run_action`): the only //! difference is the `ActionDirection` (apply vs reverse) and the audit //! `action_type` tag. Both verify HMAC, enforce the replay window, dedupe by -//! nonce, audit BEFORE acting, and honour DRY_RUN identically. +//! nonce, and audit BEFORE acting identically. use std::env; use std::sync::{Arc, Mutex}; @@ -108,11 +112,6 @@ struct AppState { /// Path to the append-only JSONL audit log. audit_log_path: String, - /// If true, action execution is skipped and the EDR is never called. - /// Default for development and CI. Production must set DRY_RUN=false - /// **explicitly** (see `main` - we err on the side of safe-by-default). - dry_run: bool, - /// In-process dedup store keyed by `request_id`. See `nonce.rs`. nonces: nonce::NonceStore, @@ -190,10 +189,18 @@ impl RateLimiter { /// Runs in the middleware before HMAC verification. Returns true if the /// request is allowed. fn check_global(&self, now: Instant) -> bool { + // PRX-03: recover from a poisoned lock instead of panicking. This mutex + // runs in the pre-HMAC middleware on EVERY request; a single panic + // while holding it would poison it and turn every subsequent /execute + // into a panic, a total DoS of containment. The guarded data is a + // plain (Instant, u32) fixed-window counter with no invariant a panic + // could have left half-updated, so taking the inner value on poison is + // safe: at worst one window's count is slightly off, which self-heals + // on the next one-second rollover. let mut window = self .global .lock() - .expect("global rate-limit mutex poisoned"); + .unwrap_or_else(|poisoned| poisoned.into_inner()); rate_check(&mut window, now, self.global_limit) } @@ -308,6 +315,22 @@ struct ExecuteRequest { /// it in transit. Never logged. #[serde(default)] edr_credentials: Option, + + /// Honesty label: this action targets a demo/mock fleet, not a real + /// customer EDR. The Python side sets it from the tenant's `is_demo` flag + /// and signs it inside the body (tamper-evident, not a spoofable header). + /// + /// It does NOT change proxy behavior: the proxy still performs the real EDR + /// call. For a demo tenant the per-tenant credential points that real call + /// at the bundled mock EDR (vyrox/mock_edr), so the whole execute/rollback + /// path runs end to end against a simulated fleet. `simulated=true` records + /// in the audit + response that the action targeted a demo/mock fleet. + /// + /// `#[serde(default)]` keeps this backward-compatible: an older Python + /// caller that omits the field signs a body the proxy still accepts, and + /// the flag defaults to false (treated as a real fleet). + #[serde(default)] + simulated: bool, } /// Response payload for `POST /execute` and `POST /rollback`. @@ -316,12 +339,14 @@ struct ExecuteResponse { /// Human-readable status. One of: /// - "executed" - EDR applied the action successfully. /// - "rolled_back" - EDR reversed the action successfully. - /// - "dry_run" - DRY_RUN was set; EDR was not called. /// - "replayed" - request was previously processed; cached result returned. status: String, - /// Whether DRY_RUN was active when this response was generated. - dry_run: bool, + /// Honesty label echoed back from the request: whether this action + /// targeted a demo/mock fleet. It does NOT mean the EDR call was skipped, + /// the call always runs; for a demo tenant it lands on the bundled mock + /// EDR. Mirrors the `simulated` field recorded in the audit entry. + simulated: bool, } /// Query parameters for the audit export endpoint. @@ -373,10 +398,10 @@ async fn execute( /// network). /// /// Identical security path to `/execute`: same HMAC verification, same -/// replay window, same nonce dedup, same audit-before-act ordering, same -/// DRY_RUN short-circuit. The only difference is `ActionDirection::Reverse`, -/// which makes the EDR client call the inverse vendor action, and the audit -/// `action_type` is prefixed `ROLLBACK_` so the trail names what was undone. +/// replay window, same nonce dedup, same audit-before-act ordering. The only +/// difference is `ActionDirection::Reverse`, which makes the EDR client call +/// the inverse vendor action, and the audit `action_type` is prefixed +/// `ROLLBACK_` so the trail names what was undone. async fn rollback( state: State, headers: HeaderMap, @@ -483,7 +508,7 @@ async fn run_action( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; return Ok(Json(ExecuteResponse { status: "replayed".to_string(), - dry_run: cached.dry_run, + simulated: cached.simulated, })); } nonce::Outcome::InFlight => { @@ -507,7 +532,7 @@ async fn run_action( audit_action.clone(), payload.host.clone(), payload.approved_by.clone(), - state.dry_run, + payload.simulated, ); 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 @@ -518,15 +543,20 @@ async fn run_action( return Err(StatusCode::INTERNAL_SERVER_ERROR); } - // ─ Step 6: Execute (or skip in DRY_RUN). ────────────────────────── + // ─ Step 6: Execute. ─────────────────────────────────────────────── + // + // The proxy ALWAYS dispatches to the configured EDR. The global DRY_RUN + // kill-switch is gone: there is no longer a path that audits an intent and + // then quietly declines to act on it. For a demo/mock tenant the request + // carries `simulated=true` and the per-tenant credential points this same + // real call at the bundled mock EDR, so the execute/rollback path runs end + // to end against a simulated fleet. `simulated` is an honesty label on the + // audit + response only; it never gates the call. // - // The supportability check runs BEFORE the DRY_RUN short-circuit, on - // purpose: a dry-run rehearsal of an action the provider cannot - // faithfully perform (e.g. PROCESS_KILL on CrowdStrike) must predict - // the production 501, not report dry-run success. The check is pure - // (action-name mappers only, no client, no network), so Rule #5 holds: - // DRY_RUN still short-circuits the EDR call for BOTH directions and no - // live call leaves the proxy in dev. + // The supportability check runs first, on purpose: an action the provider + // cannot faithfully perform (e.g. PROCESS_KILL on CrowdStrike) must fail + // loudly with a 501 before any network call, never be silently substituted. + // The check is pure (action-name mappers only, no client, no network). let success_status = match direction { actions::ActionDirection::Apply => "executed", actions::ActionDirection::Reverse => "rolled_back", @@ -539,24 +569,11 @@ async fn run_action( ); let outcome: Result = match supported { Err(err) => Err(err), - Ok(()) if state.dry_run => { - info!( - request_id = %payload.request_id, - tenant_id = %payload.tenant_id, - action = ?payload.action_type, - ?direction, - host = %payload.host, - "DRY_RUN: skipping EDR call" - ); - Ok(ExecuteResponse { - status: "dry_run".to_string(), - dry_run: true, - }) - } Ok(()) => { // Real dispatch. The per-tenant credentials on the request take // precedence over the global env fallback (E7). The EDR client - // owns its own retries, timeouts, and error mapping. + // owns its own retries, timeouts, and error mapping. We echo the + // request's `simulated` honesty label back unchanged. edr::dispatch( &state.edr, payload.edr_credentials.as_ref(), @@ -567,7 +584,7 @@ async fn run_action( .await .map(|()| ExecuteResponse { status: success_status.to_string(), - dry_run: false, + simulated: payload.simulated, }) } }; @@ -601,7 +618,7 @@ async fn run_action( format!("FAILED_{audit_action}"), payload.host.clone(), payload.approved_by.clone(), - state.dry_run, + payload.simulated, ); if let Err(audit_err) = audit::append_audit(&state.audit_log_path, &state.audit_chain, failure_entry).await @@ -671,9 +688,12 @@ async fn run_action( /// /// ## 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. +/// The log is read by streaming it line-by-line and keeping only this tenant's +/// entries (`audit::read_tenant_entries_streaming`, PRX-04), so the whole file +/// is never resident in RAM and concurrent exports do not multiply into an +/// unbounded-memory amplifier. The matched slice is still materialised because +/// the response is a JSON array; bounding it further (pagination, per-tenant log +/// shards) is the next step as pack volume grows. async fn export_audit( State(state): State, Query(query): Query, @@ -719,15 +739,15 @@ async fn export_audit( } // ─ Step 4: Actual export. ───────────────────────────────────── - let entries = audit::read_audit_logs(&state.audit_log_path) + // + // Stream the log line-by-line and keep only this tenant's entries (PRX-04), + // so the whole file is never read into RAM. The tenant filter is applied + // inside the streaming read, not after, so a misbehaving caller cannot read + // another tenant's entries by post-processing. + let filtered = audit::read_tenant_entries_streaming(&state.audit_log_path, &query.tenant_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let filtered: Vec = entries - .into_iter() - .filter(|e| e.tenant_id == query.tenant_id) - .collect(); - Ok(Json(filtered)) } @@ -763,8 +783,9 @@ fn parse_u32_env(name: &str, default: u32) -> u32 { /// /// We accept the common spellings ("true"/"false"/"1"/"0"/"yes"/"no") /// because operators write env files by hand and a strict parser leads -/// to silently-wrong DRY_RUN settings (which is exactly the failure mode -/// we cannot tolerate). +/// to silently-wrong safety toggles (e.g. `ALLOW_INSECURE`, +/// `VYROX_PROXY_ALLOW_EPHEMERAL_NONCE`), which is exactly the failure mode we +/// cannot tolerate. fn parse_bool_env(name: &str, default: bool) -> bool { match env::var(name) { Ok(value) => match value.trim().to_ascii_lowercase().as_str() { @@ -784,10 +805,70 @@ fn parse_bool_env(name: &str, default: bool) -> bool { } } +/// Resolve the HMAC secret used to verify proxy requests (SRF-07). +/// +/// Mirrors the Python signer's `effective_proxy_secret()`: prefer the +/// dedicated `VYROX_PROXY_SECRET`, fall back to the shared `VYROX_HMAC_SECRET`. +/// The two sides must resolve to the same value or every signed call 401s. +/// +/// In production we fail closed if neither is set: serving with no secret would +/// silently disable authentication on the one component that talks to a real +/// EDR. In dev/CI a missing secret is still fatal (there is nothing to verify +/// against), but the message is the same; we never invent a default. +/// +/// HKDF / per-tenant key derivation is deliberately NOT done this wave, to keep +/// the Rust and Python sides in lockstep. +fn resolve_proxy_secret(is_production: bool) -> String { + let dedicated = env::var("VYROX_PROXY_SECRET").unwrap_or_default(); + if !dedicated.trim().is_empty() { + return dedicated; + } + let shared = env::var("VYROX_HMAC_SECRET").unwrap_or_default(); + if !shared.trim().is_empty() { + warn!( + "VYROX_PROXY_SECRET is not set; verifying containment proxy requests with the shared \ + VYROX_HMAC_SECRET (dev/test fallback). Set a dedicated VYROX_PROXY_SECRET so a leak \ + of the inter-service secret cannot authorize an EDR action." + ); + return shared; + } + if is_production { + panic!( + "neither VYROX_PROXY_SECRET nor VYROX_HMAC_SECRET is set in production: refusing to \ + start the containment proxy with no signing secret, which would disable \ + authentication on every /execute and /rollback. Set VYROX_PROXY_SECRET (SRF-07)." + ); + } + panic!( + "neither VYROX_PROXY_SECRET nor VYROX_HMAC_SECRET is set: the proxy has no secret to \ + verify request signatures against. Set VYROX_PROXY_SECRET (preferred) or \ + VYROX_HMAC_SECRET." + ); +} + +/// Decide whether this is a production boot. +/// +/// The proxy has no first-class environment field, so it reads the same +/// `VYROX_ENV` / `ENVIRONMENT` the rest of the platform sets (`VYROX_ENV` wins). +/// Only an explicit "production" (case-insensitive) counts; anything else, or +/// nothing, is treated as dev/CI. We fail closed only when we are SURE the boot +/// is production, so a forgotten env var never silently relaxes a prod gate in +/// the other direction: it just means the explicit dev opt-ins remain required. +fn is_production_env() -> bool { + for var in ["VYROX_ENV", "ENVIRONMENT"] { + if let Ok(value) = env::var(var) { + if value.trim().eq_ignore_ascii_case("production") { + return true; + } + } + } + false +} + /// Assemble the Axum router with the rate-limit layer over a given state. /// /// Extracted from `main` so the full HTTP path (HMAC, replay, nonce, audit, -/// DRY_RUN, dispatch) is exercisable in-process by the test suite via +/// dispatch) is exercisable in-process by the test suite via /// `tower::ServiceExt::oneshot`, with no real socket and no live EDR. fn build_router(state: AppState) -> Router { Router::new() @@ -799,6 +880,13 @@ fn build_router(state: AppState) -> Router { .with_state(state) } +/// Marker the EDR error Display strings put right before the raw EDR response +/// body, e.g. `edr returned client error 403: {body}`. The scrubber redacts +/// everything after this marker so a captured EDR body never reaches Sentry. +const EDR_BODY_MARKER: &str = " error "; +/// What the scrubbed EDR body is replaced with in any outbound Sentry event. +const EDR_BODY_REDACTED: &str = "[edr-body-redacted]"; + /// Initialize Sentry error tracking (T31). /// /// Reads `SENTRY_DSN` from the environment. When it is unset or empty the @@ -806,6 +894,14 @@ fn build_router(state: AppState) -> Router { /// transport and sends nothing, so dev/CI and unconfigured deploys are /// unaffected. The caller MUST hold the returned guard for the life of the /// process; dropping it flushes and shuts the client down. +/// +/// PRX-05: a `before_send` hook scrubs the raw EDR response body out of every +/// outbound event. Today the EDR body lives only in an `EdrError` that is +/// mapped to an HTTP status and never returned to the client or sent to Sentry +/// (the `warn!` path is not wired to Sentry). This is defense in depth: if the +/// body ever reaches an event, via a panic carrying the error string or a +/// future tracing-to-Sentry bridge, it is redacted before egress. The Python +/// `scrub.py` does not cover this Rust path, so the proxy owns it. fn init_sentry() -> sentry::ClientInitGuard { let dsn = env::var("SENTRY_DSN").unwrap_or_default(); if dsn.trim().is_empty() { @@ -819,11 +915,48 @@ fn init_sentry() -> sentry::ClientInitGuard { sentry::ClientOptions { release: sentry::release_name!(), environment, + before_send: Some(Arc::new(|mut event| { + scrub_edr_body_from_event(&mut event); + Some(event) + })), ..Default::default() }, )) } +/// Redact any captured EDR response body from a Sentry event before it leaves +/// the process (PRX-05). +/// +/// The EDR body only ever appears in an `EdrError` Display string of the form +/// `edr returned {client|server} error {status}: {body}`. We redact the message +/// and every exception value at the first `EDR_BODY_MARKER` occurrence, keeping +/// the diagnostic prefix (which error, which status) but dropping the raw body. +fn scrub_edr_body_from_event(event: &mut sentry::protocol::Event) { + if let Some(message) = event.message.take() { + event.message = Some(redact_after_edr_marker(&message)); + } + for exception in &mut event.exception.values { + if let Some(value) = &exception.value { + exception.value = Some(redact_after_edr_marker(value)); + } + } +} + +/// If `text` looks like an EDR error string (`... error {status}: {body}`), +/// return it with everything after the status colon replaced by a redaction +/// marker. Otherwise return it unchanged. Pure so it is unit-testable. +fn redact_after_edr_marker(text: &str) -> String { + if !text.contains("edr returned") || !text.contains(EDR_BODY_MARKER) { + return text.to_string(); + } + // The body follows the first ": " after the status code. Keep the prefix + // (which error, which status), redact the rest. + match text.find(": ") { + Some(idx) => format!("{}: {EDR_BODY_REDACTED}", &text[..idx]), + None => text.to_string(), + } +} + /// Application entry point. fn main() { // Sentry must be initialized BEFORE the async runtime so panics in the @@ -839,19 +972,29 @@ fn main() { async fn run() { tracing_subscriber::fmt::init(); - // Required: secret used for HMAC verification. We refuse to start - // without it - running with a default would silently disable auth. - let hmac_secret = env::var("VYROX_HMAC_SECRET").expect("VYROX_HMAC_SECRET must be set"); + // Decide once whether this is a production boot. Drives the two fail-closed + // gates below (proxy secret, nonce durability). The proxy has no first-class + // environment field, so we read the same `VYROX_ENV`/`ENVIRONMENT` the rest + // of the platform uses; absent or any non-"production" value is treated as + // dev/CI (the safe direction is to fail closed only when we are SURE it is + // production). + let is_production = is_production_env(); + + // Secret used to verify the HMAC on every /execute, /rollback and + // /audit/export request. Mirrors the Python signer's + // `effective_proxy_secret()` (SRF-07): prefer the dedicated + // `VYROX_PROXY_SECRET`, fall back to the shared `VYROX_HMAC_SECRET`. The two + // sides MUST resolve to the same value or every call 401s. In production we + // fail closed if neither is set; running with a default would silently + // disable auth. (Per-tenant HKDF key derivation is deferred to keep this + // wave in lockstep with the Python side.) + let hmac_secret = resolve_proxy_secret(is_production); if hmac_secret.len() < 32 { - warn!("VYROX_HMAC_SECRET is shorter than 32 bytes; consider rotating to a longer key"); + warn!("proxy signing 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()); - // Safe-by-default: DRY_RUN is TRUE unless explicitly turned off. - // Operators who want real execution must opt in. - let dry_run = parse_bool_env("DRY_RUN", true); - // Initialize the EDR client. See `edr.rs` for the configuration // contract - secrets are read from env there, not here. let edr = edr::EdrClient::from_env(); @@ -862,23 +1005,54 @@ async fn run() { let audit_chain = audit::ChainState::from_file(&audit_log_path).await; // Build the nonce/replay store. Prefers a durable Redis backend - // (NONCE_REDIS_URL/REDIS_URL); falls back to in-memory with a loud warning - // when no Redis URL is set. A configured-but-unreachable Redis is a hard - // boot error, the operator asked for durability and we will not silently - // downgrade to the restart-double-execute path. + // (NONCE_REDIS_URL/REDIS_URL); falls back to in-memory only when no Redis + // URL is set. A configured-but-unreachable Redis is a hard boot error, the + // operator asked for durability and we will not silently downgrade to the + // restart-double-execute path. + // + // PRX-01: a MISSING Redis URL is itself fatal in production (and in any + // boot that has not explicitly opted into the ephemeral store). The + // in-memory store loses its dedup table on restart, so a retry crossing a + // restart re-executes containment (double-isolate / double-bill). We refuse + // to serve that path by omission; dev/CI must opt in with + // `VYROX_PROXY_ALLOW_EPHEMERAL_NONCE=1`, mirroring how `ALLOW_INSECURE` + // gates the cleartext bind. This is enforced BEFORE we build the store so a + // missing URL never reaches the in-memory fallback unacknowledged. + let allow_ephemeral_nonce = parse_bool_env("VYROX_PROXY_ALLOW_EPHEMERAL_NONCE", false); + if nonce::redis_url_configured() { + // A URL is set: from_env returns Redis or hard-errors on an unreachable + // one. Either is correct, never the silent in-memory downgrade. + } else if is_production { + panic!( + "no Redis URL configured (NONCE_REDIS_URL/REDIS_URL) in production: the in-memory \ + nonce store loses its dedup table on restart, so a retry crossing a restart can \ + double-execute a containment action. Configure Redis for durable, shared dedup. \ + (VYROX_PROXY_ALLOW_EPHEMERAL_NONCE is ignored in production.)" + ); + } else if !allow_ephemeral_nonce { + panic!( + "no Redis URL configured (NONCE_REDIS_URL/REDIS_URL): refusing to fall back to the \ + in-memory nonce store, which is not durable and would re-execute a containment on a \ + retry crossing a restart. Set REDIS_URL, or set \ + VYROX_PROXY_ALLOW_EPHEMERAL_NONCE=1 to explicitly accept the ephemeral store for \ + local dev / CI." + ); + } let nonces = nonce::NonceStore::from_env() .await .expect("failed to connect to the configured Redis nonce store"); if nonces.is_durable() { info!("nonce store backend: redis (durable, shared)"); } else { - info!("nonce store backend: in-memory (NOT durable; dev/CI only)"); + info!( + "nonce store backend: in-memory (NOT durable; dev/CI only, explicitly opted in via \ + VYROX_PROXY_ALLOW_EPHEMERAL_NONCE)" + ); } let state = AppState { hmac_secret, audit_log_path, - dry_run, nonces, edr, audit_chain, @@ -901,7 +1075,7 @@ async fn run() { match (tls_cert, tls_key) { (Some(cert), Some(key)) => { - info!(addr = %bind_addr, tls = true, dry_run, "vyrox proxy starting (TLS)"); + info!(addr = %bind_addr, tls = true, is_production, "vyrox proxy starting (TLS)"); let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert, &key) .await .expect("failed to load TLS cert/key - check TLS_CERT_PATH and TLS_KEY_PATH"); @@ -936,7 +1110,7 @@ async fn run() { acknowledge the risk explicitly." ); } - info!(addr = %bind_addr, tls = false, dry_run, "vyrox proxy starting (plain HTTP)"); + info!(addr = %bind_addr, tls = false, is_production, "vyrox proxy starting (plain HTTP)"); let listener = tokio::net::TcpListener::bind(&bind_addr) .await .expect("bind should work"); @@ -1042,6 +1216,33 @@ mod tests { assert_eq!(rl.tenants.len(), 0, "idle windows should be evicted"); } + #[test] + fn edr_body_scrubber_redacts_body_keeps_prefix() { + // PRX-05: the raw EDR response body is redacted, the diagnostic prefix + // (which error, which status) is kept. Mirrors the EdrError Display + // shapes from edr.rs. + let client = redact_after_edr_marker( + "edr returned client error 403: {\"secret\":\"leak\",\"detail\":\"forbidden\"}", + ); + assert_eq!(client, "edr returned client error 403: [edr-body-redacted]"); + + let server = redact_after_edr_marker( + "edr returned server error 500: stacktrace with hostnames and ids", + ); + assert_eq!(server, "edr returned server error 500: [edr-body-redacted]"); + + // Unrelated messages pass through untouched. + let unrelated = redact_after_edr_marker("nonce store unavailable; failing closed"); + assert_eq!(unrelated, "nonce store unavailable; failing closed"); + + // A transport error has no body to leak and is left alone. + let transport = redact_after_edr_marker("edr transport error: connection refused"); + assert_eq!( + transport, "edr transport error: connection refused", + "transport errors carry no EDR body, leave them readable" + ); + } + #[test] fn parse_u32_env_rejects_zero_and_garbage() { std::env::set_var("VYROX_TEST_RL", "0"); @@ -1058,8 +1259,7 @@ mod tests { // // These drive the assembled router in-process with `tower::oneshot`, // so the whole path (HMAC, replay window, nonce, audit-before-act, - // DRY_RUN short-circuit, EDR dispatch) is exercised with no socket and - // no live EDR. + // EDR dispatch) is exercised with no socket and no live EDR. use axum::body::Body; use axum::http::Request; @@ -1080,25 +1280,23 @@ mod tests { } } - /// Build a router with a known secret and a temp audit log. `dry_run` - /// toggles the EDR short-circuit; `edr` is the global fallback client. - fn test_router(dry_run: bool, edr: edr::EdrClient) -> (Router, TempDir) { - test_router_with_limiter(dry_run, edr, permissive_limiter()) + /// Build a router with a known secret and a temp audit log. `edr` is the + /// global fallback client. The proxy always dispatches to the EDR now (the + /// DRY_RUN kill-switch is gone), so a `Noop` fallback is what lets a test + /// exercise the dispatch path without a live EDR; a per-tenant credential + /// on the request overrides it. + fn test_router(edr: edr::EdrClient) -> (Router, TempDir) { + test_router_with_limiter(edr, permissive_limiter()) } /// Same as `test_router` but with a caller-supplied limiter, so the /// per-tenant isolation test can set a tiny per-tenant budget. - fn test_router_with_limiter( - dry_run: bool, - edr: edr::EdrClient, - rate: RateLimiter, - ) -> (Router, TempDir) { + fn test_router_with_limiter(edr: edr::EdrClient, rate: RateLimiter) -> (Router, TempDir) { let dir = TempDir::new().expect("tempdir"); let audit_log_path = dir.path().join("audit.jsonl").to_str().unwrap().to_string(); let state = AppState { hmac_secret: TEST_SECRET.to_string(), audit_log_path, - dry_run, nonces: nonce::NonceStore::in_memory(), edr, audit_chain: audit::ChainState::genesis(), @@ -1127,7 +1325,8 @@ mod tests { } /// Build a request body for `/execute` or `/rollback`. `creds` is the - /// optional per-tenant credential blob. Tenant defaults to "tenant-a". + /// optional per-tenant credential blob. Tenant defaults to "tenant-a", + /// `simulated` defaults to false (real fleet). fn action_body(request_id: &str, creds: Option) -> Vec { action_body_for("tenant-a", request_id, creds) } @@ -1139,7 +1338,7 @@ mod tests { request_id: &str, creds: Option, ) -> Vec { - action_body_full(tenant_id, request_id, "HOST_ISOLATION", creds) + action_body_full(tenant_id, request_id, "HOST_ISOLATION", creds, false) } /// As `action_body` but with an explicit action_type, for the @@ -1149,7 +1348,17 @@ mod tests { request_id: &str, creds: Option, ) -> Vec { - action_body_full("tenant-a", request_id, action_type, creds) + action_body_full("tenant-a", request_id, action_type, creds, false) + } + + /// As `action_body` but with the `simulated` honesty label set, for the + /// tests that prove the flag round-trips through audit + response. + fn action_body_simulated( + request_id: &str, + creds: Option, + simulated: bool, + ) -> Vec { + action_body_full("tenant-a", request_id, "HOST_ISOLATION", creds, simulated) } /// Fully parameterised request-body builder backing the helpers above. @@ -1158,6 +1367,7 @@ mod tests { request_id: &str, action_type: &str, creds: Option, + simulated: bool, ) -> Vec { let mut obj = json!({ "request_id": request_id, @@ -1167,6 +1377,7 @@ mod tests { "host": "device-1", "approved_by": "analyst-jane", "approved_at": now_secs(), + "simulated": simulated, }); if let Some(c) = creds { obj["edr_credentials"] = c; @@ -1209,41 +1420,57 @@ mod tests { } #[tokio::test] - async fn execute_dry_run_short_circuits_and_audits() { - let (router, dir) = test_router(true, edr::EdrClient::Noop); - let body = action_body("req-exec-dry", None); + async fn execute_always_dispatches_and_audits() { + // The global DRY_RUN kill-switch is gone: the proxy ALWAYS dispatches. + // The Noop fallback stands in for the EDR so the dispatch path runs to + // completion without a live call, and the status is "executed", never + // the old "dry_run". The intent is audited before the action. + let (router, dir) = test_router(edr::EdrClient::Noop); + let body = action_body("req-exec", None); let (status, value) = post_json(&router, "/execute", body).await; assert_eq!(status, StatusCode::OK); - assert_eq!(value["status"], "dry_run"); - assert_eq!(value["dry_run"], true); - // Audit-before-act: the entry is on disk even though no EDR ran. + assert_eq!(value["status"], "executed"); + assert_eq!(value["simulated"], false); + // Audit-before-act: the entry is on disk. let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); assert!(log.contains("HostIsolation")); } #[tokio::test] - async fn rollback_dry_run_short_circuits_and_audits_rollback_action() { - let (router, dir) = test_router(true, edr::EdrClient::Noop); - let body = action_body("req-rb-dry", None); + async fn rollback_always_dispatches_and_audits_rollback_action() { + let (router, dir) = test_router(edr::EdrClient::Noop); + let body = action_body("req-rb", None); let (status, value) = post_json(&router, "/rollback", body).await; assert_eq!(status, StatusCode::OK); - assert_eq!(value["status"], "dry_run"); - assert_eq!(value["dry_run"], true); + assert_eq!(value["status"], "rolled_back"); + assert_eq!(value["simulated"], false); // The audit entry names the rollback so the trail shows what was undone. let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); assert!(log.contains("ROLLBACK_HostIsolation")); } #[tokio::test] - async fn rollback_real_dispatch_succeeds_via_noop_fallback() { - // dry_run=false but the global fallback is Noop, so the rollback - // dispatch path runs to completion and reports rolled_back. - let (router, _dir) = test_router(false, edr::EdrClient::Noop); - let body = action_body("req-rb-live", None); - let (status, value) = post_json(&router, "/rollback", body).await; + async fn simulated_flag_round_trips_through_response_and_audit() { + // A demo/mock tenant sends simulated=true. It does NOT change behavior + // (the proxy still dispatches, here to the Noop fallback), but it MUST + // be echoed in the response and recorded in the audit entry so the + // evidence shows the action targeted a demo/mock fleet. + let (router, dir) = test_router(edr::EdrClient::Noop); + let body = action_body_simulated("req-sim", None, true); + let (status, value) = post_json(&router, "/execute", body).await; assert_eq!(status, StatusCode::OK); - assert_eq!(value["status"], "rolled_back"); - assert_eq!(value["dry_run"], false); + assert_eq!(value["status"], "executed", "still dispatches; not skipped"); + assert_eq!(value["simulated"], true, "honesty label echoed back"); + // The audit entry carries simulated:true. + let entries = audit::read_audit_logs(dir.path().join("audit.jsonl").to_str().unwrap()) + .await + .expect("read audit log"); + assert!( + entries + .iter() + .any(|e| e.simulated && e.action_type == "HostIsolation"), + "the audit entry must record simulated=true" + ); } #[tokio::test] @@ -1252,7 +1479,7 @@ mod tests { // CrowdStrike credential pointed at an unreachable base_url, so a // 502 proves the proxy used the PER-TENANT credential, not the // global Noop fallback (which would have returned 200). - let (router, _dir) = test_router(false, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", "api_key": "tenant-a-id", @@ -1278,7 +1505,7 @@ mod tests { // host quarantine) and the audit trail must record both the intent // and the failure. The credential points at an unreachable address: // 501 (not 502 Transport) proves no EDR call was even attempted. - let (router, dir) = test_router(false, edr::EdrClient::Noop); + let (router, dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", "api_key": "tenant-a-id", @@ -1306,7 +1533,7 @@ mod tests { #[tokio::test] async fn process_kill_unsupported_on_sentinelone_returns_501() { - let (router, _dir) = test_router(false, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "sentinelone", "api_key": "", @@ -1320,70 +1547,76 @@ mod tests { } #[tokio::test] - async fn process_kill_dry_run_predicts_production_501() { - // The supportability check runs BEFORE the dry-run short-circuit, - // so a rehearsal of an action the provider cannot perform returns - // the same 501 production would. The check is pure (action-name - // mappers only) and the credential points at an unreachable - // address: 501 (not 502 Transport) proves no EDR call was even - // attempted, so Rule #5 still holds. - let (router, dir) = test_router(true, edr::EdrClient::Noop); + async fn process_kill_unsupported_fails_before_any_edr_call() { + // An unsupported action must fail loudly with 501 BEFORE any network + // call, never be silently substituted with a broader containment. The + // supportability check is pure (action-name mappers only) and the + // credential points at an unreachable address: 501 (not 502 Transport) + // proves no EDR call was attempted. This held under the old DRY_RUN + // gate and must still hold now that the proxy always dispatches. + let (router, dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", "api_key": "tenant-a-id", "api_secret": "tenant-a-secret", "base_url": "http://127.0.0.1:1", }); - let body = action_body_with_type("PROCESS_KILL", "req-kill-dry", Some(creds)); + let body = action_body_with_type("PROCESS_KILL", "req-kill-pure", Some(creds)); let (status, _) = post_json(&router, "/execute", body).await; assert_eq!( status, StatusCode::NOT_IMPLEMENTED, - "dry-run must predict the production 501 for unsupported actions" + "unsupported action must be 501, attempted before any EDR call" ); - // The trail mirrors production too: intent entry plus a FAILED_ - // companion so the rehearsal does not read as a success. + // Intent entry plus a FAILED_ companion so the trail does not read as + // a success. let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); assert!(log.contains("\"ProcessKill\"")); assert!(log.contains("\"FAILED_ProcessKill\"")); } #[tokio::test] - async fn process_kill_dry_run_on_sentinelone_returns_501() { - let (router, _dir) = test_router(true, edr::EdrClient::Noop); + async fn process_kill_unsupported_on_sentinelone_fails_before_any_call() { + let (router, _dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "sentinelone", "api_key": "", "api_secret": "tenant-b-token", "base_url": "http://127.0.0.1:1", }); - let body = action_body_with_type("PROCESS_KILL", "req-kill-dry-s1", Some(creds)); + let body = action_body_with_type("PROCESS_KILL", "req-kill-pure-s1", Some(creds)); let sig = sign_body(&body); let status = post(&router, "/execute", body, Some(sig)).await; assert_eq!(status, StatusCode::NOT_IMPLEMENTED); } #[tokio::test] - async fn host_isolation_dry_run_with_creds_still_short_circuits() { - // Supported actions are untouched by the reorder: dry-run still - // returns dry-run success with ZERO network calls. The credential - // points at an unreachable address, so anything other than - // dry_run success would mean a call left the proxy (Rule #5). - let (router, dir) = test_router(true, edr::EdrClient::Noop); + async fn supported_action_with_creds_always_dispatches_no_short_circuit() { + // There is no DRY_RUN short-circuit anymore: a supported action with a + // usable per-tenant credential ALWAYS attempts the real EDR call. The + // credential points at an unreachable address, so the proxy reaches the + // transport and returns 502, proving the call left the proxy rather than + // being skipped. Under the old gate this returned a "dry_run" success + // with zero network calls; that path is gone. + let (router, dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", "api_key": "tenant-a-id", "api_secret": "tenant-a-secret", "base_url": "http://127.0.0.1:1", }); - let body = action_body("req-iso-dry-creds", Some(creds)); - let (status, value) = post_json(&router, "/execute", body).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(value["status"], "dry_run"); - assert_eq!(value["dry_run"], true); + let body = action_body("req-iso-creds", Some(creds)); + let sig = sign_body(&body); + let status = post(&router, "/execute", body, Some(sig)).await; + assert_eq!( + status, + StatusCode::BAD_GATEWAY, + "supported action must attempt the real call, not short-circuit" + ); + // Intent audited, plus a FAILED_ companion for the transport failure. let log = std::fs::read_to_string(dir.path().join("audit.jsonl")).expect("audit log"); assert!(log.contains("\"HostIsolation\"")); - assert!(!log.contains("FAILED_")); + assert!(log.contains("\"FAILED_HostIsolation\"")); } #[tokio::test] @@ -1391,7 +1624,7 @@ mod tests { // A reachable-looking but dead EDR (transport failure) must leave a // FAILED_ entry next to the intent entry, so an auditor reading the // trail can tell intent from outcome. - let (router, dir) = test_router(false, edr::EdrClient::Noop); + let (router, dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", "api_key": "tenant-a-id", @@ -1409,7 +1642,7 @@ mod tests { #[tokio::test] async fn missing_signature_is_unauthorized() { - let (router, _dir) = test_router(true, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); let body = action_body("req-nosig", None); let status = post(&router, "/execute", body, None).await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -1417,7 +1650,7 @@ mod tests { #[tokio::test] async fn bad_signature_is_unauthorized_on_rollback() { - let (router, _dir) = test_router(true, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); let body = action_body("req-badsig", None); let status = post(&router, "/rollback", body, Some("sha256=deadbeef".into())).await; assert_eq!(status, StatusCode::UNAUTHORIZED); @@ -1425,7 +1658,7 @@ mod tests { #[tokio::test] async fn stale_timestamp_is_rejected_by_replay_window() { - let (router, _dir) = test_router(true, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); // approved_at far in the past: outside the 30s replay window. let mut obj = json!({ "request_id": "req-stale", @@ -1445,11 +1678,11 @@ mod tests { #[tokio::test] async fn duplicate_request_id_is_replayed_not_re_executed() { - let (router, _dir) = test_router(true, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); let body = action_body("req-dup", None); let (s1, v1) = post_json(&router, "/execute", body.clone()).await; assert_eq!(s1, StatusCode::OK); - assert_eq!(v1["status"], "dry_run"); + assert_eq!(v1["status"], "executed"); // Same request_id again: cached replay, not a second execution. let (s2, v2) = post_json(&router, "/execute", body).await; assert_eq!(s2, StatusCode::OK); @@ -1470,7 +1703,7 @@ mod tests { tenants: Arc::new(DashMap::new()), global: Arc::new(Mutex::new((Instant::now(), 0))), }; - let (router, _dir) = test_router_with_limiter(true, edr::EdrClient::Noop, limiter); + let (router, _dir) = test_router_with_limiter(edr::EdrClient::Noop, limiter); // Tenant A: first two succeed, the rest are throttled (429). let a1 = action_body_for("tenant-a", "a-1", None); @@ -1493,14 +1726,14 @@ mod tests { let (sb2, vb2) = post_json(&router, "/execute", b2).await; assert_eq!(sb1, StatusCode::OK, "B #1 must be ok despite A's burst"); assert_eq!(sb2, StatusCode::OK, "B #2 must be ok despite A's burst"); - assert_eq!(vb1["status"], "dry_run"); - assert_eq!(vb2["status"], "dry_run"); + assert_eq!(vb1["status"], "executed"); + assert_eq!(vb2["status"], "executed"); } // ── End-to-end: execute then rollback against a stateful mock EDR ────── // - // The tests above short-circuit the EDR (DRY_RUN) or point a per-tenant - // credential at an unreachable address. This one proves the WHOLE loop: + // The tests above use the Noop fallback or point a per-tenant credential at + // an unreachable address. This one proves the WHOLE loop: // the proxy verifies the request, audits, and drives a REAL EDR call (over // loopback HTTP) that isolates a host on /execute and un-isolates it on // /rollback. A tiny stateful mock EDR implements the exact CrowdStrike @@ -1600,9 +1833,9 @@ mod tests { #[tokio::test] async fn execute_then_rollback_isolates_then_unisolates_against_mock_edr() { let (edr_base, edr_state) = spawn_mock_edr().await; - // Real dispatch (dry_run=false); the global fallback is Noop and must - // NOT be used because the per-tenant credential is present and usable. - let (router, dir) = test_router(false, edr::EdrClient::Noop); + // Real dispatch; the global fallback is Noop and must NOT be used + // because the per-tenant credential is present and usable. + let (router, dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", @@ -1617,7 +1850,7 @@ mod tests { let (exec_status, exec_value) = post_json(&router, "/execute", exec_body).await; assert_eq!(exec_status, StatusCode::OK); assert_eq!(exec_value["status"], "executed"); - assert_eq!(exec_value["dry_run"], false); + assert_eq!(exec_value["simulated"], false); assert_eq!( edr_state.lock().unwrap().get(host).copied(), Some(true), @@ -1629,7 +1862,7 @@ mod tests { let (rb_status, rb_value) = post_json(&router, "/rollback", rb_body).await; assert_eq!(rb_status, StatusCode::OK); assert_eq!(rb_value["status"], "rolled_back"); - assert_eq!(rb_value["dry_run"], false); + assert_eq!(rb_value["simulated"], false); assert_eq!( edr_state.lock().unwrap().get(host).copied(), Some(false), @@ -1652,6 +1885,54 @@ mod tests { ); } + #[tokio::test] + async fn simulated_demo_tenant_runs_real_path_against_mock_edr() { + // The milestone case: a demo/mock tenant (simulated=true) is NOT + // short-circuited. The proxy runs the SAME real execute path, the + // per-tenant credential just points it at the bundled mock EDR, so the + // host is genuinely isolated on the (mock) fleet AND the honesty label + // is echoed in the response and recorded in the audit/evidence. This is + // the end-to-end replacement for the old DRY_RUN behaviour. + let (edr_base, edr_state) = spawn_mock_edr().await; + let (router, dir) = test_router(edr::EdrClient::Noop); + + let creds = json!({ + "provider": "crowdstrike", + "api_key": "demo-tenant-client-id", + "api_secret": "demo-tenant-client-secret", + "base_url": edr_base, + }); + let host = "device-1"; + + let body = action_body_simulated("sim-exec", Some(creds), true); + let (status, value) = post_json(&router, "/execute", body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + value["status"], "executed", + "the real path ran; not skipped" + ); + assert_eq!(value["simulated"], true, "honesty label echoed"); + + // The mock EDR really isolated the host: the real call ran. + assert_eq!( + edr_state.lock().unwrap().get(host).copied(), + Some(true), + "the real execute path must have reached the mock EDR" + ); + + // The audit entry records simulated=true so the evidence shows the + // action targeted a demo/mock fleet. + let entries = audit::read_audit_logs(dir.path().join("audit.jsonl").to_str().unwrap()) + .await + .expect("read audit log"); + assert!( + entries + .iter() + .any(|e| e.simulated && e.action_type == "HostIsolation"), + "the executed entry must carry simulated=true" + ); + } + #[tokio::test] async fn rollback_against_failing_edr_is_bad_gateway() { // A mock EDR that always 500s on the action endpoint. The proxy must @@ -1678,7 +1959,7 @@ mod tests { axum::serve(listener, app).await.expect("serve"); }); - let (router, _dir) = test_router(false, edr::EdrClient::Noop); + let (router, _dir) = test_router(edr::EdrClient::Noop); let creds = json!({ "provider": "crowdstrike", "api_key": "id", diff --git a/src/nonce.rs b/src/nonce.rs index 9cc2f19..231cd4f 100644 --- a/src/nonce.rs +++ b/src/nonce.rs @@ -128,6 +128,17 @@ fn retention_seconds() -> u64 { .unwrap_or(DEFAULT_RETENTION_SECONDS) } +/// True when a Redis URL is configured (`NONCE_REDIS_URL` or `REDIS_URL`). +/// +/// Lets `main` decide BEFORE building the store whether a missing URL should be +/// a hard boot error (PRX-01). A configured-but-unreachable URL is handled by +/// `from_env` (it hard-errors); this only distinguishes "URL present" from "no +/// URL at all", which is the case that would otherwise silently fall back to +/// the non-durable in-memory store. +pub fn redis_url_configured() -> bool { + redis_url_from_env().is_some() +} + /// Resolve the Redis URL for the nonce store from the environment. /// /// `NONCE_REDIS_URL` wins over the shared `REDIS_URL`. A blank value is treated @@ -678,7 +689,10 @@ mod tests { "first claim must be fresh" ); store1 - .record_response(&req, r#"{"status":"executed","dry_run":false}"#.to_string()) + .record_response( + &req, + r#"{"status":"executed","simulated":false}"#.to_string(), + ) .await .unwrap(); @@ -698,7 +712,7 @@ mod tests { cached_response_json, } => { assert_eq!( - cached_response_json, r#"{"status":"executed","dry_run":false}"#, + cached_response_json, r#"{"status":"executed","simulated":false}"#, "cached response must survive the restart" ); } From 50a1648441a8ed8ceedb1c0c9a4d843a355a098f Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Sun, 14 Jun 2026 16:45:27 +0530 Subject: [PATCH 4/7] fix(proxy): in-memory nonce eviction honors configured ttl_seconds claim_memory/evict_expired used the hardcoded DEFAULT_RETENTION_SECONDS instead of the store's configured ttl_seconds, so the memory backend's replay-retention window ignored config. Thread ttl_seconds through. Also gitignore the runtime /audit log artifact. --- .gitignore | 1 + src/nonce.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 2c18812..825d277 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ target/ .env .env.* audit.jsonl +/audit *.log # Cargo generated diff --git a/src/nonce.rs b/src/nonce.rs index 231cd4f..ebb2bbf 100644 --- a/src/nonce.rs +++ b/src/nonce.rs @@ -267,7 +267,7 @@ impl NonceStore { /// it is non-empty. pub async fn claim_or_replay(&self, request_id: &str) -> Result { match &self.backend { - Backend::Memory(map) => Ok(claim_memory(map, request_id)), + Backend::Memory(map) => Ok(claim_memory(map, request_id, self.ttl_seconds)), Backend::Redis(manager) => { claim_redis(manager.clone(), request_id, self.ttl_seconds).await } @@ -445,12 +445,12 @@ enum RecordState { /// Atomic claim-or-replay against the in-memory map. `DashMap::entry` makes the /// check-and-insert atomic per shard. -fn claim_memory(map: &Arc>, request_id: &str) -> Outcome { +fn claim_memory(map: &Arc>, request_id: &str, ttl_seconds: u64) -> Outcome { // Run eviction opportunistically on the claim path so memory is reclaimed // even with no background task. Only triggered at/above the cap to amortize // the cost. if map.len() >= MAX_RECORDS { - evict_expired(map); + evict_expired(map, ttl_seconds); // TTL eviction alone does NOT bound memory: an adversary (or a genuine // storm) sending > MAX_RECORDS unique request_ids inside the retention // window leaves every record younger than the cutoff, so `evict_expired` @@ -491,9 +491,9 @@ fn record_memory(map: &Arc>, request_id: &str, response_ } } -/// Drop records whose `created_at` is older than the retention window. -fn evict_expired(map: &Arc>) { - let cutoff = Duration::from_secs(DEFAULT_RETENTION_SECONDS); +/// Drop records whose `created_at` is older than the configured retention window. +fn evict_expired(map: &Arc>, ttl_seconds: u64) { + let cutoff = Duration::from_secs(ttl_seconds); map.retain(|_, record| record.created_at.elapsed() < cutoff); } From 4b046bb9c1f988c460b568f541dcb309d491db47 Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Sun, 14 Jun 2026 16:46:40 +0530 Subject: [PATCH 5/7] test(proxy): pass ttl_seconds to claim_memory in nonce tests The two in-test callers used the old 2-arg signature; pass DEFAULT_RETENTION_SECONDS to preserve their original behavior. --- src/nonce.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nonce.rs b/src/nonce.rs index ebb2bbf..56a1b26 100644 --- a/src/nonce.rs +++ b/src/nonce.rs @@ -593,7 +593,10 @@ mod tests { for _ in 0..64 { let m = map.clone(); handles.push(std::thread::spawn(move || { - matches!(claim_memory(&m, "hot-key"), Outcome::FreshClaim) + matches!( + claim_memory(&m, "hot-key", DEFAULT_RETENTION_SECONDS), + Outcome::FreshClaim + ) })); } let fresh_wins: usize = handles @@ -612,7 +615,7 @@ mod tests { // eviction keeps memory bounded. Regression for the OOM gap. let map: Arc> = Arc::new(DashMap::new()); for i in 0..(MAX_RECORDS + 1_000) { - let _ = claim_memory(&map, &format!("burst-{i}")); + let _ = claim_memory(&map, &format!("burst-{i}"), DEFAULT_RETENTION_SECONDS); } assert!( map.len() <= MAX_RECORDS, From 02e64609d9b0d2080926042fbcd4079dbcede1e6 Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Sun, 14 Jun 2026 20:02:26 +0530 Subject: [PATCH 6/7] chore(proxy): bump sentry 0.34 -> 0.48 to drop vulnerable rustls-webpki sentry 0.34 pulled rustls 0.22 -> rustls-webpki 0.102.8, which carries 4 RUSTSEC TLS advisories (RUSTSEC-2026-0049/0098/0099/0104). sentry 0.48 uses rustls 0.23 -> rustls-webpki 0.103.13 (patched). The rest of the TLS stack (axum-server, reqwest, redis) was already on 0.103. No code changes needed (sentry::init, ClientOptions, release_name!, ClientInitGuard, protocol::Event are unchanged). cargo audit: 4 vulnerabilities -> 0. cargo fmt/clippy/test all pass (68 tests). --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b84d348..504fe96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ serde_json = "1" # deploys send nothing. We disable default features and opt into only the # panic + backtrace integrations to keep the dependency surface small and # avoid pulling in a second TLS stack — `rustls` matches axum-server/reqwest. -sentry = { version = "0.34", default-features = false, features = ["backtrace", "contexts", "panic", "reqwest", "rustls"] } +sentry = { version = "0.48", default-features = false, features = ["backtrace", "contexts", "panic", "reqwest", "rustls"] } sha2 = "0.10" # `subtle` provides timing-safe byte comparisons. Used in `hmac::verify_signature` # to defeat timing side-channel attacks during signature comparison. From acabfd722dc868c4172ab06a403645f314bdda25 Mon Sep 17 00:00:00 2001 From: keirsalterego Date: Sun, 14 Jun 2026 20:07:27 +0530 Subject: [PATCH 7/7] chore(proxy): commit patched Cargo.lock, stop ignoring it Cargo.lock was tracked but also listed in .gitignore, so the sentry 0.48 bump never reached the committed lock and CI kept auditing the stale tree (webpki 0.102.8). Commit the regenerated lock (only rustls-webpki 0.103.13 now) and drop the dead .gitignore entry: a binary should commit its lockfile so audit + builds are reproducible. --- .gitignore | 3 - Cargo.lock | 673 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 499 insertions(+), 177 deletions(-) diff --git a/.gitignore b/.gitignore index 825d277..563213d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ audit.jsonl /audit *.log -# Cargo generated -Cargo.lock - # Dev config .claude/ diff --git a/Cargo.lock b/Cargo.lock index bffb962..808b2b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arc-swap" version = "1.9.1" @@ -80,9 +86,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -187,7 +193,7 @@ dependencies = [ "hyper", "hyper-util", "pin-project-lite", - "rustls 0.23.40", + "rustls", "rustls-pemfile", "rustls-pki-types", "tokio", @@ -227,9 +233,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -251,9 +257,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -263,9 +269,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.60" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -287,9 +293,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -398,9 +404,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "digest" @@ -425,9 +428,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -495,6 +498,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -601,11 +610,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -637,12 +659,27 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -671,9 +708,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -716,9 +753,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -745,11 +782,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.40", + "rustls", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots", ] [[package]] @@ -881,6 +918,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -910,6 +953,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -924,6 +969,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -936,13 +1030,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -952,6 +1045,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -981,9 +1080,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -1008,9 +1107,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -1029,9 +1128,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1325,6 +1424,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1346,7 +1455,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.40", + "rustls", "socket2", "thiserror 2.0.18", "tokio", @@ -1360,13 +1469,14 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand", "ring", "rustc-hash", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1405,15 +1515,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand" -version = "0.8.6" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -1421,18 +1526,8 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1442,16 +1537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1465,9 +1551,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543" +checksum = "f9fd510128eda94d1d49b9f81487744d5c451422431cce41238fe2853d29f4cc" dependencies = [ "arc-swap", "arcstr", @@ -1481,7 +1567,7 @@ dependencies = [ "itoa", "percent-encoding", "pin-project-lite", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "ryu", "sha1_smol", @@ -1490,7 +1576,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "url", - "webpki-roots 1.0.7", + "webpki-roots", "xxhash-rust", ] @@ -1505,9 +1591,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1528,21 +1614,60 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -1554,11 +1679,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1569,7 +1694,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -1620,20 +1744,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - [[package]] name = "rustls" version = "0.23.40" @@ -1645,7 +1755,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] @@ -1682,16 +1792,32 @@ dependencies = [ ] [[package]] -name = "rustls-webpki" -version = "0.102.8" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -1716,6 +1842,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1762,13 +1897,14 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sentry" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +checksum = "931a20b0da02350676e3d6d3c9028d58eaa448cf42a866712eec5845a505421e" dependencies = [ + "cfg_aliases", "httpdate", - "reqwest", - "rustls 0.22.4", + "reqwest 0.13.4", + "rustls", "sentry-backtrace", "sentry-contexts", "sentry-core", @@ -1776,26 +1912,24 @@ dependencies = [ "sentry-tracing", "tokio", "ureq", - "webpki-roots 0.26.11", ] [[package]] name = "sentry-backtrace" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +checksum = "911ee36abf5b7fa335fccd5f54361ba9c16baea5f0c3bb361a687b6c195c21cf" dependencies = [ "backtrace", - "once_cell", "regex", "sentry-core", ] [[package]] name = "sentry-contexts" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +checksum = "9b9d7d469e9e22741c17ca23fb8b42d79861590eb7cf330f3da34fc1e4bc1bc6" dependencies = [ "hostname", "libc", @@ -1807,22 +1941,22 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +checksum = "545dc562b6758d646ac19e1407f4ebc26d452111386743e03323464bc48bb2e0" dependencies = [ - "once_cell", - "rand 0.8.6", + "rand", "sentry-types", "serde", "serde_json", + "url", ] [[package]] name = "sentry-panic" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +checksum = "772d9de150c8ca910c835353c85f434457348fdd21208f9b3da3574202b1dc5d" dependencies = [ "sentry-backtrace", "sentry-core", @@ -1830,10 +1964,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +checksum = "c51ec9620a4d398dcdf7ee90effbf8d8691cfa24e91978bfa8565cac039d4980" dependencies = [ + "bitflags", "sentry-backtrace", "sentry-core", "tracing-core", @@ -1842,16 +1977,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.34.0" +version = "0.48.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +checksum = "041359745a44dd2e14fe21b7510fe7ca8b5beffce6636a0b52e5bc7d5f736887" dependencies = [ "debugid", "hex", - "rand 0.8.6", + "rand", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", "time", "url", "uuid", @@ -1889,9 +2024,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1951,9 +2086,25 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" @@ -1963,15 +2114,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2027,7 +2178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2084,12 +2235,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -2099,15 +2249,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -2140,9 +2290,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2170,7 +2320,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls", "tokio", ] @@ -2303,9 +2453,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uname" @@ -2322,6 +2472,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2330,17 +2486,30 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", "log", - "once_cell", - "rustls 0.23.40", + "percent-encoding", + "rustls", "rustls-pki-types", - "url", - "webpki-roots 0.26.11", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", ] [[package]] @@ -2356,6 +2525,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2397,7 +2572,7 @@ dependencies = [ "hmac", "http-body-util", "redis", - "reqwest", + "reqwest 0.12.28", "sentry", "serde", "serde_json", @@ -2411,6 +2586,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2428,18 +2613,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -2450,9 +2644,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -2460,9 +2654,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2470,9 +2664,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -2483,18 +2677,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2511,12 +2739,12 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.26.11" +name = "webpki-root-certs" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ - "webpki-roots 1.0.7", + "rustls-pki-types", ] [[package]] @@ -2528,6 +2756,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2743,12 +2980,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" @@ -2763,9 +3088,9 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2786,18 +3111,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -2827,9 +3152,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie"