From 6ccc2771d1419cd768d48167f322c70006a15378 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Mon, 9 Feb 2026 23:36:11 -0500 Subject: [PATCH 01/16] docs(audits): add 2026-02 rust security correctness audit --- ...2026-02-rust-security-correctness-audit.md | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 docs/audits/2026-02-rust-security-correctness-audit.md diff --git a/docs/audits/2026-02-rust-security-correctness-audit.md b/docs/audits/2026-02-rust-security-correctness-audit.md new file mode 100644 index 000000000..4e899493f --- /dev/null +++ b/docs/audits/2026-02-rust-security-correctness-audit.md @@ -0,0 +1,402 @@ +# Rust Security + Correctness Audit (2026-02-10) + +1) **Executive Summary** +- `High`: `hush-cli` remote-extends host allowlisting is bypassable for non-HTTP git remotes (SCP-style and `ssh://`/`git://`) in `crates/services/hush-cli/src/remote_extends.rs:333`. +- `High`: Path guards (`path_allowlist`, `forbidden_path`) evaluate only lexical-normalized paths, not resolved filesystem targets; symlink traversal can bypass intent in `crates/libs/clawdstrike/src/guards/path_allowlist.rs:98` and `crates/libs/clawdstrike/src/guards/forbidden_path.rs:191`. +- `Medium`: `allow_private_ips=false` is enforced for HTTP remote extends, but not for git remotes in server/CLI resolvers; policy intent is inconsistent (`crates/services/hushd/src/remote_extends.rs:152`, `crates/services/hushd/src/remote_extends.rs:303`). +- `Medium`: `hush run` uses `mpsc::unbounded_channel` for policy events plus per-connection task spawning, enabling unbounded memory growth under event flood (`crates/services/hush-cli/src/hush_run.rs:199`, `crates/services/hush-cli/src/hush_run.rs:695`). +- `Medium`: `hushd` session lock table (`DashMap>>`) has no lifecycle pruning; lock entries accumulate over long uptime (`crates/services/hushd/src/session/mod.rs:354`, `crates/services/hushd/src/session/mod.rs:405`). +- `Low`: `threat_intel_guards` tests panic on loopback bind denial due `unwrap()`; brittle in restricted runtime environments (`crates/libs/clawdstrike/tests/threat_intel_guards.rs:15`). +- No production `unsafe` blocks were found (only a test-only `unsafe` env var set in `crates/services/hushd/tests/common/mod.rs:68`), so direct UB risk surface is currently low. +- Local Rust gates are mostly healthy: `fmt` and `clippy` pass; test failures are isolated to the threat-intel test harness using localhost bind in this sandbox. + +2) **Current Gates (what exists today)** +- Workspace/packages discovered (Rust): 16 workspace members from root `Cargo.toml`; primary libs include `clawdstrike`, `hush-core`, `hush-proxy`, `spine`, `hush-certification`, `hush-multi-agent`; primary binaries include `hush`, `hushd`, `clawdstriked`, `spine-*`, `tetragon-bridge`, `hubble-bridge`, `clawdstrike-cloud-api`, and `clawdstrike` utility bins. +- Public API/entrypoint modules are explicit in crate `lib.rs` exports, especially `clawdstrike` (`engine`, `guards`, `policy`, `plugins`, `irm`, `posture`, etc.) at `crates/libs/clawdstrike/src/lib.rs`. +- CI on PR/push (`.github/workflows/ci.yml`) includes: `cargo fmt --check`, `cargo clippy --all-targets --all-features -D warnings`, `cargo build --all-targets`, `cargo test --all --exclude sdr-integration-tests`, MSRV build, offline vendored tests, docs build, `cargo audit`, `cargo deny`, coverage (`cargo llvm-cov`), wasm build/size gate, proptests (`PROPTEST_CASES=500`), integration tests, TS/Python package jobs. +- Scheduled fuzz exists separately (`.github/workflows/fuzz.yml`): daily at `03:00 UTC`, six fuzz targets, 60s each. +- Path-aware CI (`ci-changed-paths.yml`) runs scoped checks by changed domains. +- Pre-commit hooks are default sample hooks only; no active custom pre-commit hook enforced in repo. +- Local commands executed for validation: + - `cargo fmt --all -- --check` `OK` + - `cargo clippy --all-targets --all-features -- -D warnings` `OK` + - `cargo test --workspace` `FAIL` (3 failing threat-intel tests; localhost bind `PermissionDenied`) + - `mise run ci` `FAIL` (same 3 failures) + - `cargo test --all --exclude sdr-integration-tests` `FAIL` (same 3 failures) + - `CARGO_NET_OFFLINE=true scripts/cargo-offline.sh test --workspace --all-targets` (started, then interrupted due long rebuild) +- Tooling attempts (Phase 4): + - `cargo miri ...` / `cargo +nightly miri ...` `FAIL` `cargo-miri` missing. + - `cargo +nightly` ASAN/LSAN targeted run `FAIL` noisy failures from build-script/system-runtime leaks on macOS toolchain, not directly app code. + - `cargo fuzz ...` `FAIL` `cargo-fuzz` not installed. + - `cargo audit` / `cargo deny check` `FAIL` advisory DB fetch/lock blocked by environment. + +3) **Risk Map** +- `crates/services/hush-cli/src/remote_extends.rs` (`resolve_git_absolute`): + - Invariant: remote extends must be constrained to explicit allowlisted hosts/schemes and transport safety policy. +- `crates/services/hushd/src/remote_extends.rs` (`validate_and_resolve_http_target`, `resolve_git_absolute`): + - Invariant: `allow_private_ips=false` should prevent private/loopback resolution for all remote transport paths. +- `crates/libs/clawdstrike/src/guards/path_allowlist.rs`, `crates/libs/clawdstrike/src/guards/forbidden_path.rs`, `crates/libs/clawdstrike/src/guards/path_normalization.rs`: + - Invariant: path policy decision should reflect resolved filesystem target, not only lexical path string. +- `crates/services/hush-cli/src/hush_run.rs` (`mpsc::unbounded_channel`, proxy connection task fanout): + - Invariant: event/audit pipeline must be bounded under adversarial output/connection rates. +- `crates/services/hushd/src/session/mod.rs` (`session_locks`): + - Invariant: session lock bookkeeping should not grow unbounded across terminated/expired session churn. +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs` (test harness server binding): + - Invariant: test harness should degrade gracefully when loopback bind is unavailable. + +4) **Findings (detailed)** + +**CS-AUDIT-001** +Severity: High +Location: `crates/services/hush-cli/src/remote_extends.rs:333` (`resolve_git_absolute`) +Category: security invariant +What’s happening: Git remote host validation in CLI resolver is only applied when `Url::parse(repo)` succeeds and scheme is `http|https`; SCP-style (`git@host:repo`) and `ssh://`/`git://` paths skip allowlist enforcement. +Invariant violated: Remote extends host constraints should be enforced for all accepted git remote forms. +How to trigger: Use a policy with `extends: git+git@127.0.0.1:org/repo.git@deadbeef:policy.yaml#sha256=...` while allowlisting only `github.com`; resolver proceeds to git fetch path instead of immediate host allowlist rejection. +Evidence: Host check block is gated by `matches!(repo_url.scheme(), "http" | "https")` at `crates/services/hush-cli/src/remote_extends.rs:352`. +Fix: Parse git host for URL + SCP-like remotes and always run `ensure_host_allowed`; reject unsupported schemes explicitly. +Test: Add `remote_extends_git_scp_host_must_be_allowlisted` and `remote_extends_git_file_scheme_is_rejected` in CLI tests. + +**CS-AUDIT-002** +Severity: Medium +Location: `crates/services/hushd/src/remote_extends.rs:152`, `crates/services/hushd/src/remote_extends.rs:303`, `crates/services/hush-cli/src/remote_extends.rs:231`, `crates/services/hush-cli/src/remote_extends.rs:333` +Category: security invariant +What’s happening: Private-IP filtering is implemented in HTTP resolution flow but not in git flow before invoking `git fetch`. +Invariant violated: `allow_private_ips=false` should block private/loopback/link-local targets consistently for all remote-extends transports. +How to trigger: Configure allowlisted host that resolves to private address and use git remote extends (`git+ssh://...` or SCP-style); resolver can attempt private network fetch even with `allow_private_ips=false`. +Evidence: IP filter at HTTP path only (`...:152`/`...:231`), no equivalent check in `resolve_git_absolute` (`...:303`/`...:333`). +Fix: Resolve git host addresses and enforce `is_public_ip` before git fetch, or disallow non-HTTPS git remotes when private IPs are blocked. +Test: Add resolver test asserting git remote to private host is rejected with “non-public IPs” when `allow_private_ips=false`. + +**CS-AUDIT-003** +Severity: High +Location: `crates/libs/clawdstrike/src/guards/path_allowlist.rs:98`, `crates/libs/clawdstrike/src/guards/forbidden_path.rs:191`, `crates/libs/clawdstrike/src/guards/path_normalization.rs:10` +Category: security invariant / TOCTOU +What’s happening: Guards normalize path strings lexically only; they do not resolve symlinks/canonical paths. +Invariant violated: Guard decision for filesystem actions should correspond to actual file target, including symlink resolution. +How to trigger: Create symlink inside allowlisted path that points outside allowlist (or into forbidden path), then request access via symlink path. Lexical checks pass while real target violates policy. +Evidence: `normalize_path_for_policy` is purely lexical (`without filesystem access`) and is the sole normalization used by both guards. +Fix: Prefer canonicalized path for existing paths (and canonical parent for write-target paths), fallback to lexical only when canonicalization is impossible. +Test: Add Unix symlink regression tests proving allowlist denial/forbidden-path block on symlinked escape targets. + +**CS-AUDIT-004** +Severity: Medium +Location: `crates/services/hush-cli/src/hush_run.rs:199`, `crates/services/hush-cli/src/hush_run.rs:695` +Category: async leak / reliability +What’s happening: Event pipeline uses `mpsc::unbounded_channel`; proxy path can spawn many connection tasks and enqueue events without backpressure. +Invariant violated: Telemetry/event buffering should remain memory-bounded under high request rate or slow sink. +How to trigger: Run `hush run` against workload generating many CONNECT attempts while disk/network writer is slow; queue grows until process memory pressure. +Evidence: Unbounded channel creation at `...:199`; per-connection spawn at `...:695`; send path is best-effort and unconstrained. +Fix: Replace with bounded `mpsc::channel(N)`; apply backpressure or explicit drop policy + dropped-event counter. +Test: Add stress test that saturates queue and asserts bounded behavior (send blocks/fails predictably, no unbounded growth). + +**CS-AUDIT-005** +Severity: Medium +Location: `crates/services/hushd/src/session/mod.rs:354`, `crates/services/hushd/src/session/mod.rs:405`, `crates/services/hushd/src/session/mod.rs:623` +Category: leak +What’s happening: `session_locks` map inserts one lock per session ID and is never pruned, including after termination. +Invariant violated: Per-session lock table should track active sessions only; stale entries should be reclaimable. +How to trigger: Long-lived daemon with repeated session churn (`create_session` / `terminate_session`); lock map cardinality grows monotonically. +Evidence: Insert-only `lock_for_session_id` at `...:405`; no remove path; `terminate_session` only updates store at `...:623`. +Fix: Remove idle lock on session termination/expiration and periodic prune entries with `Arc::strong_count==1`. +Test: Add test that acquires lock for session, terminates session, and asserts lock entry is removed. + +**CS-AUDIT-006** +Severity: Low +Location: `crates/libs/clawdstrike/tests/threat_intel_guards.rs:15` +Category: reliability +What’s happening: Threat-intel tests `unwrap()` loopback bind; in restricted environments they panic before guard logic executes. +Invariant violated: Test harness should fail closed with clear skip/error semantics, not panic on environment capability mismatch. +How to trigger: Current sandbox produced `PermissionDenied` bind failures consistently for all three tests. +Evidence: Local test runs failed with panic at `threat_intel_guards.rs:15:59` (`TcpListener::bind("127.0.0.1:0").await.unwrap()`). +Fix: Return `Result` from helper and skip test on `PermissionDenied` (or gate with env feature). +Test: Add harness-level test asserting bind errors are handled without panic. + +5) **Patch proposals (only for top 3)** + +**Patch Proposal A (CS-AUDIT-001): enforce git host checks for all git remote forms** +```diff +diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs +@@ +- if let Ok(repo_url) = Url::parse(repo) { +- if matches!(repo_url.scheme(), "http" | "https") { +- if self.cfg.https_only && repo_url.scheme() != "https" { +- return Err(Error::ConfigError(format!( +- "Remote extends require https:// URLs (got {}://)", +- repo_url.scheme() +- ))); +- } +- let host = repo_url.host_str().ok_or_else(|| { +- Error::ConfigError(format!("Invalid URL host in remote extends: {}", repo)) +- })?; +- self.ensure_host_allowed(host)?; +- } +- } ++ let repo_host = parse_git_remote_host(repo)?; ++ self.ensure_host_allowed(&repo_host)?; ++ ++ if let Ok(repo_url) = Url::parse(repo) { ++ if self.cfg.https_only && repo_url.scheme() == "http" { ++ return Err(Error::ConfigError(format!( ++ "Remote extends require https:// URLs (got {}://)", ++ repo_url.scheme() ++ ))); ++ } ++ } + @@ ++fn parse_git_remote_host(repo: &str) -> Result { ++ if let Ok(repo_url) = Url::parse(repo) { ++ let scheme = repo_url.scheme(); ++ if !matches!(scheme, "http" | "https" | "ssh" | "git") { ++ return Err(Error::ConfigError(format!( ++ "Unsupported git remote scheme for remote extends: {}", ++ scheme ++ ))); ++ } ++ let host = repo_url.host_str().ok_or_else(|| { ++ Error::ConfigError(format!("Invalid URL host in remote extends: {}", repo)) ++ })?; ++ return Ok(normalize_host(host)); ++ } ++ ++ parse_scp_like_git_host(repo).ok_or_else(|| { ++ Error::ConfigError(format!( ++ "Invalid git remote in remote extends (expected URL or scp-style host:path): {}", ++ repo ++ )) ++ }) ++} ++ ++fn parse_scp_like_git_host(repo: &str) -> Option { ++ let (lhs, rhs) = repo.split_once(':')?; ++ if rhs.is_empty() || lhs.contains('/') || lhs.contains('\\') { ++ return None; ++ } ++ let host = lhs.rsplit_once('@').map(|(_, h)| h).unwrap_or(lhs); ++ let host = normalize_host(host); ++ if host.is_empty() { None } else { Some(host) } ++} +diff --git a/crates/services/hush-cli/src/tests.rs b/crates/services/hush-cli/src/tests.rs +@@ mod remote_extends_contract { ++ use clawdstrike::policy::{PolicyLocation, PolicyResolver as _}; +@@ ++ #[test] ++ fn remote_extends_git_scp_host_must_be_allowlisted() { ++ let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); ++ let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); ++ let reference = format!( ++ "git+git@127.0.0.1:org/repo.git@deadbeef:policy.yaml#sha256={}", ++ "0".repeat(64) ++ ); ++ let err = resolver ++ .resolve(&reference, &PolicyLocation::None) ++ .expect_err("scp-style git host should be rejected before fetch"); ++ assert!(err.to_string().contains("allowlisted"), "unexpected error: {err}"); ++ } ++ ++ #[test] ++ fn remote_extends_git_file_scheme_is_rejected() { ++ let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); ++ let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); ++ let reference = format!( ++ "git+file:///tmp/repo@deadbeef:policy.yaml#sha256={}", ++ "0".repeat(64) ++ ); ++ let err = resolver ++ .resolve(&reference, &PolicyLocation::None) ++ .expect_err("file:// git remotes must be rejected"); ++ assert!(err.to_string().contains("Unsupported git remote scheme"), "unexpected error: {err}"); ++ } + } +``` + +**Patch Proposal B (CS-AUDIT-003): resolve filesystem targets before path-policy matching** +```diff +diff --git a/crates/libs/clawdstrike/src/guards/path_normalization.rs b/crates/libs/clawdstrike/src/guards/path_normalization.rs +@@ ++use std::path::Path; ++ + pub fn normalize_path_for_policy(path: &str) -> String { +@@ + } ++ ++pub fn normalize_path_for_policy_with_fs(path: &str) -> String { ++ resolve_path_for_policy(path).unwrap_or_else(|| normalize_path_for_policy(path)) ++} ++ ++fn resolve_path_for_policy(path: &str) -> Option { ++ let p = Path::new(path); ++ if let Ok(canon) = std::fs::canonicalize(p) { ++ return Some(normalize_path_for_policy(&canon.to_string_lossy())); ++ } ++ let parent = p.parent()?; ++ let file_name = p.file_name()?; ++ let canon_parent = std::fs::canonicalize(parent).ok()?; ++ let joined = canon_parent.join(file_name); ++ Some(normalize_path_for_policy(&joined.to_string_lossy())) ++} +diff --git a/crates/libs/clawdstrike/src/guards/path_allowlist.rs b/crates/libs/clawdstrike/src/guards/path_allowlist.rs +@@ +-use super::path_normalization::normalize_path_for_policy; ++use super::path_normalization::normalize_path_for_policy_with_fs; +@@ +- let normalized = normalize_path_for_policy(path); ++ let normalized = normalize_path_for_policy_with_fs(path); +@@ +- let normalized = normalize_path_for_policy(path); ++ let normalized = normalize_path_for_policy_with_fs(path); +@@ +- let normalized = normalize_path_for_policy(path); ++ let normalized = normalize_path_for_policy_with_fs(path); +@@ ++ #[cfg(unix)] ++ #[test] ++ fn denies_symlink_escape_outside_allowlist() { ++ use std::os::unix::fs::symlink; ++ let root = std::env::temp_dir().join(format!("path-allowlist-{}", uuid::Uuid::new_v4())); ++ let allowed = root.join("allowed"); ++ let outside = root.join("outside"); ++ std::fs::create_dir_all(&allowed).unwrap(); ++ std::fs::create_dir_all(&outside).unwrap(); ++ let target = outside.join("secret.txt"); ++ std::fs::write(&target, "x").unwrap(); ++ let link = allowed.join("link.txt"); ++ symlink(&target, &link).unwrap(); ++ ++ let guard = PathAllowlistGuard::with_config(PathAllowlistConfig { ++ enabled: true, ++ file_access_allow: vec![format!("{}/allowed/**", root.display())], ++ file_write_allow: vec![format!("{}/allowed/**", root.display())], ++ patch_allow: vec![], ++ }); ++ assert!(!guard.is_file_access_allowed(link.to_str().unwrap())); ++ let _ = std::fs::remove_dir_all(&root); ++ } +diff --git a/crates/libs/clawdstrike/src/guards/forbidden_path.rs b/crates/libs/clawdstrike/src/guards/forbidden_path.rs +@@ +-use super::path_normalization::normalize_path_for_policy; ++use super::path_normalization::normalize_path_for_policy_with_fs; +@@ +- let path = normalize_path_for_policy(path); ++ let path = normalize_path_for_policy_with_fs(path); +@@ ++ #[cfg(unix)] ++ #[test] ++ fn forbids_symlink_target_when_target_matches_forbidden_pattern() { ++ use std::os::unix::fs::symlink; ++ let root = std::env::temp_dir().join(format!("forbidden-path-{}", uuid::Uuid::new_v4())); ++ let safe = root.join("safe"); ++ let forbidden = root.join("forbidden"); ++ std::fs::create_dir_all(&safe).unwrap(); ++ std::fs::create_dir_all(&forbidden).unwrap(); ++ let target = forbidden.join("secret.txt"); ++ std::fs::write(&target, "x").unwrap(); ++ let link = safe.join("link.txt"); ++ symlink(&target, &link).unwrap(); ++ ++ let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig { ++ enabled: true, ++ patterns: Some(vec![format!("{}/forbidden/**", root.display())]), ++ exceptions: vec![], ++ additional_patterns: vec![], ++ remove_patterns: vec![], ++ }); ++ assert!(guard.is_forbidden(link.to_str().unwrap())); ++ let _ = std::fs::remove_dir_all(&root); ++ } +``` + +**Patch Proposal C (CS-AUDIT-005): prune stale per-session locks** +```diff +diff --git a/crates/services/hushd/src/session/mod.rs b/crates/services/hushd/src/session/mod.rs +@@ + impl SessionManager { ++ fn remove_session_lock_if_idle(&self, session_id: &str) { ++ if let Some(entry) = self.session_locks.get(session_id) { ++ if Arc::strong_count(entry.value()) == 1 { ++ drop(entry); ++ self.session_locks.remove(session_id); ++ } ++ } ++ } ++ + fn lock_for_session_id(&self, session_id: &str) -> Arc> { + self.session_locks + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() +@@ + pub fn terminate_session(&self, session_id: &str, _reason: Option<&str>) -> Result { + let now = Utc::now().to_rfc3339(); + let updated = self.store.update( + session_id, + SessionUpdates { + terminated_at: Some(now), + ..Default::default() + }, + )?; +- Ok(updated.is_some()) ++ let terminated = updated.is_some(); ++ if terminated { ++ self.remove_session_lock_if_idle(session_id); ++ } ++ Ok(terminated) + } +@@ + #[cfg(test)] + mod tests { +@@ ++ #[tokio::test] ++ async fn terminate_session_releases_idle_lock_entry() { ++ let store = Arc::new(InMemorySessionStore::new()); ++ let manager = SessionManager::new( ++ store, ++ 60, ++ 600, ++ None, ++ SessionHardeningConfig::default(), ++ ); ++ let session = manager.create_session(test_identity(), None).unwrap(); ++ let session_id = session.session_id.clone(); ++ ++ { ++ let _guard = manager.acquire_session_lock(&session_id).await; ++ } ++ assert!(manager.session_locks.contains_key(&session_id)); ++ ++ assert!(manager.terminate_session(&session_id, None).unwrap()); ++ assert!(!manager.session_locks.contains_key(&session_id)); ++ } + } +``` + +6) **Improve Gates suggestions (only if justified)** + +1. Scope: run a short fuzz execution on PR instead of only building fuzz targets. Cost: ~4-6 minutes. Expected signal: catches parser panic/regression classes currently missed by `fuzz-check` build-only job. +CI snippet: +```yaml +- name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked --version 0.13.1 +- name: PR fuzz smoke + run: | + cd fuzz + cargo +nightly fuzz run fuzz_policy_parse -- -max_total_time=30 + cargo +nightly fuzz run fuzz_dns_parse -- -max_total_time=30 +``` + +2. Scope: targeted sanitizer job on Linux for one or two critical crates/tests, with leak detection disabled for toolchain/build-script noise. Cost: medium (nightly + extra build time). Expected signal: memory/concurrency runtime issues in critical guards without macOS LSAN false positives seen locally. +CI snippet: +```yaml +- name: ASAN smoke (targeted) + env: + RUSTFLAGS: "-Zsanitizer=address" + ASAN_OPTIONS: "detect_leaks=0" + run: cargo +nightly test -p clawdstrike --test async_guard_runtime +``` + +3. Scope: add CI test coverage for git remote-extends invariants (host allowlist on SCP/SSH + private-IP behavior parity). Cost: low. Expected signal: prevents recurrence of CS-AUDIT-001/002 class bugs early in PR. +Command: +```bash +cargo test -p hush-cli remote_extends_contract::remote_extends_git_scp_host_must_be_allowlisted +cargo test -p hushd remote_extends::tests::scp_style_git_remote_must_be_allowlisted +``` From 8741408af3cb384f04e5bd3af2576badcb8a4aa1 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Mon, 9 Feb 2026 23:55:10 -0500 Subject: [PATCH 02/16] fix(audit): remediate CS-AUDIT-001..006 --- .../clawdstrike/src/guards/forbidden_path.rs | 50 ++++- .../clawdstrike/src/guards/path_allowlist.rs | 39 +++- .../src/guards/path_normalization.rs | 42 +++- .../clawdstrike/tests/threat_intel_guards.rs | 41 +++- crates/services/hush-cli/src/hush_run.rs | 198 ++++++++++++++++-- .../services/hush-cli/src/remote_extends.rs | 99 +++++++-- crates/services/hush-cli/src/tests.rs | 55 +++++ crates/services/hushd/src/remote_extends.rs | 91 +++++++- crates/services/hushd/src/session/mod.rs | 88 +++++++- docs/audits/2026-02-10-remediation.md | 190 +++++++++++++++++ 10 files changed, 842 insertions(+), 51 deletions(-) create mode 100644 docs/audits/2026-02-10-remediation.md diff --git a/crates/libs/clawdstrike/src/guards/forbidden_path.rs b/crates/libs/clawdstrike/src/guards/forbidden_path.rs index e6e86803c..e8b5d7ce2 100644 --- a/crates/libs/clawdstrike/src/guards/forbidden_path.rs +++ b/crates/libs/clawdstrike/src/guards/forbidden_path.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use glob::Pattern; use serde::{Deserialize, Serialize}; -use super::path_normalization::normalize_path_for_policy; +use super::path_normalization::{normalize_path_for_policy, normalize_path_for_policy_with_fs}; use super::{Guard, GuardAction, GuardContext, GuardResult, Severity}; /// Configuration for ForbiddenPathGuard @@ -189,18 +189,28 @@ impl ForbiddenPathGuard { /// Check if a path is forbidden pub fn is_forbidden(&self, path: &str) -> bool { - let path = normalize_path_for_policy(path); + let lexical_path = normalize_path_for_policy(path); + let resolved_path = normalize_path_for_policy_with_fs(path); + let resolved_differs = resolved_path != lexical_path; // Check exceptions first for exception in &self.exceptions { - if exception.matches(&path) { + let exception_matches = if resolved_differs { + // If resolution changed the path (e.g., via symlink/canonical host mount aliases), + // require the exception to match the resolved target to avoid lexical bypasses. + exception.matches(&resolved_path) + } else { + exception.matches(&lexical_path) + }; + + if exception_matches { return false; } } // Check forbidden patterns for pattern in &self.patterns { - if pattern.matches(&path) { + if pattern.matches(&resolved_path) || pattern.matches(&lexical_path) { return true; } } @@ -356,4 +366,36 @@ remove_patterns: .await; assert!(result.allowed); } + + #[cfg(unix)] + #[test] + fn symlink_target_matching_forbidden_pattern_is_forbidden() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!("forbidden-path-{}", uuid::Uuid::new_v4())); + let safe_dir = root.join("safe"); + let forbidden_dir = root.join("forbidden"); + std::fs::create_dir_all(&safe_dir).expect("create safe dir"); + std::fs::create_dir_all(&forbidden_dir).expect("create forbidden dir"); + + let target = forbidden_dir.join("secret.txt"); + std::fs::write(&target, "secret").expect("write target"); + let link = safe_dir.join("link.txt"); + symlink(&target, &link).expect("create symlink"); + + let guard = ForbiddenPathGuard::with_config(ForbiddenPathConfig { + enabled: true, + patterns: Some(vec!["**/forbidden/**".to_string()]), + exceptions: vec![], + additional_patterns: vec![], + remove_patterns: vec![], + }); + + assert!( + guard.is_forbidden(link.to_str().expect("utf-8 path")), + "symlink target that resolves into forbidden path must be blocked" + ); + + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/libs/clawdstrike/src/guards/path_allowlist.rs b/crates/libs/clawdstrike/src/guards/path_allowlist.rs index 5cae41b60..31ec29553 100644 --- a/crates/libs/clawdstrike/src/guards/path_allowlist.rs +++ b/crates/libs/clawdstrike/src/guards/path_allowlist.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use glob::Pattern; use serde::{Deserialize, Serialize}; -use super::path_normalization::normalize_path_for_policy; +use super::path_normalization::normalize_path_for_policy_with_fs; use super::{Guard, GuardAction, GuardContext, GuardResult, Severity}; /// Configuration for `PathAllowlistGuard`. @@ -99,7 +99,7 @@ impl PathAllowlistGuard { if !self.enabled { return true; } - let normalized = normalize_path_for_policy(path); + let normalized = normalize_path_for_policy_with_fs(path); Self::matches_any(&self.file_access_allow, &normalized) } @@ -107,7 +107,7 @@ impl PathAllowlistGuard { if !self.enabled { return true; } - let normalized = normalize_path_for_policy(path); + let normalized = normalize_path_for_policy_with_fs(path); Self::matches_any(&self.file_write_allow, &normalized) } @@ -115,7 +115,7 @@ impl PathAllowlistGuard { if !self.enabled { return true; } - let normalized = normalize_path_for_policy(path); + let normalized = normalize_path_for_policy_with_fs(path); Self::matches_any(&self.patch_allow, &normalized) } } @@ -221,4 +221,35 @@ mod tests { assert!(guard.is_patch_allowed("/tmp/repo/src/main.rs")); assert!(!guard.is_patch_allowed("/tmp/other/src/main.rs")); } + + #[cfg(unix)] + #[test] + fn symlink_escape_outside_allowlist_is_denied() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!("path-allowlist-{}", uuid::Uuid::new_v4())); + let allowed_dir = root.join("allowed"); + let outside_dir = root.join("outside"); + std::fs::create_dir_all(&allowed_dir).expect("create allowed dir"); + std::fs::create_dir_all(&outside_dir).expect("create outside dir"); + + let target = outside_dir.join("secret.txt"); + std::fs::write(&target, "sensitive").expect("write target"); + let link = allowed_dir.join("link.txt"); + symlink(&target, &link).expect("create symlink"); + + let guard = PathAllowlistGuard::with_config(PathAllowlistConfig { + enabled: true, + file_access_allow: vec![format!("{}/allowed/**", root.display())], + file_write_allow: vec![format!("{}/allowed/**", root.display())], + patch_allow: vec![], + }); + + assert!( + !guard.is_file_access_allowed(link.to_str().expect("utf-8 path")), + "symlink target outside allowlist must be denied" + ); + + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/libs/clawdstrike/src/guards/path_normalization.rs b/crates/libs/clawdstrike/src/guards/path_normalization.rs index b622a6956..3a4671ead 100644 --- a/crates/libs/clawdstrike/src/guards/path_normalization.rs +++ b/crates/libs/clawdstrike/src/guards/path_normalization.rs @@ -1,5 +1,7 @@ //! Shared path normalization for policy path matching. +use std::path::Path; + /// Normalize a path for policy glob matching. /// /// Rules: @@ -46,9 +48,33 @@ pub fn normalize_path_for_policy(path: &str) -> String { } } +/// Normalize a path for policy matching, preferring filesystem-resolved targets when possible. +/// +/// - For existing paths, this resolves symlinks via `canonicalize`. +/// - For non-existing write targets, this resolves the parent directory and rejoins the filename. +/// - Falls back to lexical normalization when resolution is not possible. +pub fn normalize_path_for_policy_with_fs(path: &str) -> String { + resolve_path_for_policy(path).unwrap_or_else(|| normalize_path_for_policy(path)) +} + +fn resolve_path_for_policy(path: &str) -> Option { + let raw = Path::new(path); + if let Ok(canonical) = std::fs::canonicalize(raw) { + return Some(normalize_path_for_policy(&canonical.to_string_lossy())); + } + + let parent = raw.parent()?; + let canonical_parent = std::fs::canonicalize(parent).ok()?; + let candidate = match raw.file_name() { + Some(name) => canonical_parent.join(name), + None => canonical_parent, + }; + Some(normalize_path_for_policy(&candidate.to_string_lossy())) +} + #[cfg(test)] mod tests { - use super::normalize_path_for_policy; + use super::{normalize_path_for_policy, normalize_path_for_policy_with_fs}; #[test] fn normalizes_separators_and_dots() { @@ -68,4 +94,18 @@ mod tests { assert_eq!(normalize_path_for_policy("a/b/../../c"), "c"); assert_eq!(normalize_path_for_policy("../a/../b"), "../b"); } + + #[test] + fn fs_aware_normalization_uses_canonical_parent_for_new_file() { + let root = + std::env::temp_dir().join(format!("path-normalization-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create root"); + let candidate = root.join("new_file.txt"); + let normalized = normalize_path_for_policy_with_fs(candidate.to_str().expect("utf-8 path")); + assert!( + normalized.ends_with("/new_file.txt"), + "normalized path should preserve file name, got {normalized}" + ); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/libs/clawdstrike/tests/threat_intel_guards.rs b/crates/libs/clawdstrike/tests/threat_intel_guards.rs index 874e45271..7ecfdcba7 100644 --- a/crates/libs/clawdstrike/tests/threat_intel_guards.rs +++ b/crates/libs/clawdstrike/tests/threat_intel_guards.rs @@ -11,14 +11,16 @@ use clawdstrike::{GuardContext, HushEngine, Policy}; use hush_core::sha256; use tokio::net::TcpListener; -async fn serve(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); +async fn serve(app: Router) -> std::io::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); + if let Err(err) = axum::serve(listener, app).await { + eprintln!("threat_intel_guards test server exited with error: {err}"); + } }); - format!("http://{}", addr) + Ok(format!("http://{}", addr)) } #[tokio::test] @@ -57,7 +59,16 @@ async fn virustotal_file_hash_denies_and_caches() { ) .with_state(state); - let base = serve(app).await; + let base = match serve(app).await { + Ok(base) => base, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + eprintln!( + "skipping virustotal_file_hash_denies_and_caches: loopback bind denied ({err})" + ); + return; + } + Err(err) => panic!("failed to start test server: {err}"), + }; std::env::set_var("VT_API_KEY_TEST", "dummy"); std::env::set_var("VT_BASE_URL_TEST", format!("{}/api/v3", base)); @@ -111,7 +122,14 @@ async fn safe_browsing_denies_on_match() { ) }), ); - let base = serve(app).await; + let base = match serve(app).await { + Ok(base) => base, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + eprintln!("skipping safe_browsing_denies_on_match: loopback bind denied ({err})"); + return; + } + Err(err) => panic!("failed to start test server: {err}"), + }; std::env::set_var("GSB_API_KEY_TEST", "dummy"); std::env::set_var("GSB_CLIENT_ID_TEST", "clawdstrike-test"); @@ -168,7 +186,14 @@ async fn snyk_denies_on_upgradable_vulns() { ) }), ); - let base = serve(app).await; + let base = match serve(app).await { + Ok(base) => base, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + eprintln!("skipping snyk_denies_on_upgradable_vulns: loopback bind denied ({err})"); + return; + } + Err(err) => panic!("failed to start test server: {err}"), + }; std::env::set_var("SNYK_API_TOKEN_TEST", "dummy"); std::env::set_var("SNYK_ORG_ID_TEST", "org-123"); diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index 347836f0c..a983ba50b 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -3,7 +3,7 @@ use std::io::Write; use std::net::IpAddr; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -25,6 +25,9 @@ use crate::policy_event::{ use crate::remote_extends; use crate::ExitCode; +const EVENT_QUEUE_CAPACITY: usize = 1024; +const PROXY_MAX_IN_FLIGHT_CONNECTIONS: usize = 256; + #[derive(Clone, Debug)] struct RunOutcome { // 0 = ok, 1 = warn, 2 = fail @@ -117,6 +120,36 @@ impl HushdForwarder { } } +#[derive(Clone, Debug)] +struct EventEmitter { + tx: mpsc::Sender, + dropped_full: Arc, +} + +impl EventEmitter { + fn new(tx: mpsc::Sender) -> Self { + Self { + tx, + dropped_full: Arc::new(AtomicUsize::new(0)), + } + } + + fn emit(&self, event: PolicyEvent) { + if let Err(err) = self.tx.try_send(event) { + match err { + mpsc::error::TrySendError::Full(_) => { + self.dropped_full.fetch_add(1, Ordering::Relaxed); + } + mpsc::error::TrySendError::Closed(_) => {} + } + } + } + + fn dropped_count(&self) -> usize { + self.dropped_full.load(Ordering::Relaxed) + } +} + #[derive(Clone, Debug)] pub struct RunArgs { pub policy: String, @@ -196,7 +229,8 @@ pub async fn cmd_run( let events_path = PathBuf::from(&events_out); let receipt_path = PathBuf::from(&receipt_out); - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + let (event_tx, mut event_rx) = mpsc::channel::(EVENT_QUEUE_CAPACITY); + let event_emitter = EventEmitter::new(event_tx); let writer_forwarder = forwarder.clone(); let writer_handle = tokio::spawn(async move { @@ -232,11 +266,12 @@ pub async fn cmd_run( metadata: None, context: None, }; - let _ = event_tx.send(command_event); + event_emitter.emit(command_event); let outcome = RunOutcome::new(); let mut env_proxy_url = None; + let mut proxy_rejected_connections: Option> = None; let proxy_handle = if no_proxy { None } else { @@ -244,14 +279,16 @@ pub async fn cmd_run( proxy_port, engine.clone(), base_context.clone(), - event_tx.clone(), + event_emitter.clone(), outcome.clone(), + PROXY_MAX_IN_FLIGHT_CONNECTIONS, stderr, ) .await { - Ok((listen_url, handle)) => { + Ok((listen_url, handle, rejected_connections)) => { env_proxy_url = Some(listen_url); + proxy_rejected_connections = Some(rejected_connections); Some(handle) } Err(e) => { @@ -281,7 +318,7 @@ pub async fn cmd_run( Ok(status) => status, Err(e) => { let _ = writeln!(stderr, "Error: {}", e); - drop(event_tx); + drop(event_emitter); let _ = writer_handle.await; if let Some(h) = proxy_handle { h.abort(); @@ -310,8 +347,21 @@ pub async fn cmd_run( "proxy".to_string(), serde_json::Value::Bool(env_proxy_url.is_some()), ); + let dropped_events = event_emitter.dropped_count(); + extra.insert( + "droppedEventCount".to_string(), + serde_json::Value::Number((dropped_events as u64).into()), + ); + let rejected_proxy_connections = proxy_rejected_connections + .as_ref() + .map(|count| count.load(Ordering::Relaxed)) + .unwrap_or(0); + extra.insert( + "proxyRejectedConnections".to_string(), + serde_json::Value::Number((rejected_proxy_connections as u64).into()), + ); - let _ = event_tx.send(PolicyEvent { + event_emitter.emit(PolicyEvent { event_id: Uuid::new_v4().to_string(), event_type: PolicyEventType::Custom, timestamp: Utc::now(), @@ -329,7 +379,7 @@ pub async fn cmd_run( h.abort(); } - drop(event_tx); + drop(event_emitter); match writer_handle.await { Ok(Ok(())) => {} Ok(Err(e)) => { @@ -339,6 +389,20 @@ pub async fn cmd_run( let _ = writeln!(stderr, "Warning: event writer task failed: {}", e); } } + if dropped_events > 0 { + let _ = writeln!( + stderr, + "Warning: dropped {} policy events because the event queue is full (capacity={})", + dropped_events, EVENT_QUEUE_CAPACITY + ); + } + if rejected_proxy_connections > 0 { + let _ = writeln!( + stderr, + "Warning: rejected {} proxy connections due to in-flight limit ({})", + rejected_proxy_connections, PROXY_MAX_IN_FLIGHT_CONNECTIONS + ); + } let events_bytes = match tokio::fs::read(&events_out).await { Ok(b) => b, @@ -668,10 +732,11 @@ async fn start_connect_proxy( port: u16, engine: Arc, context: GuardContext, - event_tx: mpsc::UnboundedSender, + event_emitter: EventEmitter, outcome: RunOutcome, + max_in_flight_connections: usize, stderr: &mut dyn Write, -) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> { +) -> anyhow::Result<(String, tokio::task::JoinHandle<()>, Arc)> { let listener = TcpListener::bind(("127.0.0.1", port)) .await .context("bind proxy listener")?; @@ -680,33 +745,49 @@ async fn start_connect_proxy( let url = format!("http://127.0.0.1:{}", local.port()); let _ = writeln!(stderr, "Proxy listening on {}", url); + let rejected_connections = Arc::new(AtomicUsize::new(0)); + let in_flight = Arc::new(tokio::sync::Semaphore::new(max_in_flight_connections)); + let rejected_connections_for_loop = rejected_connections.clone(); let handle = tokio::spawn(async move { loop { - let (socket, _) = match listener.accept().await { + let (mut socket, _) = match listener.accept().await { Ok(v) => v, Err(_) => return, }; + let permit = match in_flight.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + rejected_connections_for_loop.fetch_add(1, Ordering::Relaxed); + let _ = socket + .write_all(b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n") + .await; + continue; + } + }; + let engine = engine.clone(); let context = context.clone(); - let event_tx = event_tx.clone(); + let event_emitter = event_emitter.clone(); let outcome = outcome.clone(); tokio::spawn(async move { + let _permit = permit; let _ = - handle_connect_proxy_client(socket, engine, context, event_tx, outcome).await; + handle_connect_proxy_client(socket, engine, context, event_emitter, outcome) + .await; }); } }); - Ok((url, handle)) + Ok((url, handle, rejected_connections)) } async fn handle_connect_proxy_client( mut client: TcpStream, engine: Arc, context: GuardContext, - event_tx: mpsc::UnboundedSender, + event_emitter: EventEmitter, outcome: RunOutcome, ) -> anyhow::Result<()> { let header = read_http_header(&mut client, 8 * 1024) @@ -761,7 +842,7 @@ async fn handle_connect_proxy_client( outcome.observe_guard_result(&result); - let _ = event_tx.send(network_event( + event_emitter.emit(network_event( &context, host_for_policy.clone(), connect_port, @@ -960,6 +1041,22 @@ fn generate_bwrap_args(workspace: &Path) -> Vec { #[cfg(test)] mod tests { use super::*; + use clawdstrike::Policy; + + fn test_custom_event(id: usize) -> PolicyEvent { + PolicyEvent { + event_id: format!("event-{id}"), + event_type: PolicyEventType::Custom, + timestamp: Utc::now(), + session_id: Some("session-test".to_string()), + data: PolicyEventData::Custom(CustomEventData { + custom_type: "test_event".to_string(), + extra: serde_json::Map::new(), + }), + metadata: None, + context: None, + } + } #[test] #[cfg(target_os = "macos")] @@ -1028,4 +1125,73 @@ guards: assert!(joined.contains("--bind /work/project /work/project")); assert!(joined.contains("--tmpfs /tmp")); } + + #[test] + fn event_emitter_drops_events_when_queue_is_full() { + let (tx, mut rx) = mpsc::channel::(2); + let emitter = EventEmitter::new(tx); + + for i in 0..10 { + emitter.emit(test_custom_event(i)); + } + + assert_eq!(emitter.dropped_count(), 8); + + let mut queued = 0usize; + while rx.try_recv().is_ok() { + queued += 1; + } + assert_eq!(queued, 2, "queue must stay bounded at channel capacity"); + } + + #[tokio::test] + async fn proxy_rejects_connections_when_in_flight_limit_is_reached() { + let policy_yaml = r#" +version: "1.1.0" +name: "proxy-limit" +"#; + let policy = Policy::from_yaml(policy_yaml).expect("policy"); + let engine = Arc::new(HushEngine::builder(policy).build().expect("engine")); + let context = GuardContext::new().with_session_id("session-1"); + let (tx, _rx) = mpsc::channel::(32); + let emitter = EventEmitter::new(tx); + let outcome = RunOutcome::new(); + let mut stderr = Vec::::new(); + + let (url, handle, rejected_counter) = + match start_connect_proxy(0, engine, context, emitter, outcome, 1, &mut stderr).await { + Ok(v) => v, + Err(err) => { + if err.to_string().contains("Permission denied") { + eprintln!("skipping proxy limit test: {}", err); + return; + } + panic!("failed to start proxy: {err}"); + } + }; + + let addr = url.trim_start_matches("http://"); + let mut first = TcpStream::connect(addr).await.expect("first connect"); + tokio::time::sleep(Duration::from_millis(100)).await; + + let mut second = TcpStream::connect(addr).await.expect("second connect"); + let mut buf = [0u8; 128]; + let read_result = tokio::time::timeout(Duration::from_secs(2), second.read(&mut buf)) + .await + .expect("read timeout") + .expect("read"); + let response = String::from_utf8_lossy(&buf[..read_result]).to_string(); + assert!( + response.contains("503 Service Unavailable"), + "expected 503 when proxy is saturated, got: {response}" + ); + assert!( + rejected_counter.load(Ordering::Relaxed) >= 1, + "rejected connection counter must increment when limit is reached" + ); + + let _ = first.shutdown().await; + let _ = second.shutdown().await; + handle.abort(); + } } diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs index fdaa36e6f..93c0d442a 100644 --- a/crates/services/hush-cli/src/remote_extends.rs +++ b/crates/services/hush-cli/src/remote_extends.rs @@ -130,6 +130,29 @@ impl RemotePolicyResolver { Ok(()) } + fn ensure_git_host_ip_policy(&self, host: &str) -> Result<()> { + if self.cfg.allow_private_ips { + return Ok(()); + } + + let addrs = resolve_host_addrs(host, 9418)?; + if addrs.is_empty() { + return Err(Error::ConfigError(format!( + "Remote extends host resolved to no addresses: {}", + host + ))); + } + + if addrs.iter().any(|addr| !is_public_ip(addr.ip())) { + return Err(Error::ConfigError(format!( + "Remote extends host resolved to non-public IPs (blocked): {}", + host + ))); + } + + Ok(()) + } + fn resolve_http(&self, reference: &str, base: Option<&str>) -> Result { if !self.cfg.remote_enabled() { return Err(Error::ConfigError( @@ -349,20 +372,9 @@ impl RemotePolicyResolver { )); } - if let Ok(repo_url) = Url::parse(repo) { - if matches!(repo_url.scheme(), "http" | "https") { - if self.cfg.https_only && repo_url.scheme() != "https" { - return Err(Error::ConfigError(format!( - "Remote extends require https:// URLs (got {}://)", - repo_url.scheme() - ))); - } - let host = repo_url.host_str().ok_or_else(|| { - Error::ConfigError(format!("Invalid URL host in remote extends: {}", repo)) - })?; - self.ensure_host_allowed(host)?; - } - } + let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?; + self.ensure_host_allowed(&repo_host)?; + self.ensure_git_host_ip_policy(&repo_host)?; if !self.cfg.remote_enabled() { return Err(Error::ConfigError( @@ -566,6 +578,65 @@ fn parse_remote_url(url: &str, https_only: bool) -> std::result::Result Result { + if let Ok(repo_url) = Url::parse(repo) { + let scheme = repo_url.scheme(); + if !matches!(scheme, "http" | "https" | "ssh" | "git") { + return Err(Error::ConfigError(format!( + "Unsupported git remote scheme for remote extends: {}", + scheme + ))); + } + if https_only && scheme == "http" { + return Err(Error::ConfigError(format!( + "Remote extends require https:// URLs (got {}://)", + scheme + ))); + } + + let host = repo_url.host_str().ok_or_else(|| { + Error::ConfigError(format!("Invalid URL host in remote extends: {}", repo)) + })?; + return Ok(normalize_host(host)); + } + + parse_scp_like_git_host(repo).ok_or_else(|| { + Error::ConfigError(format!( + "Invalid git remote in remote extends (expected URL or scp-style host:path): {}", + repo + )) + }) +} + +fn parse_scp_like_git_host(repo: &str) -> Option { + let (lhs, rhs) = repo.split_once(':')?; + if rhs.is_empty() { + return None; + } + if lhs.contains('/') || lhs.contains('\\') { + return None; + } + + let host = lhs.rsplit_once('@').map(|(_, host)| host).unwrap_or(lhs); + let host = normalize_host(host); + if host.is_empty() { + None + } else { + Some(host) + } +} + +fn resolve_host_addrs(host: &str, port: u16) -> Result> { + if let Ok(ip) = host.parse::() { + return Ok(vec![SocketAddr::new(ip, port)]); + } + + (host, port) + .to_socket_addrs() + .map(|addrs| addrs.collect()) + .map_err(|e| Error::ConfigError(format!("Failed to resolve host {}: {}", host, e))) +} + fn join_url(base: &str, reference: &str) -> Result { if reference.starts_with("http://") || reference.starts_with("https://") { return Ok(reference.to_string()); diff --git a/crates/services/hush-cli/src/tests.rs b/crates/services/hush-cli/src/tests.rs index f272e05df..1daa202ab 100644 --- a/crates/services/hush-cli/src/tests.rs +++ b/crates/services/hush-cli/src/tests.rs @@ -2824,6 +2824,7 @@ mod remote_extends_contract { use std::thread::JoinHandle; use std::time::Duration; + use clawdstrike::policy::{PolicyLocation, PolicyResolver}; use clawdstrike::Policy; use hush_core::sha256; @@ -3034,6 +3035,60 @@ extends: {}#sha256={} ); } + #[test] + fn remote_extends_git_scp_host_must_be_allowlisted() { + let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!( + "git+git@evil.example:org/repo.git@deadbeef:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("SCP-style disallowed host should be rejected before fetch"); + assert!( + err.to_string().contains("allowlisted"), + "unexpected error: {err}" + ); + } + + #[test] + fn remote_extends_git_file_scheme_is_rejected() { + let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!( + "git+file:///tmp/repo.git@deadbeef:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("file:// git remote should be rejected"); + assert!( + err.to_string() + .contains("Unsupported git remote scheme for remote extends"), + "unexpected error: {err}" + ); + } + + #[test] + fn remote_extends_git_private_ip_blocked_when_disallowed() { + let cfg = RemoteExtendsConfig::new(["127.0.0.1".to_string()]) + .with_https_only(false) + .with_allow_private_ips(false); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!( + "git+ssh://127.0.0.1/repo.git@deadbeef:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("private IP git remote should be rejected"); + assert!( + err.to_string().contains("non-public IPs"), + "unexpected error: {err}" + ); + } + #[test] fn remote_extends_resolves_relative_urls() { let nested = br#" diff --git a/crates/services/hushd/src/remote_extends.rs b/crates/services/hushd/src/remote_extends.rs index 42c847cb4..3e033f425 100644 --- a/crates/services/hushd/src/remote_extends.rs +++ b/crates/services/hushd/src/remote_extends.rs @@ -100,6 +100,29 @@ impl RemotePolicyResolver { Ok(()) } + fn ensure_git_host_ip_policy(&self, host: &str) -> Result<()> { + if self.cfg.allow_private_ips { + return Ok(()); + } + + let addrs = resolve_host_addrs(host, 9418)?; + if addrs.is_empty() { + return Err(Error::ConfigError(format!( + "Remote extends host resolved to no addresses: {}", + host + ))); + } + + if addrs.iter().any(|addr| !is_public_ip(addr.ip())) { + return Err(Error::ConfigError(format!( + "Remote extends host resolved to non-public IPs (blocked): {}", + host + ))); + } + + Ok(()) + } + fn validate_and_resolve_http_target( &self, url: &Url, @@ -325,8 +348,9 @@ impl RemotePolicyResolver { )); } - let repo_host = parse_git_remote_host(repo)?; + let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?; self.ensure_host_allowed(&repo_host)?; + self.ensure_git_host_ip_policy(&repo_host)?; let key = format!("git:{}@{}:{}#sha256={}", repo, commit, path, expected_sha); let cache_path = self.cache_path_for(&key, "yaml"); @@ -524,7 +548,7 @@ fn parse_remote_url(url: &str, https_only: bool) -> std::result::Result Result { +fn parse_git_remote_host(repo: &str, https_only: bool) -> Result { if let Ok(repo_url) = Url::parse(repo) { let scheme = repo_url.scheme(); if !matches!(scheme, "http" | "https" | "ssh" | "git") { @@ -533,6 +557,12 @@ fn parse_git_remote_host(repo: &str) -> Result { scheme ))); } + if https_only && scheme == "http" { + return Err(Error::ConfigError(format!( + "Remote extends require https:// URLs (got {}://)", + scheme + ))); + } let host = repo_url.host_str().ok_or_else(|| { Error::ConfigError(format!("Invalid URL host in remote extends: {}", repo)) })?; @@ -565,6 +595,17 @@ fn parse_scp_like_git_host(repo: &str) -> Option { } } +fn resolve_host_addrs(host: &str, port: u16) -> Result> { + if let Ok(ip) = host.parse::() { + return Ok(vec![SocketAddr::new(ip, port)]); + } + + (host, port) + .to_socket_addrs() + .map(|addrs| addrs.collect()) + .map_err(|e| Error::ConfigError(format!("Failed to resolve host {}: {}", host, e))) +} + fn join_url(base: &str, reference: &str) -> Result { if reference.starts_with("http://") || reference.starts_with("https://") { return Ok(reference.to_string()); @@ -923,11 +964,21 @@ mod tests { #[test] fn parse_git_remote_host_accepts_scp_style() { - let host = parse_git_remote_host("git@github.com:backbay-labs/clawdstrike.git") + let host = parse_git_remote_host("git@github.com:backbay-labs/clawdstrike.git", true) .expect("scp-like git remote should parse"); assert_eq!(host, "github.com"); } + #[test] + fn parse_git_remote_host_rejects_unsupported_scheme() { + let err = parse_git_remote_host("file:///tmp/repo.git", true).expect_err("must reject"); + assert!( + err.to_string() + .contains("Unsupported git remote scheme for remote extends"), + "unexpected error: {err}" + ); + } + #[test] fn scp_style_git_remote_must_be_allowlisted() { let cache_dir = std::env::temp_dir().join(format!( @@ -1092,6 +1143,40 @@ mod tests { let _ = std::fs::remove_dir_all(&cache_dir); } + #[test] + fn private_ip_git_remote_is_blocked_by_default() { + let cache_dir = std::env::temp_dir().join(format!( + "hushd-remote-extends-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&cache_dir).expect("create cache dir"); + + let cfg = RemoteExtendsResolverConfig { + allowed_hosts: ["127.0.0.1".to_string()].into_iter().collect(), + cache_dir: cache_dir.clone(), + https_only: false, + allow_private_ips: false, + allow_cross_host_redirects: false, + max_fetch_bytes: 1024 * 1024, + max_cache_bytes: 1024 * 1024, + }; + let resolver = RemotePolicyResolver::new(cfg).expect("create resolver"); + + let reference = format!( + "git+ssh://127.0.0.1/repo.git@deadbeef:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("private IP git remotes should be rejected by default"); + assert!( + err.to_string().contains("non-public IPs"), + "expected private-IP rejection, got: {err}" + ); + + let _ = std::fs::remove_dir_all(&cache_dir); + } + #[test] fn allow_private_ips_allows_fetching_localhost() { let (port, _calls, stop, handle) = spawn_server(|stream| { diff --git a/crates/services/hushd/src/session/mod.rs b/crates/services/hushd/src/session/mod.rs index bc9875596..d10ce2996 100644 --- a/crates/services/hushd/src/session/mod.rs +++ b/crates/services/hushd/src/session/mod.rs @@ -409,6 +409,26 @@ impl SessionManager { .clone() } + fn remove_session_lock_if_idle(&self, session_id: &str) { + if let Some(entry) = self.session_locks.get(session_id) { + if Arc::strong_count(entry.value()) == 1 { + drop(entry); + self.session_locks.remove(session_id); + } + } + } + + fn prune_idle_session_locks(&self) { + let keys: Vec = self + .session_locks + .iter() + .map(|entry| entry.key().clone()) + .collect(); + for session_id in keys { + self.remove_session_lock_if_idle(&session_id); + } + } + pub async fn acquire_session_lock(&self, session_id: &str) -> tokio::sync::OwnedMutexGuard<()> { self.lock_for_session_id(session_id).lock_owned().await } @@ -629,7 +649,11 @@ impl SessionManager { ..Default::default() }, )?; - Ok(updated.is_some()) + let terminated = updated.is_some(); + if terminated { + self.remove_session_lock_if_idle(session_id); + } + Ok(terminated) } pub fn terminate_sessions_for_user(&self, user_id: &str, reason: Option<&str>) -> Result { @@ -640,6 +664,7 @@ impl SessionManager { count = count.saturating_add(1); } } + self.prune_idle_session_locks(); Ok(count) } @@ -992,4 +1017,65 @@ mod tests { ); assert_eq!(restored.transition_history.len(), 1); } + + #[test] + fn terminate_session_removes_idle_lock_entry() { + let store = Arc::new(InMemorySessionStore::new()); + let manager = + SessionManager::new(store, 3600, 86_400, None, SessionHardeningConfig::default()); + + let session = manager + .create_session(test_identity(), None) + .expect("create"); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + rt.block_on(async { + let _guard = manager.acquire_session_lock(&session.session_id).await; + }); + + assert!( + manager.session_locks.contains_key(&session.session_id), + "lock entry should exist after lock acquisition" + ); + assert!( + manager + .terminate_session(&session.session_id, Some("test")) + .expect("terminate"), + "session should terminate" + ); + assert!( + !manager.session_locks.contains_key(&session.session_id), + "idle lock entry should be removed after termination" + ); + } + + #[test] + fn lock_table_does_not_grow_under_session_churn() { + let store = Arc::new(InMemorySessionStore::new()); + let manager = + SessionManager::new(store, 3600, 86_400, None, SessionHardeningConfig::default()); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + for _ in 0..32 { + let session = manager + .create_session(test_identity(), None) + .expect("create"); + rt.block_on(async { + let _guard = manager.acquire_session_lock(&session.session_id).await; + }); + assert!( + manager + .terminate_session(&session.session_id, Some("churn")) + .expect("terminate"), + "session should terminate" + ); + } + + manager.prune_idle_session_locks(); + assert_eq!( + manager.session_locks.len(), + 0, + "session lock table should be fully pruned after terminate churn" + ); + } } diff --git a/docs/audits/2026-02-10-remediation.md b/docs/audits/2026-02-10-remediation.md new file mode 100644 index 000000000..f77a3e1f6 --- /dev/null +++ b/docs/audits/2026-02-10-remediation.md @@ -0,0 +1,190 @@ +# Clawdstrike Audit Remediation (2026-02-10) + +## Tracking Checklist +- [x] CS-AUDIT-001 (High) git remote host allowlist bypass for non-HTTP remotes (SCP/ssh/git) +- [x] CS-AUDIT-002 (Medium) allow_private_ips=false inconsistent for git remote extends +- [x] CS-AUDIT-003 (High) path guards bypassable via symlink traversal (lexical-only normalization) +- [x] CS-AUDIT-004 (Medium) hush run unbounded channel + task fanout causes unbounded memory growth +- [x] CS-AUDIT-005 (Medium) hushd session lock DashMap grows unbounded (no pruning) +- [x] CS-AUDIT-006 (Low) threat_intel_guards test harness unwrap() panics on loopback bind denial + +## A) Summary +On 2026-02-10, the Rust security/correctness audit findings CS-AUDIT-001 through CS-AUDIT-006 were remediated across `clawdstrike`, `hush-cli`, and `hushd`. The fixes enforce host/IP policy invariants for git remote extends, close symlink-based path guard bypasses, bound the hush event/proxy pipeline under load, add lock lifecycle pruning in session management, and harden threat-intel tests to avoid panic in restricted loopback environments. + +## B) Per-issue closure evidence + +### CS-AUDIT-001 — git remote host allowlist bypass for non-HTTP remotes +Bug/invariant: git remote extends must enforce host allowlist for URL-style (`ssh://`, `git://`) and SCP-style (`git@host:path`) remotes before any fetch operation. + +Fix approach: +- Added git remote host parsing for URL and SCP forms. +- Enforced allowlist checks on parsed git hosts in resolver path before `git fetch`. +- Rejected unsupported git remote schemes (e.g., `file://`) explicitly. + +Code pointers: +- `crates/services/hush-cli/src/remote_extends.rs:356` (`resolve_git_absolute` pre-fetch host validation) +- `crates/services/hush-cli/src/remote_extends.rs:581` (`parse_git_remote_host`) +- `crates/services/hush-cli/src/remote_extends.rs:611` (`parse_scp_like_git_host`) +- `crates/services/hushd/src/remote_extends.rs:326` (`resolve_git_absolute` parity) +- `crates/services/hushd/src/remote_extends.rs:551` (`parse_git_remote_host`) + +New/updated tests: +- `remote_extends_git_scp_host_must_be_allowlisted` denies SCP host outside allowlist. +- `remote_extends_git_file_scheme_is_rejected` rejects `git+file://...`. +- `scp_style_git_remote_must_be_allowlisted` verifies daemon resolver parity. +- `parse_git_remote_host_rejects_unsupported_scheme` verifies unsupported scheme rejection. + +Proof commands: +```bash +cargo test -p hush-cli remote_extends_contract::remote_extends_git_scp_host_must_be_allowlisted -- --nocapture +cargo test -p hush-cli remote_extends_contract::remote_extends_git_file_scheme_is_rejected -- --nocapture +cargo test -p hushd remote_extends::tests::scp_style_git_remote_must_be_allowlisted -- --nocapture +cargo test -p hushd remote_extends::tests::parse_git_remote_host_rejects_unsupported_scheme -- --nocapture +``` +Expected output: each command reports `test result: ok` with `1 passed; 0 failed` for the selected test. + +### CS-AUDIT-002 — allow_private_ips=false inconsistent for git remote extends +Bug/invariant: `allow_private_ips=false` must block private/loopback/link-local targets for git remote extends with the same behavior as HTTP extends. + +Fix approach: +- Added git host resolution path (`resolve_host_addrs`) and non-public IP rejection check (`ensure_git_host_ip_policy`) for git remotes. +- Applied policy in both CLI and daemon remote resolvers. +- Preserved `https_only` behavior for URL-style git remotes where applicable. + +Code pointers: +- `crates/services/hush-cli/src/remote_extends.rs:133` (`ensure_git_host_ip_policy`) +- `crates/services/hush-cli/src/remote_extends.rs:629` (`resolve_host_addrs`) +- `crates/services/hushd/src/remote_extends.rs:103` (`ensure_git_host_ip_policy`) +- `crates/services/hushd/src/remote_extends.rs:598` (`resolve_host_addrs`) + +New/updated tests: +- `remote_extends_git_private_ip_blocked_when_disallowed` (CLI) rejects `ssh://127.0.0.1/...` git remote. +- `private_ip_git_remote_is_blocked_by_default` (daemon) rejects private git remote with default policy. + +Proof commands: +```bash +cargo test -p hush-cli remote_extends_contract::remote_extends_git_private_ip_blocked_when_disallowed -- --nocapture +cargo test -p hushd remote_extends::tests::private_ip_git_remote_is_blocked_by_default -- --nocapture +``` +Expected output: both commands report `test result: ok` and no fetch attempt succeeds for private targets. + +### CS-AUDIT-003 — path guards bypassable via symlink traversal +Bug/invariant: path allowlist and forbidden-path decisions must evaluate effective filesystem target (resolved path), not only lexical path. + +Fix approach: +- Added filesystem-aware normalization: canonicalize existing paths; for non-existing write targets canonicalize parent and rejoin filename. +- Path allowlist guard now matches against filesystem-aware normalized path. +- Forbidden path guard now evaluates both lexical and resolved paths; exceptions are resolved-target aware when canonicalization changes the target. + +Code pointers: +- `crates/libs/clawdstrike/src/guards/path_normalization.rs:56` (`normalize_path_for_policy_with_fs`) +- `crates/libs/clawdstrike/src/guards/path_allowlist.rs:98` (`is_file_access_allowed`, `is_file_write_allowed`, `is_patch_allowed`) +- `crates/libs/clawdstrike/src/guards/forbidden_path.rs:191` (`is_forbidden` uses lexical + resolved checks) + +New/updated tests: +- `symlink_escape_outside_allowlist_is_denied` ensures allowlisted symlink escaping outside scope is blocked. +- `symlink_target_matching_forbidden_pattern_is_forbidden` ensures forbidden target reached via symlink is still blocked. +- `fs_aware_normalization_uses_canonical_parent_for_new_file` covers non-existing write-target normalization. + +Proof commands: +```bash +cargo test -p clawdstrike symlink_escape_outside_allowlist_is_denied --lib -- --nocapture +cargo test -p clawdstrike symlink_target_matching_forbidden_pattern_is_forbidden --lib -- --nocapture +cargo test -p clawdstrike fs_aware_normalization_uses_canonical_parent_for_new_file --lib -- --nocapture +``` +Expected output: all tests pass and confirm symlink-based bypass cases are denied. + +Remaining TOCTOU limitation and mitigation: +- A post-check symlink swap is still theoretically possible in any path-check-then-open model. +- Mitigation here is to canonicalize at guard evaluation time and require resolved-target matching for exceptions, reducing lexical-only bypasses without widening allow rules. + +### CS-AUDIT-004 — hush run unbounded channel + task fanout memory growth +Bug/invariant: telemetry/event and proxy handling must remain bounded under adversarial flood; no unbounded queue growth. + +Fix approach: +- Replaced unbounded event channel with bounded `tokio::mpsc::channel`. +- Added `EventEmitter` drop-on-full behavior using `try_send` and atomic dropped-event counter. +- Added proxy in-flight semaphore cap; saturated connections receive `503` and increment rejection counter. +- Exposed counters in run-end metadata and warning logs. + +Code pointers: +- `crates/services/hush-cli/src/hush_run.rs:28` (`EVENT_QUEUE_CAPACITY`, `PROXY_MAX_IN_FLIGHT_CONNECTIONS`) +- `crates/services/hush-cli/src/hush_run.rs:124` (`EventEmitter` bounded emission/drop counter) +- `crates/services/hush-cli/src/hush_run.rs:232` (bounded channel creation) +- `crates/services/hush-cli/src/hush_run.rs:350` (`droppedEventCount` / `proxyRejectedConnections` metadata) +- `crates/services/hush-cli/src/hush_run.rs:731` (`start_connect_proxy` in-flight semaphore and 503 behavior) + +New/updated tests: +- `event_emitter_drops_events_when_queue_is_full` verifies bounded queue and drop counting. +- `proxy_rejects_connections_when_in_flight_limit_is_reached` verifies saturated proxy returns 503 and increments rejection counter. + +Proof commands: +```bash +cargo test -p hush-cli event_emitter_drops_events_when_queue_is_full -- --nocapture +cargo test -p hush-cli proxy_rejects_connections_when_in_flight_limit_is_reached -- --nocapture +``` +Expected output: both tests pass; queue occupancy remains bounded and proxy saturation is observable. + +### CS-AUDIT-005 — hushd session lock DashMap grows unbounded +Bug/invariant: per-session lock table must not grow monotonically after session termination/churn. + +Fix approach: +- Added idle lock removal function based on `Arc` strong count. +- Added pruning method across lock-table keys. +- Invoked lock cleanup on `terminate_session` and prune pass after `terminate_sessions_for_user`. + +Code pointers: +- `crates/services/hushd/src/session/mod.rs:412` (`remove_session_lock_if_idle`) +- `crates/services/hushd/src/session/mod.rs:421` (`prune_idle_session_locks`) +- `crates/services/hushd/src/session/mod.rs:643` (`terminate_session` cleanup hook) +- `crates/services/hushd/src/session/mod.rs:659` (`terminate_sessions_for_user` prune hook) + +New/updated tests: +- `terminate_session_removes_idle_lock_entry` verifies lock entry cleanup on termination. +- `lock_table_does_not_grow_under_session_churn` verifies map does not grow under repeated create/lock/terminate cycles. + +Proof commands: +```bash +cargo test -p hushd session::tests::terminate_session_removes_idle_lock_entry -- --nocapture +cargo test -p hushd session::tests::lock_table_does_not_grow_under_session_churn -- --nocapture +``` +Expected output: both tests pass and final lock-table length is zero in churn test. + +### CS-AUDIT-006 — threat_intel_guards unwrap panic on loopback bind denial +Bug/invariant: threat-intel integration tests must not panic due to loopback bind denial in restricted CI/sandbox environments. + +Fix approach: +- Changed test server helper to return `std::io::Result` instead of unwrapping bind/start failures. +- Added graceful per-test handling: skip on `PermissionDenied`, panic only for unexpected errors. +- Removed panic-on-spawn behavior in server task path. + +Code pointers: +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:14` (`serve` now returns `Result`) +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:62` (skip handling in VT test) +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:125` (skip handling in GSB test) +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:189` (skip handling in Snyk test) + +New/updated tests: +- Existing tests (`virustotal_file_hash_denies_and_caches`, `safe_browsing_denies_on_match`, `snyk_denies_on_upgradable_vulns`) now degrade gracefully instead of panicking when loopback bind is denied. + +Proof commands: +```bash +cargo test -p clawdstrike --test threat_intel_guards -- --nocapture +``` +Expected output: suite reports `ok`; in restricted environments, individual tests print explicit skip reason instead of panicking. + +## C) Full gate run evidence +Commands executed: +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --workspace +``` + +Results summary: +- `cargo fmt --all -- --check`: pass. +- `cargo clippy --all-targets --all-features -- -D warnings`: pass. +- `cargo test --workspace`: pass (all unit/integration/doc tests across workspace passed; no failing tests). + +Skipped gates: +- None. From a0d2c125e4de0ed8887f76bcd474994a262c649c Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 00:16:36 -0500 Subject: [PATCH 03/16] fix(hush-cli): harden CONNECT proxy policy/resource bounds --- crates/services/hush-cli/src/hush_run.rs | 386 +++++++++++++++++++---- 1 file changed, 330 insertions(+), 56 deletions(-) diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index a983ba50b..620a99f63 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -12,7 +12,7 @@ use chrono::Utc; use clawdstrike::{GuardContext, GuardResult, HushEngine, Severity}; use hush_core::{sha256, Keypair, PublicKey, Receipt, SignedReceipt, Signer, Verdict}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::net::{lookup_host, TcpListener, TcpStream}; use tokio::process::Command; use tokio::sync::mpsc; use uuid::Uuid; @@ -27,6 +27,9 @@ use crate::ExitCode; const EVENT_QUEUE_CAPACITY: usize = 1024; const PROXY_MAX_IN_FLIGHT_CONNECTIONS: usize = 256; +const PROXY_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(5); +const PROXY_TLS_SNI_TIMEOUT: Duration = Duration::from_secs(3); +const HUSHD_FORWARD_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Clone, Debug)] struct RunOutcome { @@ -98,10 +101,27 @@ struct HushdForwarder { impl HushdForwarder { fn new(base_url: String, token: Option) -> Self { + let client = reqwest::Client::builder() + .timeout(HUSHD_FORWARD_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); Self { base_url: base_url.trim_end_matches('/').to_string(), token, - client: reqwest::Client::new(), + client, + } + } + + #[cfg(test)] + fn new_with_timeout(base_url: String, token: Option, timeout: Duration) -> Self { + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + base_url: base_url.trim_end_matches('/').to_string(), + token, + client, } } @@ -109,7 +129,8 @@ impl HushdForwarder { let mut req = self .client .post(format!("{}/api/v1/eval", self.base_url)) - .json(event); + .json(event) + .timeout(HUSHD_FORWARD_TIMEOUT); if let Some(token) = self.token.as_ref() { req = req.bearer_auth(token); @@ -282,6 +303,7 @@ pub async fn cmd_run( event_emitter.clone(), outcome.clone(), PROXY_MAX_IN_FLIGHT_CONNECTIONS, + PROXY_HEADER_READ_TIMEOUT, stderr, ) .await @@ -728,6 +750,7 @@ async fn spawn_and_wait_child( Ok(status) } +#[allow(clippy::too_many_arguments)] async fn start_connect_proxy( port: u16, engine: Arc, @@ -735,6 +758,7 @@ async fn start_connect_proxy( event_emitter: EventEmitter, outcome: RunOutcome, max_in_flight_connections: usize, + header_read_timeout: Duration, stderr: &mut dyn Write, ) -> anyhow::Result<(String, tokio::task::JoinHandle<()>, Arc)> { let listener = TcpListener::bind(("127.0.0.1", port)) @@ -773,9 +797,15 @@ async fn start_connect_proxy( tokio::spawn(async move { let _permit = permit; - let _ = - handle_connect_proxy_client(socket, engine, context, event_emitter, outcome) - .await; + let _ = handle_connect_proxy_client( + socket, + engine, + context, + event_emitter, + outcome, + header_read_timeout, + ) + .await; }); } }); @@ -789,10 +819,21 @@ async fn handle_connect_proxy_client( context: GuardContext, event_emitter: EventEmitter, outcome: RunOutcome, + header_read_timeout: Duration, ) -> anyhow::Result<()> { - let header = read_http_header(&mut client, 8 * 1024) - .await - .context("read proxy request header")?; + let header = + match tokio::time::timeout(header_read_timeout, read_http_header(&mut client, 8 * 1024)) + .await + { + Ok(Ok(header)) => header, + Ok(Err(err)) => return Err(err).context("read proxy request header"), + Err(_) => { + let _ = client + .write_all(b"HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n") + .await; + return Ok(()); + } + }; let header_str = std::str::from_utf8(&header).context("proxy request header must be UTF-8")?; let mut lines = header_str.split("\r\n"); @@ -812,71 +853,105 @@ async fn handle_connect_proxy_client( } let (connect_host, connect_port) = parse_connect_target(target)?; - - // If the CONNECT target is an IP address, try to use TLS SNI as the policy host. - let mut sni_buf = Vec::new(); - let host_for_policy = if connect_host.parse::().is_ok() { - client - .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") - .await?; - - // Best-effort: read one TLS record to extract SNI. - match tokio::time::timeout(Duration::from_secs(3), read_tls_record(&mut client)).await { - Ok(Ok(record)) => { - sni_buf = record.clone(); - match hush_proxy::sni::extract_sni(&record) { - Ok(Some(host)) => host, - _ => connect_host.clone(), - } - } - _ => connect_host.clone(), - } - } else { - connect_host.clone() - }; - - let result = engine - .check_egress(&host_for_policy, connect_port, &context) + let connect_result = engine + .check_egress(&connect_host, connect_port, &context) .await .context("check egress policy")?; - outcome.observe_guard_result(&result); + outcome.observe_guard_result(&connect_result); event_emitter.emit(network_event( &context, - host_for_policy.clone(), + connect_host.clone(), connect_port, - &result, + &connect_result, )); - if !result.allowed { - // If we already sent 200 (IP + SNI path), we can only close the tunnel. - if sni_buf.is_empty() { - client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; - } + if !connect_result.allowed { + client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; return Ok(()); } + let connect_ip = connect_host.parse::().ok(); + let mut buffered_tls_record: Option> = None; + + if let Some(ip) = connect_ip { + client + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await?; + + // Best-effort: read one TLS record to extract SNI and enforce CONNECT target consistency. + if let Ok(Ok(record)) = + tokio::time::timeout(PROXY_TLS_SNI_TIMEOUT, read_tls_record(&mut client)).await + { + buffered_tls_record = Some(record.clone()); + if let Ok(Some(sni_host)) = hush_proxy::sni::extract_sni(&record) { + let sni_result = engine + .check_egress(&sni_host, connect_port, &context) + .await + .context("check egress policy for SNI host")?; + + outcome.observe_guard_result(&sni_result); + event_emitter.emit(network_event( + &context, + sni_host.clone(), + connect_port, + &sni_result, + )); + + if !sni_result.allowed { + return Ok(()); + } + + if !sni_host_matches_connect_ip(&sni_host, connect_port, ip).await { + let mismatch = GuardResult::block( + "connect_proxy_sni_consistency", + Severity::Error, + format!( + "CONNECT target {} does not match SNI host {}", + connect_host, sni_host + ), + ); + outcome.observe_guard_result(&mismatch); + event_emitter.emit(network_event(&context, sni_host, connect_port, &mismatch)); + return Ok(()); + } + } + } + } + // Connect to the requested endpoint. let mut upstream = TcpStream::connect((connect_host.as_str(), connect_port)) .await .context("connect upstream")?; - // If we used the SNI path, forward the already-read TLS bytes. - if !sni_buf.is_empty() { - upstream.write_all(&sni_buf).await?; - } else { + // If we already answered CONNECT for IP targets, do not send it twice. + if connect_ip.is_none() { client .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") .await?; } + // Forward the already-read TLS bytes, if any. + if let Some(sni_buf) = buffered_tls_record { + upstream.write_all(&sni_buf).await?; + } + // Tunnel bytes both ways until EOF. let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await; Ok(()) } +async fn sni_host_matches_connect_ip(host: &str, port: u16, connect_ip: IpAddr) -> bool { + let lookup = tokio::time::timeout(Duration::from_secs(2), lookup_host((host, port))).await; + let Ok(Ok(addrs)) = lookup else { + return false; + }; + + addrs.into_iter().any(|addr| addr.ip() == connect_ip) +} + async fn read_http_header(stream: &mut TcpStream, max_bytes: usize) -> anyhow::Result> { let mut buf = Vec::new(); let mut scratch = [0u8; 1024]; @@ -1158,17 +1233,27 @@ name: "proxy-limit" let outcome = RunOutcome::new(); let mut stderr = Vec::::new(); - let (url, handle, rejected_counter) = - match start_connect_proxy(0, engine, context, emitter, outcome, 1, &mut stderr).await { - Ok(v) => v, - Err(err) => { - if err.to_string().contains("Permission denied") { - eprintln!("skipping proxy limit test: {}", err); - return; - } - panic!("failed to start proxy: {err}"); + let (url, handle, rejected_counter) = match start_connect_proxy( + 0, + engine, + context, + emitter, + outcome, + 1, + Duration::from_secs(2), + &mut stderr, + ) + .await + { + Ok(v) => v, + Err(err) => { + if err.to_string().contains("Permission denied") { + eprintln!("skipping proxy limit test: {}", err); + return; } - }; + panic!("failed to start proxy: {err}"); + } + }; let addr = url.trim_start_matches("http://"); let mut first = TcpStream::connect(addr).await.expect("first connect"); @@ -1194,4 +1279,193 @@ name: "proxy-limit" let _ = second.shutdown().await; handle.abort(); } + + #[tokio::test] + async fn connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch() { + let policy_yaml = r#" +version: "1.1.0" +name: "sni-mismatch" +guards: + egress_allowlist: + allow: ["example.com"] + default_action: block +"#; + let policy = Policy::from_yaml(policy_yaml).expect("policy"); + let engine = Arc::new(HushEngine::builder(policy).build().expect("engine")); + let context = GuardContext::new().with_session_id("session-sni"); + let (tx, _rx) = mpsc::channel::(32); + let emitter = EventEmitter::new(tx); + let outcome = RunOutcome::new(); + let mut stderr = Vec::::new(); + + let upstream = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind upstream"); + let upstream_port = upstream.local_addr().expect("upstream addr").port(); + + let (url, handle, _rejected_counter) = start_connect_proxy( + 0, + engine, + context, + emitter, + outcome, + 4, + Duration::from_secs(2), + &mut stderr, + ) + .await + .expect("start proxy"); + + let addr = url.trim_start_matches("http://"); + let mut client = TcpStream::connect(addr).await.expect("proxy connect"); + + let req = format!( + "CONNECT 127.0.0.1:{} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\r\n", + upstream_port, upstream_port + ); + client + .write_all(req.as_bytes()) + .await + .expect("write connect"); + + let mut buf = [0u8; 256]; + let n = tokio::time::timeout(Duration::from_secs(1), client.read(&mut buf)) + .await + .expect("read timeout") + .expect("read response"); + let response = String::from_utf8_lossy(&buf[..n]).to_string(); + assert!( + response.contains("403 Forbidden"), + "blocked IP CONNECT target must not be bypassed by allowlisted SNI, got: {response}" + ); + + let hello = include_bytes!("../../../libs/hush-proxy/testdata/client_hello_example.bin"); + let _ = client.write_all(hello).await; + + let upstream_accept = + tokio::time::timeout(Duration::from_millis(300), upstream.accept()).await; + assert!( + upstream_accept.is_err(), + "proxy must not connect upstream when CONNECT IP target is blocked" + ); + + handle.abort(); + } + + #[tokio::test] + async fn proxy_slowloris_does_not_exceed_connection_cap() { + let policy_yaml = r#" +version: "1.1.0" +name: "slowloris-cap" +"#; + let policy = Policy::from_yaml(policy_yaml).expect("policy"); + let engine = Arc::new(HushEngine::builder(policy).build().expect("engine")); + let context = GuardContext::new().with_session_id("session-slowloris"); + let (tx, _rx) = mpsc::channel::(32); + let emitter = EventEmitter::new(tx); + let outcome = RunOutcome::new(); + let mut stderr = Vec::::new(); + + let (url, handle, rejected_counter) = start_connect_proxy( + 0, + engine, + context, + emitter, + outcome, + 1, + Duration::from_millis(150), + &mut stderr, + ) + .await + .expect("start proxy"); + + let addr = url.trim_start_matches("http://"); + let mut slow = TcpStream::connect(addr).await.expect("slow connect"); + slow.write_all(b"CON").await.expect("write partial header"); + + let mut second = TcpStream::connect(addr).await.expect("second connect"); + let mut second_buf = [0u8; 128]; + let second_n = tokio::time::timeout(Duration::from_secs(1), second.read(&mut second_buf)) + .await + .expect("second read timeout") + .expect("second read"); + let second_response = String::from_utf8_lossy(&second_buf[..second_n]).to_string(); + assert!( + second_response.contains("503 Service Unavailable"), + "expected 503 while slowloris connection holds the only slot, got: {second_response}" + ); + + tokio::time::sleep(Duration::from_millis(250)).await; + + let mut third = TcpStream::connect(addr).await.expect("third connect"); + third + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .expect("write full request"); + let mut third_buf = [0u8; 128]; + let third_n = tokio::time::timeout(Duration::from_secs(1), third.read(&mut third_buf)) + .await + .expect("third read timeout") + .expect("third read"); + let third_response = String::from_utf8_lossy(&third_buf[..third_n]).to_string(); + assert!( + third_response.contains("501 Not Implemented"), + "proxy should remain responsive after slowloris timeout, got: {third_response}" + ); + assert!( + rejected_counter.load(Ordering::Relaxed) >= 1, + "rejected connection counter should increment under slowloris saturation" + ); + + let _ = slow.shutdown().await; + let _ = second.shutdown().await; + let _ = third.shutdown().await; + handle.abort(); + } + + #[tokio::test] + async fn event_forwarding_backpressure_keeps_memory_bounded() { + let stalled_listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind stalled target"); + let stalled_addr = stalled_listener.local_addr().expect("stalled addr"); + let stalled_handle = tokio::spawn(async move { + while let Ok((mut stream, _)) = stalled_listener.accept().await { + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = + tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf)).await; + tokio::time::sleep(Duration::from_secs(5)).await; + }); + } + }); + + let (tx, mut rx) = mpsc::channel::(4); + let emitter = EventEmitter::new(tx); + let forwarder = HushdForwarder::new_with_timeout( + format!("http://{}", stalled_addr), + None, + Duration::from_millis(50), + ); + + let writer = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + forwarder.forward_event(&event).await; + } + }); + + for i in 0..200 { + emitter.emit(test_custom_event(i)); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + assert!( + emitter.dropped_count() > 0, + "bounded queue should drop events under stalled forwarding pressure" + ); + + drop(emitter); + let _ = tokio::time::timeout(Duration::from_secs(2), writer).await; + stalled_handle.abort(); + } } From a292e664b82678fdc67b9b25d50a310812f1df55 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 00:16:40 -0500 Subject: [PATCH 04/16] fix(clawdstrike): fail closed on IRM path and URL spoof bypasses --- crates/libs/clawdstrike/src/irm/fs.rs | 69 +++++++++++++++++++++++--- crates/libs/clawdstrike/src/irm/net.rs | 62 +++++++++++++++++++---- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/crates/libs/clawdstrike/src/irm/fs.rs b/crates/libs/clawdstrike/src/irm/fs.rs index 0bf6ff369..01047e44a 100644 --- a/crates/libs/clawdstrike/src/irm/fs.rs +++ b/crates/libs/clawdstrike/src/irm/fs.rs @@ -102,14 +102,12 @@ impl FilesystemIrm { // Remove trailing slashes let trimmed = expanded.trim_end_matches('/'); - // Resolve .. and . (simple implementation) + // Resolve "." but preserve ".." so security checks do not silently change path meaning. let mut parts: Vec<&str> = Vec::new(); for part in trimmed.split('/') { match part { "" | "." => {} - ".." => { - parts.pop(); - } + ".." => parts.push(".."), other => { parts.push(other); } @@ -139,7 +137,7 @@ impl FilesystemIrm { fn extract_path(&self, call: &HostCall) -> Option { for arg in &call.args { if let Some(s) = arg.as_str() { - if s.starts_with('/') || s.starts_with("~/") || s.starts_with("./") { + if self.looks_like_path(s) { return Some(s.to_string()); } } @@ -156,6 +154,30 @@ impl FilesystemIrm { None } + + fn looks_like_path(&self, value: &str) -> bool { + if value.is_empty() { + return false; + } + + if value.starts_with('/') || value.starts_with("~/") || value.starts_with("./") { + return true; + } + + if value == ".." || value.starts_with("../") { + return true; + } + + if value.contains('\\') { + return true; + } + + value.contains('/') && !value.contains("://") + } + + fn has_parent_traversal(&self, path: &str) -> bool { + path.replace('\\', "/").split('/').any(|seg| seg == "..") + } } impl Default for FilesystemIrm { @@ -188,6 +210,12 @@ impl Monitor for FilesystemIrm { debug!("FilesystemIrm checking path: {}", path); + if self.has_parent_traversal(&path) { + return Decision::Deny { + reason: format!("Path contains parent traversal segment: {}", path), + }; + } + // Check forbidden paths if let Some(pattern) = self.is_forbidden(&path, policy) { return Decision::Deny { @@ -222,7 +250,7 @@ mod tests { assert_eq!(irm.normalize_path("/foo/bar"), "/foo/bar"); assert_eq!(irm.normalize_path("/foo/bar/"), "/foo/bar"); - assert_eq!(irm.normalize_path("/foo/../bar"), "/bar"); + assert_eq!(irm.normalize_path("/foo/../bar"), "/foo/../bar"); assert_eq!(irm.normalize_path("/foo/./bar"), "/foo/bar"); assert_eq!(irm.normalize_path("~/test"), "/home/user/test"); } @@ -307,10 +335,39 @@ mod tests { let call = HostCall::new("fd_read", vec![serde_json::json!({"path": "/app/main.rs"})]); assert_eq!(irm.extract_path(&call), Some("/app/main.rs".to_string())); + let call = HostCall::new("fd_read", vec![serde_json::json!("../../etc/passwd")]); + assert_eq!( + irm.extract_path(&call), + Some("../../etc/passwd".to_string()) + ); + let call = HostCall::new("fd_read", vec![serde_json::json!(123)]); assert_eq!(irm.extract_path(&call), None); } + #[tokio::test] + async fn filesystem_irm_denies_parent_traversal_relative_paths() { + let irm = FilesystemIrm::new(); + let policy = Policy::default(); + + let call = HostCall::new("fd_read", vec![serde_json::json!("../../etc/passwd")]); + let decision = irm.evaluate(&call, &policy).await; + assert!( + !decision.is_allowed(), + "string traversal path should be denied" + ); + + let call = HostCall::new( + "fd_write", + vec![serde_json::json!({"path": "./../..//etc/passwd"})], + ); + let decision = irm.evaluate(&call, &policy).await; + assert!( + !decision.is_allowed(), + "object traversal path should be denied" + ); + } + #[test] fn test_handles_event_types() { let irm = FilesystemIrm::new(); diff --git a/crates/libs/clawdstrike/src/irm/net.rs b/crates/libs/clawdstrike/src/irm/net.rs index dce27d8e0..86743dcc6 100644 --- a/crates/libs/clawdstrike/src/irm/net.rs +++ b/crates/libs/clawdstrike/src/irm/net.rs @@ -3,6 +3,7 @@ //! Monitors network operations and enforces egress control. use async_trait::async_trait; +use reqwest::Url; use tracing::debug; use hush_proxy::dns::domain_matches; @@ -36,14 +37,20 @@ impl NetworkIrm { } // Plain hostname pattern if s.contains('.') && !s.contains('/') { - return Some(s.to_string()); + let host = self.normalize_host(s); + if !host.is_empty() { + return Some(host); + } } } // Check object with host field if let Some(obj) = arg.as_object() { if let Some(host) = obj.get("host").and_then(|h| h.as_str()) { - return Some(host.to_string()); + let host = self.normalize_host(host); + if !host.is_empty() { + return Some(host); + } } if let Some(url) = obj.get("url").and_then(|u| u.as_str()) { return self.extract_host_from_url(url); @@ -92,15 +99,18 @@ impl NetworkIrm { /// Extract host from URL fn extract_host_from_url(&self, url: &str) -> Option { - let without_scheme = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://")) - .unwrap_or(url); - - let host_part = without_scheme.split('/').next()?; - let host = host_part.split(':').next()?; + let parsed = Url::parse(url).ok()?; + let host = parsed.host_str()?; + let host = self.normalize_host(host); + if host.is_empty() { + None + } else { + Some(host) + } + } - Some(host.to_string()) + fn normalize_host(&self, host: &str) -> String { + host.trim().trim_end_matches('.').to_ascii_lowercase() } /// Check if a host matches a pattern @@ -228,6 +238,10 @@ mod tests { irm.extract_host_from_url("http://localhost:8080/api"), Some("localhost".to_string()) ); + assert_eq!( + irm.extract_host_from_url("https://api.openai.com:443@evil.example/path"), + Some("evil.example".to_string()) + ); } #[test] @@ -314,6 +328,34 @@ mod tests { assert_eq!(irm.extract_host(&call), Some("api.openai.com".to_string())); } + #[tokio::test] + async fn test_userinfo_spoof_url_uses_actual_host_and_is_denied() { + let irm = NetworkIrm::new(); + let policy = Policy::from_yaml( + r#" +version: "1.1.0" +name: net-allowlist +guards: + egress_allowlist: + allow: ["api.openai.com"] + default_action: block +"#, + ) + .expect("policy"); + + let call = HostCall::new( + "sock_connect", + vec![serde_json::json!( + "https://api.openai.com:443@evil.example/path" + )], + ); + let decision = irm.evaluate(&call, &policy).await; + assert!( + !decision.is_allowed(), + "spoofed userinfo URL should be denied" + ); + } + #[test] fn test_extract_port() { let irm = NetworkIrm::new(); From b347eff5462ea61869926dcf87c650ad7bd7f8c0 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 00:16:44 -0500 Subject: [PATCH 05/16] fix(remote-extends): validate git refs and block option injection --- .../services/hush-cli/src/remote_extends.rs | 54 +++++++++++- crates/services/hush-cli/src/tests.rs | 17 ++++ crates/services/hushd/src/remote_extends.rs | 88 ++++++++++++++++++- 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs index 93c0d442a..c82bf65f6 100644 --- a/crates/services/hush-cli/src/remote_extends.rs +++ b/crates/services/hush-cli/src/remote_extends.rs @@ -371,6 +371,7 @@ impl RemotePolicyResolver { "Invalid git extends (empty repo/commit/path)".into(), )); } + validate_git_commit_ref(commit)?; let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?; self.ensure_host_allowed(&repo_host)?; @@ -426,6 +427,7 @@ impl RemotePolicyResolver { commit: &str, base_path: &str, ) -> Result { + validate_git_commit_ref(commit)?; let (rel_path, expected_sha) = split_sha256_pin(reference)?; let joined = normalize_git_join(base_path, rel_path)?; let absolute = format!("git+{}@{}:{}#sha256={}", repo, commit, joined, expected_sha); @@ -437,7 +439,10 @@ impl RemotePolicyResolver { run_git(&temp.path, &["init"])?; run_git(&temp.path, &["remote", "add", "origin", repo])?; - run_git(&temp.path, &["fetch", "--depth", "1", "origin", commit])?; + run_git( + &temp.path, + &["fetch", "--depth", "1", "origin", "--", commit], + )?; let output = Command::new("git") .arg("-C") @@ -551,6 +556,53 @@ fn verify_sha256_pin(bytes: &[u8], expected_hex: &str) -> Result<()> { Ok(()) } +fn validate_git_commit_ref(token: &str) -> Result<()> { + if token.starts_with('-') { + return Err(Error::ConfigError( + "Invalid git extends commit/ref: token must not start with '-'".to_string(), + )); + } + + if is_hex_oid(token) || is_valid_git_refname(token) { + return Ok(()); + } + + Err(Error::ConfigError(format!( + "Invalid git extends commit/ref: {}", + token + ))) +} + +fn is_hex_oid(token: &str) -> bool { + (7..=40).contains(&token.len()) && token.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn is_valid_git_refname(token: &str) -> bool { + if token.is_empty() + || token.starts_with('/') + || token.ends_with('/') + || token.ends_with('.') + || token.ends_with(".lock") + || token.contains("//") + || token.contains("..") + || token.contains("@{") + { + return false; + } + + if token.bytes().any(|b| { + b.is_ascii_control() + || b == b' ' + || matches!(b, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\') + }) { + return false; + } + + token + .split('/') + .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.')) +} + fn parse_remote_url(url: &str, https_only: bool) -> std::result::Result { let parsed = Url::parse(url).map_err(|e| format!("Invalid URL in remote extends: {url}: {e}"))?; diff --git a/crates/services/hush-cli/src/tests.rs b/crates/services/hush-cli/src/tests.rs index 1daa202ab..f6756514b 100644 --- a/crates/services/hush-cli/src/tests.rs +++ b/crates/services/hush-cli/src/tests.rs @@ -3070,6 +3070,23 @@ extends: {}#sha256={} ); } + #[test] + fn remote_extends_rejects_dash_prefixed_commit_ref() { + let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!( + "git+https://github.com/backbay-labs/clawdstrike.git@-not-a-ref:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("dash-prefixed commit/ref must be rejected before any git invocation"); + assert!( + err.to_string().contains("must not start with '-'"), + "unexpected error: {err}" + ); + } + #[test] fn remote_extends_git_private_ip_blocked_when_disallowed() { let cfg = RemoteExtendsConfig::new(["127.0.0.1".to_string()]) diff --git a/crates/services/hushd/src/remote_extends.rs b/crates/services/hushd/src/remote_extends.rs index 3e033f425..42cbb1448 100644 --- a/crates/services/hushd/src/remote_extends.rs +++ b/crates/services/hushd/src/remote_extends.rs @@ -347,6 +347,7 @@ impl RemotePolicyResolver { "Invalid git extends (empty repo/commit/path)".into(), )); } + validate_git_commit_ref(commit)?; let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?; self.ensure_host_allowed(&repo_host)?; @@ -396,6 +397,7 @@ impl RemotePolicyResolver { commit: &str, base_path: &str, ) -> Result { + validate_git_commit_ref(commit)?; let (rel_path, expected_sha) = split_sha256_pin(reference)?; let joined = normalize_git_join(base_path, rel_path)?; let absolute = format!("git+{}@{}:{}#sha256={}", repo, commit, joined, expected_sha); @@ -407,7 +409,10 @@ impl RemotePolicyResolver { run_git(&temp.path, &["init"])?; run_git(&temp.path, &["remote", "add", "origin", repo])?; - run_git(&temp.path, &["fetch", "--depth", "1", "origin", commit])?; + run_git( + &temp.path, + &["fetch", "--depth", "1", "origin", "--", commit], + )?; let output = Command::new("git") .arg("-C") @@ -521,6 +526,53 @@ fn verify_sha256_pin(bytes: &[u8], expected_hex: &str) -> Result<()> { Ok(()) } +fn validate_git_commit_ref(token: &str) -> Result<()> { + if token.starts_with('-') { + return Err(Error::ConfigError( + "Invalid git extends commit/ref: token must not start with '-'".to_string(), + )); + } + + if is_hex_oid(token) || is_valid_git_refname(token) { + return Ok(()); + } + + Err(Error::ConfigError(format!( + "Invalid git extends commit/ref: {}", + token + ))) +} + +fn is_hex_oid(token: &str) -> bool { + (7..=40).contains(&token.len()) && token.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn is_valid_git_refname(token: &str) -> bool { + if token.is_empty() + || token.starts_with('/') + || token.ends_with('/') + || token.ends_with('.') + || token.ends_with(".lock") + || token.contains("//") + || token.contains("..") + || token.contains("@{") + { + return false; + } + + if token.bytes().any(|b| { + b.is_ascii_control() + || b == b' ' + || matches!(b, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\') + }) { + return false; + } + + token + .split('/') + .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.')) +} + fn parse_remote_url(url: &str, https_only: bool) -> std::result::Result { let parsed = Url::parse(url).map_err(|e| format!("Invalid URL in remote extends: {url}: {e}"))?; @@ -1177,6 +1229,40 @@ mod tests { let _ = std::fs::remove_dir_all(&cache_dir); } + #[test] + fn remote_extends_rejects_dash_prefixed_commit_ref() { + let cache_dir = std::env::temp_dir().join(format!( + "hushd-remote-extends-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&cache_dir).expect("create cache dir"); + + let cfg = RemoteExtendsResolverConfig { + allowed_hosts: ["github.com".to_string()].into_iter().collect(), + cache_dir: cache_dir.clone(), + https_only: true, + allow_private_ips: true, + allow_cross_host_redirects: false, + max_fetch_bytes: 1024 * 1024, + max_cache_bytes: 1024 * 1024, + }; + let resolver = RemotePolicyResolver::new(cfg).expect("create resolver"); + + let reference = format!( + "git+https://github.com/backbay-labs/clawdstrike.git@-badref:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("dash-prefixed commit/ref must be rejected before any git invocation"); + assert!( + err.to_string().contains("must not start with '-'"), + "unexpected error: {err}" + ); + + let _ = std::fs::remove_dir_all(&cache_dir); + } + #[test] fn allow_private_ips_allows_fetching_localhost() { let (port, _calls, stop, handle) = spawn_server(|stream| { From 15553973fe64b8392684429afc637d6ead69fdc8 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 00:16:49 -0500 Subject: [PATCH 06/16] fix(policy): bound extends recursion and async background inflight --- .../clawdstrike/src/async_guards/runtime.rs | 95 ++++++++- crates/libs/clawdstrike/src/policy.rs | 12 ++ .../clawdstrike/tests/async_guard_runtime.rs | 50 +++++ .../libs/clawdstrike/tests/policy_extends.rs | 57 ++++++ docs/audits/2026-02-10-wave2-remediation.md | 190 ++++++++++++++++++ 5 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 docs/audits/2026-02-10-wave2-remediation.md diff --git a/crates/libs/clawdstrike/src/async_guards/runtime.rs b/crates/libs/clawdstrike/src/async_guards/runtime.rs index 31c79e422..7e1dd9ed7 100644 --- a/crates/libs/clawdstrike/src/async_guards/runtime.rs +++ b/crates/libs/clawdstrike/src/async_guards/runtime.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use dashmap::DashMap; @@ -14,6 +15,8 @@ use crate::async_guards::types::{ use crate::guards::{GuardAction, GuardContext, GuardResult, Severity}; use crate::policy::{AsyncExecutionMode, TimeoutBehavior}; +const DEFAULT_BACKGROUND_IN_FLIGHT_LIMIT: usize = 64; + #[derive(Clone, Debug)] pub enum OwnedGuardAction { FileAccess { @@ -94,6 +97,11 @@ pub struct AsyncGuardRuntime { caches: DashMap>, limiters: DashMap>, breakers: DashMap>, + background_slots: Arc, + background_in_flight_limit: usize, + background_running: AtomicUsize, + background_peak_running: AtomicUsize, + background_dropped: AtomicUsize, } impl Default for AsyncGuardRuntime { @@ -104,11 +112,21 @@ impl Default for AsyncGuardRuntime { impl AsyncGuardRuntime { pub fn new() -> Self { + Self::with_background_in_flight_limit(DEFAULT_BACKGROUND_IN_FLIGHT_LIMIT) + } + + pub fn with_background_in_flight_limit(limit: usize) -> Self { + let limit = limit.max(1); Self { http: HttpClient::new(), caches: DashMap::new(), limiters: DashMap::new(), breakers: DashMap::new(), + background_slots: Arc::new(tokio::sync::Semaphore::new(limit)), + background_in_flight_limit: limit, + background_running: AtomicUsize::new(0), + background_peak_running: AtomicUsize::new(0), + background_dropped: AtomicUsize::new(0), } } @@ -116,6 +134,22 @@ impl AsyncGuardRuntime { &self.http } + pub fn background_inflight_limit(&self) -> usize { + self.background_in_flight_limit + } + + pub fn background_inflight_count(&self) -> usize { + self.background_running.load(Ordering::Relaxed) + } + + pub fn background_peak_inflight(&self) -> usize { + self.background_peak_running.load(Ordering::Relaxed) + } + + pub fn background_dropped_count(&self) -> usize { + self.background_dropped.load(Ordering::Relaxed) + } + pub async fn evaluate_async_guards( self: &Arc, guards: &[Arc], @@ -210,13 +244,28 @@ impl AsyncGuardRuntime { background.sort_by_key(|(idx, _)| *idx); for (_idx, g) in background { - self.spawn_background(g.clone(), owned_action.clone(), ctx.clone()); - out.push( - GuardResult::allow(g.name()).with_details(serde_json::json!({ - "background": true, - "note": "scheduled" - })), - ); + if self.spawn_background(g.clone(), owned_action.clone(), ctx.clone()) { + out.push( + GuardResult::allow(g.name()).with_details(serde_json::json!({ + "background": true, + "note": "scheduled", + "in_flight_limit": self.background_inflight_limit() + })), + ); + } else { + out.push( + GuardResult::warn( + g.name(), + "background guard dropped due to in-flight limit", + ) + .with_details(serde_json::json!({ + "background": true, + "note": "dropped", + "in_flight_limit": self.background_inflight_limit(), + "dropped_total": self.background_dropped_count() + })), + ); + } } } @@ -228,9 +277,20 @@ impl AsyncGuardRuntime { guard: Arc, action: OwnedGuardAction, context: GuardContext, - ) { + ) -> bool { + let permit = match self.background_slots.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + self.background_dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + }; + let runtime = Arc::clone(self); tokio::spawn(async move { + let running = runtime.background_running.fetch_add(1, Ordering::Relaxed) + 1; + update_max(&runtime.background_peak_running, running); + let borrowed = action.as_guard_action(); let result = runtime .evaluate_one(guard.clone(), &borrowed, &context) @@ -245,7 +305,11 @@ impl AsyncGuardRuntime { "background async guard would have denied" ); } + + runtime.background_running.fetch_sub(1, Ordering::Relaxed); + drop(permit); }); + true } async fn evaluate_one( @@ -390,6 +454,21 @@ impl AsyncGuardRuntime { } } +fn update_max(target: &AtomicUsize, candidate: usize) { + loop { + let current = target.load(Ordering::Relaxed); + if candidate <= current { + return; + } + if target + .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + return; + } + } +} + fn fallback( guard: &str, cfg: &AsyncGuardConfig, diff --git a/crates/libs/clawdstrike/src/policy.rs b/crates/libs/clawdstrike/src/policy.rs index acf88d8c6..d05dbbefd 100644 --- a/crates/libs/clawdstrike/src/policy.rs +++ b/crates/libs/clawdstrike/src/policy.rs @@ -22,6 +22,7 @@ use crate::posture::{validate_posture_config, PostureConfig}; /// unsupported versions to prevent silent drift. pub const POLICY_SCHEMA_VERSION: &str = "1.2.0"; pub const POLICY_SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1.1.0", "1.2.0"]; +const MAX_POLICY_EXTENDS_DEPTH: usize = 32; fn default_true() -> bool { true @@ -907,6 +908,7 @@ impl Policy { location, resolver, &mut std::collections::HashSet::new(), + 0, PolicyValidationOptions::default(), ) } @@ -916,8 +918,16 @@ impl Policy { location: PolicyLocation, resolver: &impl PolicyResolver, visited: &mut std::collections::HashSet, + depth: usize, validation: PolicyValidationOptions, ) -> Result { + if depth > MAX_POLICY_EXTENDS_DEPTH { + return Err(Error::ConfigError(format!( + "Policy extends depth exceeded (limit: {})", + MAX_POLICY_EXTENDS_DEPTH + ))); + } + let child = Policy::from_yaml_unvalidated(yaml)?; if let Some(ref extends) = child.extends { @@ -937,6 +947,7 @@ impl Policy { resolved.location, resolver, visited, + depth + 1, validation, )?; @@ -965,6 +976,7 @@ impl Policy { location, resolver, &mut std::collections::HashSet::new(), + 0, validation, ) } diff --git a/crates/libs/clawdstrike/tests/async_guard_runtime.rs b/crates/libs/clawdstrike/tests/async_guard_runtime.rs index d17fc3a3b..a1a5d8c38 100644 --- a/crates/libs/clawdstrike/tests/async_guard_runtime.rs +++ b/crates/libs/clawdstrike/tests/async_guard_runtime.rs @@ -313,3 +313,53 @@ async fn circuit_breaker_opens_on_timeouts() { Some("CircuitOpen") ); } + +#[tokio::test] +async fn async_background_guards_enforce_inflight_limit() { + let calls = Arc::new(AtomicUsize::new(0)); + let guard: Arc = Arc::new(SleepGuard { + name: "background_sleep", + cfg: AsyncGuardConfig { + execution_mode: AsyncExecutionMode::Background, + timeout: Duration::from_secs(1), + ..base_async_cfg() + }, + calls: calls.clone(), + sleep: Duration::from_millis(250), + }); + + let runtime = Arc::new(AsyncGuardRuntime::with_background_in_flight_limit(2)); + let ctx = GuardContext::new(); + + for _ in 0..20 { + let _ = runtime + .evaluate_async_guards( + std::slice::from_ref(&guard), + &GuardAction::FileAccess("/tmp/a"), + &ctx, + false, + ) + .await; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + + assert!( + runtime.background_peak_inflight() <= 2, + "background in-flight peak exceeded configured limit: {}", + runtime.background_peak_inflight() + ); + assert!( + runtime.background_dropped_count() > 0, + "burst load should drop background tasks once in-flight limit is saturated" + ); + + tokio::time::timeout(Duration::from_secs(2), async { + while runtime.background_inflight_count() > 0 { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("background tasks should drain within timeout"); + assert_eq!(runtime.background_inflight_count(), 0); +} diff --git a/crates/libs/clawdstrike/tests/policy_extends.rs b/crates/libs/clawdstrike/tests/policy_extends.rs index 4bcfcb689..263cd5d7d 100644 --- a/crates/libs/clawdstrike/tests/policy_extends.rs +++ b/crates/libs/clawdstrike/tests/policy_extends.rs @@ -285,3 +285,60 @@ extends: a let err = Policy::from_yaml_with_extends_resolver(a, None, &resolver).unwrap_err(); assert!(err.to_string().contains("Circular")); } + +#[test] +fn policy_extends_depth_limit_enforced() { + use std::collections::HashMap; + + #[derive(Clone, Default)] + struct MapResolver { + policies: HashMap, + } + + impl PolicyResolver for MapResolver { + fn resolve( + &self, + reference: &str, + _from: &PolicyLocation, + ) -> clawdstrike::Result { + let yaml = self.policies.get(reference).cloned().ok_or_else(|| { + clawdstrike::Error::ConfigError(format!("Unknown policy ref: {}", reference)) + })?; + Ok(ResolvedPolicySource { + key: format!("url:{}", reference), + yaml, + location: PolicyLocation::Url(reference.to_string()), + }) + } + } + + let mut resolver = MapResolver::default(); + for i in 0..40 { + let extends = if i < 39 { + format!("extends: p{}\n", i + 1) + } else { + String::new() + }; + let yaml = format!( + r#" +version: "1.1.0" +name: p{} +{} +"#, + i, extends + ); + resolver.policies.insert(format!("p{}", i), yaml); + } + + let root = r#" +version: "1.1.0" +name: root +extends: p0 +"#; + let err = Policy::from_yaml_with_extends_resolver(root, None, &resolver) + .expect_err("extends chain deeper than limit should fail"); + assert!( + err.to_string().contains("extends depth exceeded"), + "unexpected error: {err}" + ); +} diff --git a/docs/audits/2026-02-10-wave2-remediation.md b/docs/audits/2026-02-10-wave2-remediation.md new file mode 100644 index 000000000..6ca5a0e18 --- /dev/null +++ b/docs/audits/2026-02-10-wave2-remediation.md @@ -0,0 +1,190 @@ +# Rust Security + Correctness Audit — Wave 2 Remediation (2026-02-10) + +This document records remediation for CS-AUDIT2-001 through CS-AUDIT2-007. + +## CS-AUDIT2-001 — CONNECT policy target != dial target (SNI vs CONNECT host) + +### What was wrong +CONNECT requests targeting an IP could be policy-checked against extracted SNI instead of the dial target, enabling policy bypass when SNI was allowlisted but CONNECT IP was blocked. + +### Fix strategy +- Enforced policy check on the actual CONNECT target (`connect_host:connect_port`) before any upstream dial. +- For IP CONNECT targets, added SNI consistency enforcement: + - Evaluate SNI host separately. + - Require SNI host DNS resolution to include the same CONNECT IP. + - Reject on mismatch before upstream connection. +- Kept event emission and outcome tracking for both connect target and SNI checks. + +### Code pointers +- `crates/services/hush-cli/src/hush_run.rs` +- Functions: `handle_connect_proxy_client`, `sni_host_matches_connect_ip` + +### Tests added +- `connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch` + - Asserts blocked IP CONNECT target is rejected even when SNI payload contains allowlisted host. + - Asserts no upstream TCP accept occurs. + +### Proof commands +- `cargo test -p hush-cli connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch -- --nocapture` +- PASS: test passed. + +## CS-AUDIT2-002 — hush run resource bounds (slowloris + unbounded events + forward timeout) + +### What was wrong +Proxy and event-forwarding paths had bounded pieces but lacked complete protection against slow header reads and potentially stalled forwarding operations. + +### Fix strategy +- Added CONNECT header read timeout (slowloris mitigation) with 408 response on timeout. +- Kept and exercised in-flight proxy connection cap behavior. +- Added explicit forwarding timeout for hushd event forwarding HTTP requests. +- Verified bounded event queue drop behavior under stalled forwarding pressure. + +### Code pointers +- `crates/services/hush-cli/src/hush_run.rs` +- Functions: `start_connect_proxy`, `handle_connect_proxy_client`, `HushdForwarder::new`, `HushdForwarder::forward_event` + +### Tests added +- `proxy_slowloris_does_not_exceed_connection_cap` + - Simulates partial/slow header sender. + - Asserts cap enforcement (`503`) and post-timeout responsiveness (`501`). +- `event_forwarding_backpressure_keeps_memory_bounded` + - Simulates stalled forward target. + - Asserts queue saturation triggers drops (`dropped_count > 0`). + +### Proof commands +- `cargo test -p hush-cli proxy_slowloris_does_not_exceed_connection_cap -- --nocapture` +- `cargo test -p hush-cli event_forwarding_backpressure_keeps_memory_bounded -- --nocapture` +- PASS: both tests passed. + +## CS-AUDIT2-003 — IRM filesystem traversal bypass via normalization + +### What was wrong +Filesystem IRM normalization collapsed `..` segments, potentially converting traversal intent into apparently safe paths and bypassing boundary checks. + +### Fix strategy +- Stopped sanitizing away parent traversal during normalization. +- Added explicit fail-closed traversal detection for `..` segments (including mixed forms). +- Broadened path extraction to catch relative traversal path inputs. + +### Code pointers +- `crates/libs/clawdstrike/src/irm/fs.rs` +- Functions: `normalize_path`, `extract_path`, `has_parent_traversal`, `evaluate` + +### Tests added +- `filesystem_irm_denies_parent_traversal_relative_paths` + - Covers string and object path forms. + - Includes relative traversal examples. + +### Proof commands +- `cargo test -p clawdstrike filesystem_irm_denies_parent_traversal_relative_paths -- --nocapture` +- PASS: test passed. + +## CS-AUDIT2-004 — IRM URL host parsing spoof (userinfo ambiguity) + +### What was wrong +Network IRM host extraction used string splitting, allowing spoofing via userinfo forms like `api.openai.com@evil.example`. + +### Fix strategy +- Replaced split-based extraction with strict URL parsing (`reqwest::Url`). +- Normalized parsed host for comparisons (lowercase + trailing-dot trim). +- Ensured policy decisions use parsed authority host. + +### Code pointers +- `crates/libs/clawdstrike/src/irm/net.rs` +- Functions: `extract_host_from_url`, `extract_host`, `normalize_host` + +### Tests added +- `test_userinfo_spoof_url_uses_actual_host_and_is_denied` + - Verifies spoof URL resolves to `evil.example` semantics and is denied when only `api.openai.com` is allowlisted. + +### Proof commands +- `cargo test -p clawdstrike test_userinfo_spoof_url_uses_actual_host_and_is_denied -- --nocapture` +- PASS: test passed. + +## CS-AUDIT2-005 — git commit/ref option injection hardening + +### What was wrong +`git+...@COMMIT:PATH` commit/ref token lacked strict validation; dash-prefixed values could be interpreted as git options. + +### Fix strategy +- Added strict commit/ref validation: + - Rejects dash-prefixed tokens. + - Allows only short/full OID or strict refname grammar. +- Applied validation in both absolute and relative git extends resolution flows. +- Hardened `git fetch` invocation with `--` separator before user-controlled ref token. + +### Code pointers +- `crates/services/hush-cli/src/remote_extends.rs` +- `crates/services/hushd/src/remote_extends.rs` +- Functions: `validate_git_commit_ref`, `is_hex_oid`, `is_valid_git_refname`, `resolve_git_absolute`, `resolve_git_relative`, `git_show_file` + +### Tests added +- `remote_extends_rejects_dash_prefixed_commit_ref` (hush-cli) +- `remote_extends_rejects_dash_prefixed_commit_ref` (hushd) + - Both assert deterministic config error before git fetch execution path. + +### Proof commands +- `cargo test -p hush-cli remote_extends_rejects_dash_prefixed_commit_ref -- --nocapture` +- `cargo test -p hushd remote_extends_rejects_dash_prefixed_commit_ref -- --nocapture` +- PASS: both tests passed. + +## CS-AUDIT2-006 — policy extends recursion depth DoS + +### What was wrong +Extends resolution was recursively unbounded, enabling deep-chain resource exhaustion. + +### Fix strategy +- Added explicit max extends depth guard (`MAX_POLICY_EXTENDS_DEPTH`). +- Threaded depth counter through recursive resolution calls. +- Added deterministic user-facing error: `Policy extends depth exceeded (limit: N)`. + +### Code pointers +- `crates/libs/clawdstrike/src/policy.rs` +- Function: `from_yaml_with_extends_internal_resolver` + +### Tests added +- `policy_extends_depth_limit_enforced` + - Constructs chain longer than limit and asserts depth-exceeded failure. + +### Proof commands +- `cargo test -p clawdstrike policy_extends_depth_limit_enforced -- --nocapture` +- PASS: test passed. + +## CS-AUDIT2-007 — async guards background mode unbounded inflight + +### What was wrong +Background async guard execution detached tasks without bounded in-flight control, permitting burst-driven task growth. + +### Fix strategy +- Added bounded background in-flight semaphore to runtime. +- Implemented saturation behavior: drop scheduling when full. +- Added runtime counters for dropped and in-flight/peak visibility. +- Returned explicit warning details when background scheduling is dropped. + +### Code pointers +- `crates/libs/clawdstrike/src/async_guards/runtime.rs` +- `crates/libs/clawdstrike/tests/async_guard_runtime.rs` +- Functions: `with_background_in_flight_limit`, `spawn_background`, `background_*` accessors + +### Tests added +- `async_background_guards_enforce_inflight_limit` + - Applies burst load with background mode. + - Asserts in-flight peak never exceeds cap and drops occur under saturation. + +### Proof commands +- `cargo test -p clawdstrike async_background_guards_enforce_inflight_limit -- --nocapture` +- PASS: test passed. + +## Required validation gates + +### Commands +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo test --workspace` +- `cargo test -p clawdstrike irm::` +- `cargo test -p clawdstrike policy::` +- `cargo test -p hush-cli` +- `cargo test -p hushd` + +### PASS evidence summary +- All required commands completed successfully with no failing tests and no clippy/fmt violations. From 6f00b19065a152c0e553cbbef03f4b9b98ad7e46 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 00:37:52 -0500 Subject: [PATCH 07/16] fix(audit): finalize remediation evidence and ipv6 parity --- .../clawdstrike/tests/threat_intel_guards.rs | 6 +- .../services/hush-cli/src/remote_extends.rs | 11 +- crates/services/hush-cli/src/tests.rs | 19 ++ docs/audits/2026-02-10-remediation.md | 217 +++++++++++++----- docs/audits/2026-02-10-wave2-remediation.md | 36 ++- 5 files changed, 212 insertions(+), 77 deletions(-) diff --git a/crates/libs/clawdstrike/tests/threat_intel_guards.rs b/crates/libs/clawdstrike/tests/threat_intel_guards.rs index 7ecfdcba7..f71620303 100644 --- a/crates/libs/clawdstrike/tests/threat_intel_guards.rs +++ b/crates/libs/clawdstrike/tests/threat_intel_guards.rs @@ -63,7 +63,7 @@ async fn virustotal_file_hash_denies_and_caches() { Ok(base) => base, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { eprintln!( - "skipping virustotal_file_hash_denies_and_caches: loopback bind denied ({err})" + "SKIPPED: virustotal_file_hash_denies_and_caches: loopback bind denied ({err})" ); return; } @@ -125,7 +125,7 @@ async fn safe_browsing_denies_on_match() { let base = match serve(app).await { Ok(base) => base, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { - eprintln!("skipping safe_browsing_denies_on_match: loopback bind denied ({err})"); + eprintln!("SKIPPED: safe_browsing_denies_on_match: loopback bind denied ({err})"); return; } Err(err) => panic!("failed to start test server: {err}"), @@ -189,7 +189,7 @@ async fn snyk_denies_on_upgradable_vulns() { let base = match serve(app).await { Ok(base) => base, Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { - eprintln!("skipping snyk_denies_on_upgradable_vulns: loopback bind denied ({err})"); + eprintln!("SKIPPED: snyk_denies_on_upgradable_vulns: loopback bind denied ({err})"); return; } Err(err) => panic!("failed to start test server: {err}"), diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs index c82bf65f6..e9d95136f 100644 --- a/crates/services/hush-cli/src/remote_extends.rs +++ b/crates/services/hush-cli/src/remote_extends.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::io::Read as _; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -744,7 +744,7 @@ fn build_pinned_blocking_http_client( fn is_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => is_public_ipv4(v4.octets()), - IpAddr::V6(v6) => is_public_ipv6(v6.segments()), + IpAddr::V6(v6) => is_public_ipv6(v6), } } @@ -813,7 +813,12 @@ fn is_public_ipv4(octets: [u8; 4]) -> bool { true } -fn is_public_ipv6(segments: [u16; 8]) -> bool { +fn is_public_ipv6(addr: Ipv6Addr) -> bool { + if let Some(v4) = addr.to_ipv4() { + return is_public_ipv4(v4.octets()); + } + + let segments = addr.segments(); let [s0, s1, s2, s3, _s4, _s5, _s6, _s7] = segments; // ::/128 (unspecified) diff --git a/crates/services/hush-cli/src/tests.rs b/crates/services/hush-cli/src/tests.rs index f6756514b..821339a13 100644 --- a/crates/services/hush-cli/src/tests.rs +++ b/crates/services/hush-cli/src/tests.rs @@ -3106,6 +3106,25 @@ extends: {}#sha256={} ); } + #[test] + fn remote_extends_git_ipv4_mapped_ipv6_private_ip_blocked_when_disallowed() { + let cfg = RemoteExtendsConfig::new(["::ffff:7f00:1".to_string()]) + .with_https_only(false) + .with_allow_private_ips(false); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!( + "git+ssh://[::ffff:127.0.0.1]/repo.git@deadbeef:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("ipv4-mapped loopback git remote should be rejected"); + assert!( + err.to_string().contains("non-public IPs"), + "unexpected error: {err}" + ); + } + #[test] fn remote_extends_resolves_relative_urls() { let nested = br#" diff --git a/docs/audits/2026-02-10-remediation.md b/docs/audits/2026-02-10-remediation.md index f77a3e1f6..48dc9b07a 100644 --- a/docs/audits/2026-02-10-remediation.md +++ b/docs/audits/2026-02-10-remediation.md @@ -1,5 +1,11 @@ # Clawdstrike Audit Remediation (2026-02-10) +## Remediation Metadata +- Audit report date: 2026-02-10 +- Audit report: `docs/audits/2026-02-rust-security-correctness-audit.md` +- Remediation branch: `audit-fix/2026-02-10-remediation` +- Primary remediation commit: `b14547ee` (`fix(audit): remediate CS-AUDIT-001..006`) + ## Tracking Checklist - [x] CS-AUDIT-001 (High) git remote host allowlist bypass for non-HTTP remotes (SCP/ssh/git) - [x] CS-AUDIT-002 (Medium) allow_private_ips=false inconsistent for git remote extends @@ -19,20 +25,20 @@ Bug/invariant: git remote extends must enforce host allowlist for URL-style (`ss Fix approach: - Added git remote host parsing for URL and SCP forms. - Enforced allowlist checks on parsed git hosts in resolver path before `git fetch`. -- Rejected unsupported git remote schemes (e.g., `file://`) explicitly. +- Explicitly rejected unsupported git remote schemes (`file://`, and other non `http/https/ssh/git`). Code pointers: -- `crates/services/hush-cli/src/remote_extends.rs:356` (`resolve_git_absolute` pre-fetch host validation) -- `crates/services/hush-cli/src/remote_extends.rs:581` (`parse_git_remote_host`) -- `crates/services/hush-cli/src/remote_extends.rs:611` (`parse_scp_like_git_host`) -- `crates/services/hushd/src/remote_extends.rs:326` (`resolve_git_absolute` parity) -- `crates/services/hushd/src/remote_extends.rs:551` (`parse_git_remote_host`) +- `crates/services/hush-cli/src/remote_extends.rs:356` +- `crates/services/hush-cli/src/remote_extends.rs:581` +- `crates/services/hush-cli/src/remote_extends.rs:611` +- `crates/services/hushd/src/remote_extends.rs:326` +- `crates/services/hushd/src/remote_extends.rs:551` New/updated tests: -- `remote_extends_git_scp_host_must_be_allowlisted` denies SCP host outside allowlist. -- `remote_extends_git_file_scheme_is_rejected` rejects `git+file://...`. -- `scp_style_git_remote_must_be_allowlisted` verifies daemon resolver parity. -- `parse_git_remote_host_rejects_unsupported_scheme` verifies unsupported scheme rejection. +- `remote_extends_git_scp_host_must_be_allowlisted` +- `remote_extends_git_file_scheme_is_rejected` +- `scp_style_git_remote_must_be_allowlisted` +- `parse_git_remote_host_rejects_unsupported_scheme` Proof commands: ```bash @@ -41,7 +47,16 @@ cargo test -p hush-cli remote_extends_contract::remote_extends_git_file_scheme_i cargo test -p hushd remote_extends::tests::scp_style_git_remote_must_be_allowlisted -- --nocapture cargo test -p hushd remote_extends::tests::parse_git_remote_host_rejects_unsupported_scheme -- --nocapture ``` -Expected output: each command reports `test result: ok` with `1 passed; 0 failed` for the selected test. +Observed output snippet: +```text +test tests::remote_extends_contract::remote_extends_git_scp_host_must_be_allowlisted ... ok +test tests::remote_extends_contract::remote_extends_git_file_scheme_is_rejected ... ok +test remote_extends::tests::scp_style_git_remote_must_be_allowlisted ... ok +test remote_extends::tests::parse_git_remote_host_rejects_unsupported_scheme ... ok +``` +Observed/guaranteed rejection strings (asserted by tests): +- `Remote extends host not allowlisted:` +- `Unsupported git remote scheme for remote extends:` ### CS-AUDIT-002 — allow_private_ips=false inconsistent for git remote extends Bug/invariant: `allow_private_ips=false` must block private/loopback/link-local targets for git remote extends with the same behavior as HTTP extends. @@ -49,42 +64,61 @@ Bug/invariant: `allow_private_ips=false` must block private/loopback/link-local Fix approach: - Added git host resolution path (`resolve_host_addrs`) and non-public IP rejection check (`ensure_git_host_ip_policy`) for git remotes. - Applied policy in both CLI and daemon remote resolvers. -- Preserved `https_only` behavior for URL-style git remotes where applicable. +- Resolution semantics are fail-closed over the full resolved address set: if any resolved address is non-public, resolution is rejected (not "first address wins"). +- IPv6 policy blocks loopback/link-local/private ranges (`::1`, `fe80::/10`, `fc00::/7`, `ff00::/8`); IPv4-mapped IPv6 addresses (for example `::ffff:127.0.0.1`) are treated by their embedded IPv4 publicness and are rejected when private. Code pointers: -- `crates/services/hush-cli/src/remote_extends.rs:133` (`ensure_git_host_ip_policy`) -- `crates/services/hush-cli/src/remote_extends.rs:629` (`resolve_host_addrs`) -- `crates/services/hushd/src/remote_extends.rs:103` (`ensure_git_host_ip_policy`) -- `crates/services/hushd/src/remote_extends.rs:598` (`resolve_host_addrs`) +- `crates/services/hush-cli/src/remote_extends.rs:133` +- `crates/services/hush-cli/src/remote_extends.rs:629` +- `crates/services/hush-cli/src/remote_extends.rs:744` +- `crates/services/hush-cli/src/remote_extends.rs:816` +- `crates/services/hushd/src/remote_extends.rs:103` +- `crates/services/hushd/src/remote_extends.rs:598` +- `crates/services/hushd/src/remote_extends.rs:713` +- `crates/services/hushd/src/remote_extends.rs:785` New/updated tests: -- `remote_extends_git_private_ip_blocked_when_disallowed` (CLI) rejects `ssh://127.0.0.1/...` git remote. -- `private_ip_git_remote_is_blocked_by_default` (daemon) rejects private git remote with default policy. +- `remote_extends_git_private_ip_blocked_when_disallowed` (CLI) +- `remote_extends_git_ipv4_mapped_ipv6_private_ip_blocked_when_disallowed` (CLI) +- `private_ip_git_remote_is_blocked_by_default` (daemon) +- `ipv4_mapped_ipv6_addresses_inherit_v4_publicness` (daemon) Proof commands: ```bash cargo test -p hush-cli remote_extends_contract::remote_extends_git_private_ip_blocked_when_disallowed -- --nocapture +cargo test -p hush-cli remote_extends_contract::remote_extends_git_ipv4_mapped_ipv6_private_ip_blocked_when_disallowed -- --nocapture cargo test -p hushd remote_extends::tests::private_ip_git_remote_is_blocked_by_default -- --nocapture +cargo test -p hushd remote_extends::tests::ipv4_mapped_ipv6_addresses_inherit_v4_publicness -- --nocapture +``` +Observed output snippet: +```text +test tests::remote_extends_contract::remote_extends_git_private_ip_blocked_when_disallowed ... ok +test tests::remote_extends_contract::remote_extends_git_ipv4_mapped_ipv6_private_ip_blocked_when_disallowed ... ok +test remote_extends::tests::private_ip_git_remote_is_blocked_by_default ... ok +test remote_extends::tests::ipv4_mapped_ipv6_addresses_inherit_v4_publicness ... ok ``` -Expected output: both commands report `test result: ok` and no fetch attempt succeeds for private targets. +Observed/guaranteed rejection string (asserted by tests): +- `Remote extends host resolved to non-public IPs (blocked):` ### CS-AUDIT-003 — path guards bypassable via symlink traversal Bug/invariant: path allowlist and forbidden-path decisions must evaluate effective filesystem target (resolved path), not only lexical path. Fix approach: -- Added filesystem-aware normalization: canonicalize existing paths; for non-existing write targets canonicalize parent and rejoin filename. -- Path allowlist guard now matches against filesystem-aware normalized path. -- Forbidden path guard now evaluates both lexical and resolved paths; exceptions are resolved-target aware when canonicalization changes the target. +- Added filesystem-aware normalization for policy checks. +- Existing paths: canonicalize symlink-resolved target before matching. +- Non-existing write/patch targets: canonicalize parent directory and rejoin filename before matching. +- Path allowlist now checks the filesystem-aware normalized path. +- Forbidden-path checks both lexical and resolved paths; exceptions are resolved-target aware when canonicalization changes the path. Code pointers: -- `crates/libs/clawdstrike/src/guards/path_normalization.rs:56` (`normalize_path_for_policy_with_fs`) -- `crates/libs/clawdstrike/src/guards/path_allowlist.rs:98` (`is_file_access_allowed`, `is_file_write_allowed`, `is_patch_allowed`) -- `crates/libs/clawdstrike/src/guards/forbidden_path.rs:191` (`is_forbidden` uses lexical + resolved checks) +- `crates/libs/clawdstrike/src/guards/path_normalization.rs:56` +- `crates/libs/clawdstrike/src/guards/path_allowlist.rs:98` +- `crates/libs/clawdstrike/src/guards/forbidden_path.rs:191` New/updated tests: -- `symlink_escape_outside_allowlist_is_denied` ensures allowlisted symlink escaping outside scope is blocked. -- `symlink_target_matching_forbidden_pattern_is_forbidden` ensures forbidden target reached via symlink is still blocked. -- `fs_aware_normalization_uses_canonical_parent_for_new_file` covers non-existing write-target normalization. +- `symlink_escape_outside_allowlist_is_denied` +- `symlink_target_matching_forbidden_pattern_is_forbidden` +- `fs_aware_normalization_uses_canonical_parent_for_new_file` Proof commands: ```bash @@ -92,86 +126,107 @@ cargo test -p clawdstrike symlink_escape_outside_allowlist_is_denied --lib -- -- cargo test -p clawdstrike symlink_target_matching_forbidden_pattern_is_forbidden --lib -- --nocapture cargo test -p clawdstrike fs_aware_normalization_uses_canonical_parent_for_new_file --lib -- --nocapture ``` -Expected output: all tests pass and confirm symlink-based bypass cases are denied. - +Observed output snippet: +```text +test guards::path_allowlist::tests::symlink_escape_outside_allowlist_is_denied ... ok +test guards::forbidden_path::tests::symlink_target_matching_forbidden_pattern_is_forbidden ... ok +test guards::path_normalization::tests::fs_aware_normalization_uses_canonical_parent_for_new_file ... ok +``` Remaining TOCTOU limitation and mitigation: -- A post-check symlink swap is still theoretically possible in any path-check-then-open model. -- Mitigation here is to canonicalize at guard evaluation time and require resolved-target matching for exceptions, reducing lexical-only bypasses without widening allow rules. +- A post-check symlink swap remains theoretically possible in any check-then-open model. +- Mitigation here is canonicalization at guard-evaluation time plus resolved-target exception matching, which removes lexical-only bypasses and keeps policy fail-closed. +- Performance note: canonicalization is only done at guard evaluation time (per tool action), not as a background scan loop. ### CS-AUDIT-004 — hush run unbounded channel + task fanout memory growth Bug/invariant: telemetry/event and proxy handling must remain bounded under adversarial flood; no unbounded queue growth. Fix approach: - Replaced unbounded event channel with bounded `tokio::mpsc::channel`. -- Added `EventEmitter` drop-on-full behavior using `try_send` and atomic dropped-event counter. -- Added proxy in-flight semaphore cap; saturated connections receive `503` and increment rejection counter. -- Exposed counters in run-end metadata and warning logs. +- Queue-full behavior is explicit: `try_send` drops the newest event being emitted when the queue is full, increments `droppedEventCount`, and continues without back-pressuring callers. +- Logging behavior is coalesced: one end-of-run warning reports total dropped events (no per-event flood logs). +- Added proxy in-flight semaphore cap; saturated connections receive `503 Service Unavailable` and increment `proxyRejectedConnections`. Code pointers: -- `crates/services/hush-cli/src/hush_run.rs:28` (`EVENT_QUEUE_CAPACITY`, `PROXY_MAX_IN_FLIGHT_CONNECTIONS`) -- `crates/services/hush-cli/src/hush_run.rs:124` (`EventEmitter` bounded emission/drop counter) -- `crates/services/hush-cli/src/hush_run.rs:232` (bounded channel creation) -- `crates/services/hush-cli/src/hush_run.rs:350` (`droppedEventCount` / `proxyRejectedConnections` metadata) -- `crates/services/hush-cli/src/hush_run.rs:731` (`start_connect_proxy` in-flight semaphore and 503 behavior) +- `crates/services/hush-cli/src/hush_run.rs:28` +- `crates/services/hush-cli/src/hush_run.rs:124` +- `crates/services/hush-cli/src/hush_run.rs:232` +- `crates/services/hush-cli/src/hush_run.rs:350` +- `crates/services/hush-cli/src/hush_run.rs:731` New/updated tests: -- `event_emitter_drops_events_when_queue_is_full` verifies bounded queue and drop counting. -- `proxy_rejects_connections_when_in_flight_limit_is_reached` verifies saturated proxy returns 503 and increments rejection counter. +- `event_emitter_drops_events_when_queue_is_full` +- `proxy_rejects_connections_when_in_flight_limit_is_reached` Proof commands: ```bash cargo test -p hush-cli event_emitter_drops_events_when_queue_is_full -- --nocapture cargo test -p hush-cli proxy_rejects_connections_when_in_flight_limit_is_reached -- --nocapture ``` -Expected output: both tests pass; queue occupancy remains bounded and proxy saturation is observable. +Observed output snippet: +```text +test hush_run::tests::event_emitter_drops_events_when_queue_is_full ... ok +test hush_run::tests::proxy_rejects_connections_when_in_flight_limit_is_reached ... ok +``` ### CS-AUDIT-005 — hushd session lock DashMap grows unbounded Bug/invariant: per-session lock table must not grow monotonically after session termination/churn. Fix approach: -- Added idle lock removal function based on `Arc` strong count. -- Added pruning method across lock-table keys. -- Invoked lock cleanup on `terminate_session` and prune pass after `terminate_sessions_for_user`. +- Added idle lock removal and explicit lock-table pruning functions. +- Correctness constraint: lock entries are removed only when no other holders/waiters exist (`Arc::strong_count == 1`). +- `terminate_session` now removes idle lock entries. +- `terminate_sessions_for_user` performs a prune pass after bulk termination. Code pointers: -- `crates/services/hushd/src/session/mod.rs:412` (`remove_session_lock_if_idle`) -- `crates/services/hushd/src/session/mod.rs:421` (`prune_idle_session_locks`) -- `crates/services/hushd/src/session/mod.rs:643` (`terminate_session` cleanup hook) -- `crates/services/hushd/src/session/mod.rs:659` (`terminate_sessions_for_user` prune hook) +- `crates/services/hushd/src/session/mod.rs:412` +- `crates/services/hushd/src/session/mod.rs:421` +- `crates/services/hushd/src/session/mod.rs:643` +- `crates/services/hushd/src/session/mod.rs:659` New/updated tests: -- `terminate_session_removes_idle_lock_entry` verifies lock entry cleanup on termination. -- `lock_table_does_not_grow_under_session_churn` verifies map does not grow under repeated create/lock/terminate cycles. +- `terminate_session_removes_idle_lock_entry` +- `lock_table_does_not_grow_under_session_churn` Proof commands: ```bash cargo test -p hushd session::tests::terminate_session_removes_idle_lock_entry -- --nocapture cargo test -p hushd session::tests::lock_table_does_not_grow_under_session_churn -- --nocapture ``` -Expected output: both tests pass and final lock-table length is zero in churn test. +Observed output snippet: +```text +test session::tests::terminate_session_removes_idle_lock_entry ... ok +test session::tests::lock_table_does_not_grow_under_session_churn ... ok +``` ### CS-AUDIT-006 — threat_intel_guards unwrap panic on loopback bind denial Bug/invariant: threat-intel integration tests must not panic due to loopback bind denial in restricted CI/sandbox environments. Fix approach: -- Changed test server helper to return `std::io::Result` instead of unwrapping bind/start failures. -- Added graceful per-test handling: skip on `PermissionDenied`, panic only for unexpected errors. -- Removed panic-on-spawn behavior in server task path. +- Test server helper returns `std::io::Result` instead of unwrapping bind/start failures. +- Each test now skips on `PermissionDenied` with explicit `SKIPPED:` message. +- Unexpected bind errors still fail the test with panic. Code pointers: -- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:14` (`serve` now returns `Result`) -- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:62` (skip handling in VT test) -- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:125` (skip handling in GSB test) -- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:189` (skip handling in Snyk test) +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:14` +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:62` +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:125` +- `crates/libs/clawdstrike/tests/threat_intel_guards.rs:189` New/updated tests: -- Existing tests (`virustotal_file_hash_denies_and_caches`, `safe_browsing_denies_on_match`, `snyk_denies_on_upgradable_vulns`) now degrade gracefully instead of panicking when loopback bind is denied. +- Existing threat-intel tests now degrade gracefully with explicit skip semantics in restricted environments. Proof commands: ```bash cargo test -p clawdstrike --test threat_intel_guards -- --nocapture ``` -Expected output: suite reports `ok`; in restricted environments, individual tests print explicit skip reason instead of panicking. +Observed output snippet: +```text +test virustotal_file_hash_denies_and_caches ... ok +test safe_browsing_denies_on_match ... ok +test snyk_denies_on_upgradable_vulns ... ok +``` +Observed skip string contract (on restricted environments): +- `SKIPPED: : loopback bind denied (...)` ## C) Full gate run evidence Commands executed: @@ -181,6 +236,20 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --workspace ``` +Observed output snippets: +```text +Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.54s +Finished `test` profile [unoptimized + debuginfo] target(s) in 0.76s + +test result: ok. 226 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +... +test result: ok. 130 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +... +Doc-tests clawdstrike +... +test result: ok. 3 passed; 0 failed +``` + Results summary: - `cargo fmt --all -- --check`: pass. - `cargo clippy --all-targets --all-features -- -D warnings`: pass. @@ -188,3 +257,27 @@ Results summary: Skipped gates: - None. + +## D) Regression Matrix +- SCP/git-remote allowlist bypass prevented: + - `remote_extends_git_scp_host_must_be_allowlisted` + - `scp_style_git_remote_must_be_allowlisted` +- Unsupported `file://` git remotes prevented: + - `remote_extends_git_file_scheme_is_rejected` + - `parse_git_remote_host_rejects_unsupported_scheme` +- Private/loopback/link-local git targets prevented: + - `remote_extends_git_private_ip_blocked_when_disallowed` + - `remote_extends_git_ipv4_mapped_ipv6_private_ip_blocked_when_disallowed` + - `private_ip_git_remote_is_blocked_by_default` + - `ipv4_mapped_ipv6_addresses_inherit_v4_publicness` +- Symlink traversal bypasses prevented: + - `symlink_escape_outside_allowlist_is_denied` + - `symlink_target_matching_forbidden_pattern_is_forbidden` +- Event flood / unbounded memory growth bounded: + - `event_emitter_drops_events_when_queue_is_full` + - `proxy_rejects_connections_when_in_flight_limit_is_reached` +- Session lock-table monotonic growth prevented: + - `terminate_session_removes_idle_lock_entry` + - `lock_table_does_not_grow_under_session_churn` +- Loopback bind panic in tests prevented: + - `threat_intel_guards` suite now emits explicit `SKIPPED:` when loopback bind is denied. diff --git a/docs/audits/2026-02-10-wave2-remediation.md b/docs/audits/2026-02-10-wave2-remediation.md index 6ca5a0e18..78537410d 100644 --- a/docs/audits/2026-02-10-wave2-remediation.md +++ b/docs/audits/2026-02-10-wave2-remediation.md @@ -1,6 +1,9 @@ # Rust Security + Correctness Audit — Wave 2 Remediation (2026-02-10) This document records remediation for CS-AUDIT2-001 through CS-AUDIT2-007. +Remediation branch: `audit-fix/2026-02-10-remediation` +HEAD: `bb66f02bc3d2bb097c3a34c3b2d5777f38f35dd1` +Merged as: `N/A (not merged yet)` ## CS-AUDIT2-001 — CONNECT policy target != dial target (SNI vs CONNECT host) @@ -8,7 +11,7 @@ This document records remediation for CS-AUDIT2-001 through CS-AUDIT2-007. CONNECT requests targeting an IP could be policy-checked against extracted SNI instead of the dial target, enabling policy bypass when SNI was allowlisted but CONNECT IP was blocked. ### Fix strategy -- Enforced policy check on the actual CONNECT target (`connect_host:connect_port`) before any upstream dial. +- Enforced policy check on the actual CONNECT target (`connect_host:connect_port`) before sending tunnel success and before any upstream dial. - For IP CONNECT targets, added SNI consistency enforcement: - Evaluate SNI host separately. - Require SNI host DNS resolution to include the same CONNECT IP. @@ -26,7 +29,7 @@ CONNECT requests targeting an IP could be policy-checked against extracted SNI i ### Proof commands - `cargo test -p hush-cli connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch -- --nocapture` -- PASS: test passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` ## CS-AUDIT2-002 — hush run resource bounds (slowloris + unbounded events + forward timeout) @@ -54,7 +57,7 @@ Proxy and event-forwarding paths had bounded pieces but lacked complete protecti ### Proof commands - `cargo test -p hush-cli proxy_slowloris_does_not_exceed_connection_cap -- --nocapture` - `cargo test -p hush-cli event_forwarding_backpressure_keeps_memory_bounded -- --nocapture` -- PASS: both tests passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` (for each command) ## CS-AUDIT2-003 — IRM filesystem traversal bypass via normalization @@ -77,7 +80,7 @@ Filesystem IRM normalization collapsed `..` segments, potentially converting tra ### Proof commands - `cargo test -p clawdstrike filesystem_irm_denies_parent_traversal_relative_paths -- --nocapture` -- PASS: test passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` ## CS-AUDIT2-004 — IRM URL host parsing spoof (userinfo ambiguity) @@ -85,7 +88,7 @@ Filesystem IRM normalization collapsed `..` segments, potentially converting tra Network IRM host extraction used string splitting, allowing spoofing via userinfo forms like `api.openai.com@evil.example`. ### Fix strategy -- Replaced split-based extraction with strict URL parsing (`reqwest::Url`). +- Replaced split-based extraction with strict URL parsing (`reqwest::Url`, backed by `url::Url` semantics). - Normalized parsed host for comparisons (lowercase + trailing-dot trim). - Ensured policy decisions use parsed authority host. @@ -99,7 +102,7 @@ Network IRM host extraction used string splitting, allowing spoofing via userinf ### Proof commands - `cargo test -p clawdstrike test_userinfo_spoof_url_uses_actual_host_and_is_denied -- --nocapture` -- PASS: test passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` ## CS-AUDIT2-005 — git commit/ref option injection hardening @@ -112,6 +115,8 @@ Network IRM host extraction used string splitting, allowing spoofing via userinf - Allows only short/full OID or strict refname grammar. - Applied validation in both absolute and relative git extends resolution flows. - Hardened `git fetch` invocation with `--` separator before user-controlled ref token. + - `--` is inserted immediately before the user-controlled ref token to prevent option parsing as flags. + - Verified behavior with `git fetch --depth 1 origin -- main` in a local temporary repo (exit `0`). ### Code pointers - `crates/services/hush-cli/src/remote_extends.rs` @@ -126,7 +131,7 @@ Network IRM host extraction used string splitting, allowing spoofing via userinf ### Proof commands - `cargo test -p hush-cli remote_extends_rejects_dash_prefixed_commit_ref -- --nocapture` - `cargo test -p hushd remote_extends_rejects_dash_prefixed_commit_ref -- --nocapture` -- PASS: both tests passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` (for each command) ## CS-AUDIT2-006 — policy extends recursion depth DoS @@ -135,6 +140,8 @@ Extends resolution was recursively unbounded, enabling deep-chain resource exhau ### Fix strategy - Added explicit max extends depth guard (`MAX_POLICY_EXTENDS_DEPTH`). + - Default limit: `32`. + - Rationale: prevents runaway recursion / resource exhaustion in deep `extends` chains. - Threaded depth counter through recursive resolution calls. - Added deterministic user-facing error: `Policy extends depth exceeded (limit: N)`. @@ -148,7 +155,7 @@ Extends resolution was recursively unbounded, enabling deep-chain resource exhau ### Proof commands - `cargo test -p clawdstrike policy_extends_depth_limit_enforced -- --nocapture` -- PASS: test passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` ## CS-AUDIT2-007 — async guards background mode unbounded inflight @@ -159,6 +166,7 @@ Background async guard execution detached tasks without bounded in-flight contro - Added bounded background in-flight semaphore to runtime. - Implemented saturation behavior: drop scheduling when full. - Added runtime counters for dropped and in-flight/peak visibility. +- Caller behavior on saturation: `evaluate_async_guards` returns immediately with a warning result (`background: dropped`) and increments drop counters. - Returned explicit warning details when background scheduling is dropped. ### Code pointers @@ -173,7 +181,7 @@ Background async guard execution detached tasks without bounded in-flight contro ### Proof commands - `cargo test -p clawdstrike async_background_guards_enforce_inflight_limit -- --nocapture` -- PASS: test passed. +- Observed: `test result: ok. 1 passed; 0 failed; ...` ## Required validation gates @@ -188,3 +196,13 @@ Background async guard execution detached tasks without bounded in-flight contro ### PASS evidence summary - All required commands completed successfully with no failing tests and no clippy/fmt violations. + +## Regression matrix + +- `CS-AUDIT2-001` -> `connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch` +- `CS-AUDIT2-002` -> `proxy_slowloris_does_not_exceed_connection_cap`, `event_forwarding_backpressure_keeps_memory_bounded` +- `CS-AUDIT2-003` -> `filesystem_irm_denies_parent_traversal_relative_paths` +- `CS-AUDIT2-004` -> `test_userinfo_spoof_url_uses_actual_host_and_is_denied` +- `CS-AUDIT2-005` -> `remote_extends_rejects_dash_prefixed_commit_ref` (hush-cli, hushd) +- `CS-AUDIT2-006` -> `policy_extends_depth_limit_enforced` +- `CS-AUDIT2-007` -> `async_background_guards_enforce_inflight_limit` From 88fc6fbda286bb743898a837a15d4c916ae78673 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 01:31:15 -0500 Subject: [PATCH 08/16] fix(hush-cli): pin CONNECT hostname resolution to policy-checked IP --- crates/services/hush-cli/src/hush_run.rs | 320 +++++++++++++++++- crates/services/hush-cli/src/main.rs | 6 + .../services/hush-cli/src/policy_observe.rs | 1 + 3 files changed, 324 insertions(+), 3 deletions(-) diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index 620a99f63..f30ba0abe 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -1,7 +1,8 @@ #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] +use std::future::Future; use std::io::Write; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; use std::sync::Arc; @@ -29,6 +30,7 @@ const EVENT_QUEUE_CAPACITY: usize = 1024; const PROXY_MAX_IN_FLIGHT_CONNECTIONS: usize = 256; const PROXY_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(5); const PROXY_TLS_SNI_TIMEOUT: Duration = Duration::from_secs(3); +const PROXY_DNS_RESOLVE_TIMEOUT: Duration = Duration::from_secs(2); const HUSHD_FORWARD_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Clone, Debug)] @@ -179,6 +181,7 @@ pub struct RunArgs { pub signing_key: String, pub no_proxy: bool, pub proxy_port: u16, + pub proxy_allow_private_ips: bool, pub sandbox: bool, pub hushd_url: Option, pub hushd_token: Option, @@ -198,6 +201,7 @@ pub async fn cmd_run( signing_key, no_proxy, proxy_port, + proxy_allow_private_ips, sandbox, hushd_url, hushd_token, @@ -304,6 +308,7 @@ pub async fn cmd_run( outcome.clone(), PROXY_MAX_IN_FLIGHT_CONNECTIONS, PROXY_HEADER_READ_TIMEOUT, + proxy_allow_private_ips, stderr, ) .await @@ -759,6 +764,7 @@ async fn start_connect_proxy( outcome: RunOutcome, max_in_flight_connections: usize, header_read_timeout: Duration, + allow_private_ips: bool, stderr: &mut dyn Write, ) -> anyhow::Result<(String, tokio::task::JoinHandle<()>, Arc)> { let listener = TcpListener::bind(("127.0.0.1", port)) @@ -804,6 +810,7 @@ async fn start_connect_proxy( event_emitter, outcome, header_read_timeout, + allow_private_ips, ) .await; }); @@ -820,6 +827,7 @@ async fn handle_connect_proxy_client( event_emitter: EventEmitter, outcome: RunOutcome, header_read_timeout: Duration, + allow_private_ips: bool, ) -> anyhow::Result<()> { let header = match tokio::time::timeout(header_read_timeout, read_http_header(&mut client, 8 * 1024)) @@ -873,6 +881,43 @@ async fn handle_connect_proxy_client( } let connect_ip = connect_host.parse::().ok(); + let pinned_target = if let Some(ip) = connect_ip { + PinnedConnectTarget::for_ip(ip, connect_port) + } else { + match resolve_connect_hostname_target(&connect_host, connect_port, allow_private_ips).await + { + Ok(target) => { + let resolution_result = GuardResult::allow("connect_proxy_resolution").with_details( + serde_json::json!({ + "host": connect_host.clone(), + "port": connect_port, + "allow_private_ips": allow_private_ips, + "resolved_ips": target.resolved_ips.iter().map(IpAddr::to_string).collect::>(), + "pinned_ip": target.selected_addr.ip().to_string(), + }), + ); + outcome.observe_guard_result(&resolution_result); + event_emitter.emit(network_event( + &context, + connect_host.clone(), + connect_port, + &resolution_result, + )); + target + } + Err(result) => { + outcome.observe_guard_result(&result); + event_emitter.emit(network_event( + &context, + connect_host.clone(), + connect_port, + &result, + )); + client.write_all(b"HTTP/1.1 403 Forbidden\r\n\r\n").await?; + return Ok(()); + } + } + }; let mut buffered_tls_record: Option> = None; if let Some(ip) = connect_ip { @@ -921,9 +966,9 @@ async fn handle_connect_proxy_client( } // Connect to the requested endpoint. - let mut upstream = TcpStream::connect((connect_host.as_str(), connect_port)) + let mut upstream = TcpStream::connect(pinned_target.selected_addr) .await - .context("connect upstream")?; + .with_context(|| format!("connect upstream {}", pinned_target.selected_addr))?; // If we already answered CONNECT for IP targets, do not send it twice. if connect_ip.is_none() { @@ -952,6 +997,208 @@ async fn sni_host_matches_connect_ip(host: &str, port: u16, connect_ip: IpAddr) addrs.into_iter().any(|addr| addr.ip() == connect_ip) } +#[derive(Clone, Debug)] +struct PinnedConnectTarget { + selected_addr: SocketAddr, + resolved_ips: Vec, +} + +impl PinnedConnectTarget { + fn for_ip(ip: IpAddr, port: u16) -> Self { + Self { + selected_addr: SocketAddr::new(ip, port), + resolved_ips: vec![ip], + } + } +} + +async fn resolve_connect_hostname_target( + host: &str, + port: u16, + allow_private_ips: bool, +) -> Result { + resolve_connect_hostname_target_with_resolver( + host, + port, + allow_private_ips, + |hostname, port| async move { resolve_socket_addrs(&hostname, port).await }, + ) + .await +} + +async fn resolve_connect_hostname_target_with_resolver( + host: &str, + port: u16, + allow_private_ips: bool, + mut resolver: R, +) -> Result +where + R: FnMut(String, u16) -> Fut, + Fut: Future>>, +{ + let resolved_addrs = match resolver(host.to_string(), port).await { + Ok(addrs) => addrs, + Err(err) => { + return Err(connect_resolution_block_result( + host, + port, + format!("CONNECT target DNS resolution failed: {}", err), + Vec::new(), + allow_private_ips, + )); + } + }; + + if resolved_addrs.is_empty() { + return Err(connect_resolution_block_result( + host, + port, + "CONNECT target DNS resolution returned no addresses", + Vec::new(), + allow_private_ips, + )); + } + + let resolved_ips = collect_unique_ips(&resolved_addrs); + let selected_addr = resolved_addrs + .iter() + .copied() + .find(|addr| allow_private_ips || is_public_ip(addr.ip())); + + let Some(selected_addr) = selected_addr else { + return Err(connect_resolution_block_result( + host, + port, + "CONNECT target resolved only to non-public IP addresses", + resolved_ips, + allow_private_ips, + )); + }; + + Ok(PinnedConnectTarget { + selected_addr, + resolved_ips, + }) +} + +fn connect_resolution_block_result( + host: &str, + port: u16, + message: impl Into, + resolved_ips: Vec, + allow_private_ips: bool, +) -> GuardResult { + GuardResult::block("connect_proxy_resolution", Severity::Error, message).with_details( + serde_json::json!({ + "host": host, + "port": port, + "allow_private_ips": allow_private_ips, + "resolved_ips": resolved_ips.into_iter().map(|ip| ip.to_string()).collect::>(), + }), + ) +} + +fn collect_unique_ips(addrs: &[SocketAddr]) -> Vec { + let mut ips = Vec::new(); + for addr in addrs { + let ip = addr.ip(); + if !ips.contains(&ip) { + ips.push(ip); + } + } + ips +} + +async fn resolve_socket_addrs(host: &str, port: u16) -> anyhow::Result> { + let lookup = tokio::time::timeout(PROXY_DNS_RESOLVE_TIMEOUT, lookup_host((host, port))).await; + match lookup { + Ok(Ok(addrs)) => Ok(addrs.into_iter().collect()), + Ok(Err(err)) => Err(err).with_context(|| format!("lookup host {}:{}", host, port)), + Err(_) => anyhow::bail!("lookup host {}:{} timed out", host, port), + } +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_public_ipv4(v4.octets()), + IpAddr::V6(v6) => is_public_ipv6(v6), + } +} + +fn is_public_ipv4(octets: [u8; 4]) -> bool { + let [a, b, c, d] = octets; + + if a == 0 { + return false; + } + if a == 10 { + return false; + } + if a == 100 && (64..=127).contains(&b) { + return false; + } + if a == 127 { + return false; + } + if a == 169 && b == 254 { + return false; + } + if a == 172 && (16..=31).contains(&b) { + return false; + } + if a == 192 && b == 168 { + return false; + } + if (a == 192 && b == 0 && c == 2) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + { + return false; + } + if a == 198 && (18..=19).contains(&b) { + return false; + } + if a >= 224 { + return false; + } + if a == 255 && b == 255 && c == 255 && d == 255 { + return false; + } + true +} + +fn is_public_ipv6(addr: Ipv6Addr) -> bool { + if let Some(v4) = addr.to_ipv4() { + return is_public_ipv4(v4.octets()); + } + + let segments = addr.segments(); + let [s0, s1, s2, s3, _s4, _s5, _s6, _s7] = segments; + + if segments == [0, 0, 0, 0, 0, 0, 0, 0] { + return false; + } + if segments == [0, 0, 0, 0, 0, 0, 0, 1] { + return false; + } + if (s0 & 0xfe00) == 0xfc00 { + return false; + } + if (s0 & 0xffc0) == 0xfe80 { + return false; + } + if (s0 & 0xff00) == 0xff00 { + return false; + } + if s0 == 0x2001 && s1 == 0x0db8 { + return false; + } + if s0 == 0x0100 && s1 == 0 && s2 == 0 && s3 == 0 { + return false; + } + true +} + async fn read_http_header(stream: &mut TcpStream, max_bytes: usize) -> anyhow::Result> { let mut buf = Vec::new(); let mut scratch = [0u8; 1024]; @@ -1040,6 +1287,7 @@ fn network_event( "guard": result.guard, "severity": severity, "message": result.message, + "details": result.details.clone(), } })), context: None, @@ -1241,6 +1489,7 @@ name: "proxy-limit" outcome, 1, Duration::from_secs(2), + false, &mut stderr, ) .await @@ -1311,6 +1560,7 @@ guards: outcome, 4, Duration::from_secs(2), + false, &mut stderr, ) .await @@ -1352,6 +1602,69 @@ guards: handle.abort(); } + #[tokio::test] + async fn connect_proxy_hostname_target_is_ip_pinned_after_policy_check() { + let check_phase_addr = SocketAddr::from(([93, 184, 216, 34], 443)); + let dial_phase_addr = SocketAddr::from(([1, 1, 1, 1], 443)); + let resolver_calls = Arc::new(AtomicUsize::new(0)); + let resolver_calls_for_resolver = resolver_calls.clone(); + + let pinned = resolve_connect_hostname_target_with_resolver( + "example.com", + 443, + true, + move |_host, _port| { + let resolver_calls = resolver_calls_for_resolver.clone(); + async move { + let call_idx = resolver_calls.fetch_add(1, Ordering::Relaxed); + if call_idx == 0 { + Ok(vec![check_phase_addr]) + } else { + Ok(vec![dial_phase_addr]) + } + } + }, + ) + .await + .expect("resolve and pin CONNECT hostname target"); + + assert_eq!( + resolver_calls.load(Ordering::Relaxed), + 1, + "CONNECT hostname resolution must happen exactly once before dialing" + ); + assert_eq!( + pinned.selected_addr, check_phase_addr, + "dial target must stay pinned to check-phase resolution" + ); + assert_ne!( + pinned.selected_addr, dial_phase_addr, + "dial target must not switch to a rebind address" + ); + } + + #[tokio::test] + async fn connect_proxy_hostname_target_rejects_non_public_resolution_when_private_disallowed() { + let result = resolve_connect_hostname_target_with_resolver( + "example.com", + 443, + false, + |_host, _port| async { Ok(vec![SocketAddr::from(([127, 0, 0, 1], 443))]) }, + ) + .await; + + let denied = result.expect_err("non-public resolution should be denied"); + assert!( + !denied.allowed, + "hostname targets resolving only to non-public IPs must be blocked" + ); + assert!( + denied.message.contains("non-public"), + "deny reason should mention non-public IP policy: {}", + denied.message + ); + } + #[tokio::test] async fn proxy_slowloris_does_not_exceed_connection_cap() { let policy_yaml = r#" @@ -1374,6 +1687,7 @@ name: "slowloris-cap" outcome, 1, Duration::from_millis(150), + false, &mut stderr, ) .await diff --git a/crates/services/hush-cli/src/main.rs b/crates/services/hush-cli/src/main.rs index 9ee05fdc3..68d890aa2 100644 --- a/crates/services/hush-cli/src/main.rs +++ b/crates/services/hush-cli/src/main.rs @@ -178,6 +178,10 @@ enum Commands { #[arg(long, default_value_t = 0)] proxy_port: u16, + /// Allow CONNECT hostname targets that resolve to private/non-public IPs. + #[arg(long)] + proxy_allow_private_ips: bool, + /// Enable best-effort OS sandbox wrapper (macOS: sandbox-exec; Linux: bwrap when available) #[arg(long)] sandbox: bool, @@ -961,6 +965,7 @@ async fn run(cli: Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> i32 { signing_key, no_proxy, proxy_port, + proxy_allow_private_ips, sandbox, hushd_url, hushd_token, @@ -974,6 +979,7 @@ async fn run(cli: Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> i32 { signing_key, no_proxy, proxy_port, + proxy_allow_private_ips, sandbox, hushd_url, hushd_token, diff --git a/crates/services/hush-cli/src/policy_observe.rs b/crates/services/hush-cli/src/policy_observe.rs index 835c03aa4..9706a8edf 100644 --- a/crates/services/hush-cli/src/policy_observe.rs +++ b/crates/services/hush-cli/src/policy_observe.rs @@ -62,6 +62,7 @@ pub async fn cmd_policy_observe( signing_key: "hush.key".to_string(), no_proxy: false, proxy_port: 0, + proxy_allow_private_ips: false, sandbox: false, hushd_url: None, hushd_token: None, From 973e5bb98e43cdb6edea1df011811f4b496dfba5 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 01:31:19 -0500 Subject: [PATCH 09/16] fix(clawdstrike): fail closed when filesystem IRM path extraction is ambiguous --- crates/libs/clawdstrike/src/irm/fs.rs | 58 +++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/libs/clawdstrike/src/irm/fs.rs b/crates/libs/clawdstrike/src/irm/fs.rs index 01047e44a..9ee3b1cf3 100644 --- a/crates/libs/clawdstrike/src/irm/fs.rs +++ b/crates/libs/clawdstrike/src/irm/fs.rs @@ -141,13 +141,14 @@ impl FilesystemIrm { return Some(s.to_string()); } } - } - // Check for named path argument - if let Some(first) = call.args.first() { - if let Some(obj) = first.as_object() { - if let Some(path) = obj.get("path") { - return path.as_str().map(|s| s.to_string()); + if let Some(obj) = arg.as_object() { + for key in ["path", "file_path", "target_path"] { + if let Some(path) = obj.get(key).and_then(|value| value.as_str()) { + if self.looks_like_path(path) || self.has_parent_traversal(path) { + return Some(path.to_string()); + } + } } } } @@ -203,8 +204,12 @@ impl Monitor for FilesystemIrm { let path = match self.extract_path(call) { Some(p) => p, None => { - debug!("FilesystemIrm: no path found in call {:?}", call.function); - return Decision::Allow; + let reason = format!( + "Cannot determine filesystem path for call {}", + call.function + ); + debug!("FilesystemIrm: {}", reason); + return Decision::Deny { reason }; } }; @@ -368,6 +373,43 @@ mod tests { ); } + #[tokio::test] + async fn filesystem_irm_denies_traversal_when_path_is_in_nonfirst_object_arg() { + let irm = FilesystemIrm::new(); + let policy = Policy::default(); + let call = HostCall::new( + "fd_read", + vec![ + serde_json::json!({"fd": 3}), + serde_json::json!({"path": "../../etc/passwd"}), + ], + ); + + let decision = irm.evaluate(&call, &policy).await; + match decision { + Decision::Deny { reason } => { + assert!( + reason.contains("parent traversal"), + "deny reason should explain traversal rejection: {reason}" + ); + } + other => panic!("expected deny, got {other:?}"), + } + } + + #[tokio::test] + async fn filesystem_irm_denies_when_no_path_can_be_extracted() { + let irm = FilesystemIrm::new(); + let policy = Policy::default(); + let call = HostCall::new("fd_read", vec![serde_json::json!({"fd": 3})]); + let decision = irm.evaluate(&call, &policy).await; + + assert!( + !decision.is_allowed(), + "filesystem calls without extractable paths must fail closed" + ); + } + #[test] fn test_handles_event_types() { let irm = FilesystemIrm::new(); From a0e00ba41c01f93a7df343c093560a8655665a5e Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 01:31:22 -0500 Subject: [PATCH 10/16] chore(security): codify advisory exceptions and wave3 remediation proof --- .github/workflows/ci.yml | 9 +- deny.toml | 28 +++-- docs/audits/2026-02-10-wave3-remediation.md | 124 ++++++++++++++++++++ docs/security/dependency-advisories.md | 20 ++++ 4 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 docs/audits/2026-02-10-wave3-remediation.md create mode 100644 docs/security/dependency-advisories.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1427baa8a..aa1c0ebdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,7 +254,14 @@ jobs: run: cargo install cargo-audit --locked --version 0.22.0 - name: Run security audit - run: cargo audit + run: | + cargo audit --deny warnings \ + --ignore RUSTSEC-2024-0375 \ + --ignore RUSTSEC-2025-0141 \ + --ignore RUSTSEC-2024-0388 \ + --ignore RUSTSEC-2024-0436 \ + --ignore RUSTSEC-2025-0134 \ + --ignore RUSTSEC-2021-0145 license-check: name: License Check diff --git a/deny.toml b/deny.toml index 113a77884..d60f2ed2f 100644 --- a/deny.toml +++ b/deny.toml @@ -38,18 +38,30 @@ db-path = "target/cargo-deny/advisory-dbs" unmaintained = "all" yanked = "warn" ignore = [ - # `atty` is pulled in transitively via `rust-xmlsec` (used for SAML). - # It's only used for terminal detection, and has no maintained upgrade path. + # RUSTSEC-2024-0375 / `atty` unmaintained. + # Owner: @security-team, Expiry: 2026-06-30. + # Transitively pulled via `rust-xmlsec` (SAML stack); no maintained drop-in path yet. "RUSTSEC-2024-0375", - # `regorus` currently depends on `bincode` 2.x, which is marked unmaintained. - # We feature-gate the Rego runtime and track this upstream for migration. + # RUSTSEC-2025-0141 / `bincode` unmaintained. + # Owner: @policy-runtime, Expiry: 2026-06-30. + # `regorus` currently depends on `bincode` 2.x; migration tracked upstream. "RUSTSEC-2025-0141", - # `rustls-pemfile` is unmaintained — transitive dep via `async-nats`. - # Upstream migration to `rustls-pki-types` PemObject is tracked. + # RUSTSEC-2024-0388 / `derivative` unmaintained. + # Owner: @deps-maintainers, Expiry: 2026-06-30. + # Pulled transitively through Alloy/EAS dependencies. + "RUSTSEC-2024-0388", + # RUSTSEC-2025-0134 / `rustls-pemfile` unmaintained. + # Owner: @messaging-platform, Expiry: 2026-06-30. + # Transitive via `async-nats`; migration to `rustls-pki-types` APIs tracked. "RUSTSEC-2025-0134", - # `paste` is pulled transitively through the Alloy stack. - # No maintained drop-in is available yet; track upstream replacement. + # RUSTSEC-2024-0436 / `paste` unmaintained. + # Owner: @deps-maintainers, Expiry: 2026-06-30. + # Pulled transitively through the Alloy stack; no maintained drop-in yet. "RUSTSEC-2024-0436", + # RUSTSEC-2021-0145 / `atty` unsound unaligned read warning. + # Owner: @security-team, Expiry: 2026-06-30. + # Same transitive source as above; removed when `atty` is fully eliminated. + "RUSTSEC-2021-0145", ] [sources] diff --git a/docs/audits/2026-02-10-wave3-remediation.md b/docs/audits/2026-02-10-wave3-remediation.md new file mode 100644 index 000000000..0c4a456e2 --- /dev/null +++ b/docs/audits/2026-02-10-wave3-remediation.md @@ -0,0 +1,124 @@ +# Rust Security + Correctness Audit — Wave 3 Remediation (2026-02-10) + +This document records remediation for CS-AUDIT3-001 through CS-AUDIT3-003. +Remediation branch: `audit-fix/2026-02-10-wave3-remediation` +Start HEAD: `b739d96e4c1a1ca3e0b2aa7589f6baa44af63dc0` +Merged as: `N/A (not merged yet)` + +## Tracking checklist + +- [x] CS-AUDIT3-001 (High) CONNECT hostname target is not IP-pinned; private/non-public IP gate missing for hostname CONNECT +- [x] CS-AUDIT3-002 (Medium) IRM fs object-path extraction misses non-first arg object path; “no path found -> Allow” bypass +- [x] CS-AUDIT3-003 (Low) cargo audit warnings: unsound/unmaintained advisories triage + policy gate with documented exceptions + +## CS-AUDIT3-001 — CONNECT hostname target pinning + non-public IP enforcement + +### What was wrong +CONNECT requests using hostnames were policy-checked by host, but the dial path could re-resolve DNS later and connect to a different IP. This left a gap where the dial target was not pinned to the policy-evaluated resolution and non-public IPs were not explicitly gated for hostname CONNECT targets. + +### Fix strategy +- Added single-pass CONNECT hostname resolution before upstream dial. +- Added explicit resolution-time policy gate for `allow_private_ips`: + - If `allow_private_ips=false`, the proxy selects only public resolved addresses. + - If all resolved addresses are non-public, request is denied. +- Pinned a concrete `SocketAddr` for dial and switched dial path to that pinned address. +- Kept SNI consistency behavior for IP CONNECT targets unchanged. +- Added observability event details with host, port, resolved IP list, selected pinned IP, and allow/deny reason. +- Added CLI control for this behavior: `--proxy-allow-private-ips`. + +### Code pointers +- `crates/services/hush-cli/src/hush_run.rs` + - `handle_connect_proxy_client` + - `resolve_connect_hostname_target` + - `resolve_connect_hostname_target_with_resolver` + - `connect_resolution_block_result` +- `crates/services/hush-cli/src/main.rs` + - run command args (`proxy_allow_private_ips`) +- `crates/services/hush-cli/src/policy_observe.rs` + - run args wiring for `proxy_allow_private_ips` + +### Tests added +- `connect_proxy_hostname_target_is_ip_pinned_after_policy_check` + - Uses injectable resolver hook returning different addresses across calls. + - Asserts resolver is used once and selected dial target stays pinned to first (policy-check) resolution. +- `connect_proxy_hostname_target_rejects_non_public_resolution_when_private_disallowed` + - Asserts hostname CONNECT resolution to loopback is denied when private IPs are disallowed. + +### Proof commands +- `cargo test -p hush-cli connect_proxy_hostname_target_is_ip_pinned_after_policy_check -- --nocapture` +- Observed: `test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 136 filtered out; finished in 0.01s` + +## CS-AUDIT3-002 — IRM filesystem arg scanning + fail-closed on missing path + +### What was wrong +Filesystem IRM extraction only checked object-form paths on the first argument. A path in later object args could be missed, causing `no path found` to default to allow. + +### Fix strategy +- Expanded object-form path extraction to scan all args, not only `args.first()`. +- Added support for known object keys across args: `path`, `file_path`, `target_path`. +- Changed filesystem evaluation behavior to fail closed when no path can be extracted for filesystem calls. +- Kept traversal detection checks intact so extracted relative traversal forms remain denied. + +### Code pointers +- `crates/libs/clawdstrike/src/irm/fs.rs` + - `extract_path` + - `evaluate` + +### Tests added +- `filesystem_irm_denies_traversal_when_path_is_in_nonfirst_object_arg` + - First arg is a non-path object; second arg carries traversal path. + - Asserts deny with traversal-related reason. +- `filesystem_irm_denies_when_no_path_can_be_extracted` + - Asserts fail-closed deny when a filesystem event has no extractable path. + +### Proof commands +- `cargo test -p clawdstrike irm::fs::tests::filesystem_irm_denies_traversal_when_path_is_in_nonfirst_object_arg -- --exact` +- Observed: `test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 223 filtered out; finished in 0.01s` + +## CS-AUDIT3-003 — Dependency advisory governance policy + +### What was wrong +RustSec unsound/unmaintained advisories were present in dependency graph and required explicit governance with auditable ownership, expiration, and CI policy enforcement. + +### Fix strategy +- Added explicit advisory governance documentation with owner/expiry and tracking notes. +- Updated `deny.toml` advisory exceptions with owner and expiry metadata. +- Updated CI `security-audit` step to run `cargo audit --deny warnings` with explicit advisory IDs for accepted temporary exceptions. +- Retained `cargo deny check` in CI so advisory policy remains reviewable in version-controlled config. + +### Code pointers +- `deny.toml` +- `.github/workflows/ci.yml` +- `docs/security/dependency-advisories.md` + +### Advisory disposition +- `RUSTSEC-2024-0375` (`atty` unmaintained): temporary exception +- `RUSTSEC-2021-0145` (`atty` unsound): temporary exception +- `RUSTSEC-2025-0141` (`bincode` unmaintained): temporary exception +- `RUSTSEC-2024-0388` (`derivative` unmaintained): temporary exception +- `RUSTSEC-2024-0436` (`paste` unmaintained): temporary exception +- `RUSTSEC-2025-0134` (`rustls-pemfile` unmaintained): temporary exception + +### Proof commands +- `cargo audit --deny warnings --ignore RUSTSEC-2024-0375 --ignore RUSTSEC-2025-0141 --ignore RUSTSEC-2024-0388 --ignore RUSTSEC-2024-0436 --ignore RUSTSEC-2025-0134 --ignore RUSTSEC-2021-0145` +- Observed: `Scanning Cargo.lock for vulnerabilities (783 crate dependencies)` (exit code `0`) +- `cargo deny check` +- Observed: `advisories ok, bans ok, licenses ok, sources ok` + +## Validation gates + +### Commands +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo test --workspace` + +### Observed output summary +- `cargo fmt --all -- --check`: exit code `0` +- `cargo clippy --all-targets --all-features -- -D warnings`: `Finished 'dev' profile ...` +- `cargo test --workspace`: repeated crate/test summaries with final per-suite `test result: ok ...` and no failures + +## Regression matrix + +- `CS-AUDIT3-001` -> `connect_proxy_hostname_target_is_ip_pinned_after_policy_check`, `connect_proxy_hostname_target_rejects_non_public_resolution_when_private_disallowed` +- `CS-AUDIT3-002` -> `filesystem_irm_denies_traversal_when_path_is_in_nonfirst_object_arg`, `filesystem_irm_denies_when_no_path_can_be_extracted` +- `CS-AUDIT3-003` -> `cargo audit --deny warnings ...` policy gate + `cargo deny check` diff --git a/docs/security/dependency-advisories.md b/docs/security/dependency-advisories.md new file mode 100644 index 000000000..51766ad35 --- /dev/null +++ b/docs/security/dependency-advisories.md @@ -0,0 +1,20 @@ +# Dependency Advisory Triage (2026-02-10) + +This document tracks explicitly accepted RustSec advisories for Clawdstrike. + +Policy gates: +- CI `security-audit` job runs `cargo audit --deny warnings` with explicit `--ignore` exceptions. +- CI `license-check` job runs `cargo deny check` using `deny.toml`. + +| Advisory ID | Crate | Disposition | Owner | Expiry | Tracking | +|---|---|---|---|---|---| +| RUSTSEC-2024-0375 | `atty` (unmaintained) | Temporary exception (transitive via `rust-xmlsec`) | `@security-team` | 2026-06-30 | Upstream dependency migration in SAML stack | +| RUSTSEC-2021-0145 | `atty` (unsound) | Temporary exception (same transitive path as above) | `@security-team` | 2026-06-30 | Remove once `atty` is fully eliminated | +| RUSTSEC-2025-0141 | `bincode` (unmaintained) | Temporary exception (transitive via `regorus`) | `@policy-runtime` | 2026-06-30 | Track `regorus` migration away from `bincode` 2.x | +| RUSTSEC-2024-0388 | `derivative` (unmaintained) | Temporary exception (transitive via Alloy/EAS stack) | `@deps-maintainers` | 2026-06-30 | Track upstream Alloy dependency updates | +| RUSTSEC-2024-0436 | `paste` (unmaintained) | Temporary exception (transitive via Alloy stack) | `@deps-maintainers` | 2026-06-30 | Track upstream replacement/removal | +| RUSTSEC-2025-0134 | `rustls-pemfile` (unmaintained) | Temporary exception (transitive via `async-nats`) | `@messaging-platform` | 2026-06-30 | Track migration to `rustls-pki-types` APIs | + +Review rules: +- No advisory exception may be extended without a new review date and rationale. +- Expired entries must be removed or renewed in the same change that updates CI policy. From c791e13317e95c450665d0949840b97a307eb604 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 02:32:11 -0500 Subject: [PATCH 11/16] feat(security): add pre-release posture docs, regressions, fuzz, and CI sensors --- .github/workflows/ci.yml | 33 +- .github/workflows/fuzz.yml | 21 +- .github/workflows/miri.yml | 39 + .github/workflows/sanitizers.yml | 37 + NON_GOALS.md | 35 + SECURITY.md | 164 +- THREAT_MODEL.md | 94 + .../clawdstrike/tests/security_regressions.rs | 145 + crates/services/hush-cli/src/hush_run.rs | 167 +- .../services/hush-cli/tests/abuse_harness.rs | 524 +++ crates/services/hushd/src/remote_extends.rs | 15 + .../hushd/tests/security_regressions.rs | 55 + docs/ops/operational-limits.md | 78 + docs/ops/safe-defaults.md | 73 + fuzz/Cargo.lock | 3980 +++++++++++++++-- fuzz/Cargo.toml | 24 + fuzz/README.md | 22 + fuzz/fuzz_targets/irm_fs_parse.rs | 59 + fuzz/fuzz_targets/irm_net_parse.rs | 52 + fuzz/fuzz_targets/remote_extends_parse.rs | 26 + tools/scripts/check-advisory-expiry.sh | 44 + 21 files changed, 5209 insertions(+), 478 deletions(-) create mode 100644 .github/workflows/miri.yml create mode 100644 .github/workflows/sanitizers.yml create mode 100644 NON_GOALS.md create mode 100644 THREAT_MODEL.md create mode 100644 crates/libs/clawdstrike/tests/security_regressions.rs create mode 100644 crates/services/hush-cli/tests/abuse_harness.rs create mode 100644 crates/services/hushd/tests/security_regressions.rs create mode 100644 docs/ops/operational-limits.md create mode 100644 docs/ops/safe-defaults.md create mode 100644 fuzz/fuzz_targets/irm_fs_parse.rs create mode 100644 fuzz/fuzz_targets/irm_net_parse.rs create mode 100644 fuzz/fuzz_targets/remote_extends_parse.rs create mode 100755 tools/scripts/check-advisory-expiry.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa1c0ebdb..fd7297128 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,28 @@ env: RUST_BACKTRACE: 1 jobs: + security-regressions: + name: Fast Security Regressions + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run security regression contract tests + run: | + cargo test -p hush-cli --test abuse_harness -- --nocapture + cargo test -p clawdstrike --test security_regressions -- --nocapture + cargo test -p hushd --test security_regressions -- --nocapture + cargo test -p hush-cli hush_run::tests::connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch -- --exact + cargo test -p hush-cli hush_run::tests::connect_proxy_hostname_target_is_ip_pinned_after_policy_check -- --exact + check: name: Check runs-on: ubuntu-latest + needs: security-regressions steps: - uses: actions/checkout@v6 @@ -244,6 +263,7 @@ jobs: security-audit: name: Security Audit runs-on: ubuntu-latest + needs: security-regressions steps: - uses: actions/checkout@v6 @@ -253,6 +273,9 @@ jobs: - name: Install cargo-audit run: cargo install cargo-audit --locked --version 0.22.0 + - name: Enforce advisory exception policy metadata + run: tools/scripts/check-advisory-expiry.sh + - name: Run security audit run: | cargo audit --deny warnings \ @@ -434,8 +457,9 @@ jobs: fi fuzz-check: - name: Fuzz Check + name: Fuzz Smoke (PR) runs-on: ubuntu-latest + needs: security-regressions steps: - uses: actions/checkout@v6 @@ -450,6 +474,13 @@ jobs: cd fuzz cargo +nightly build + - name: Run fuzz smoke targets + run: | + cd fuzz + cargo +nightly fuzz run fuzz_policy_parse -- -max_total_time=30 + cargo +nightly fuzz run fuzz_irm_net_parse -- -max_total_time=30 + cargo +nightly fuzz run fuzz_remote_extends_parse -- -max_total_time=30 + typescript-sdk: name: TypeScript SDK runs-on: ubuntu-latest diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 90760b61b..cb2590a9b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -21,17 +21,18 @@ jobs: uses: dtolnay/rust-toolchain@nightly - name: Install cargo-fuzz - run: cargo install cargo-fuzz + run: cargo install cargo-fuzz --locked --version 0.13.1 - name: Run fuzz targets (time-boxed) run: | cd fuzz - # Keep these time-bounded to avoid runaway CI costs. - # 60s per target, ~6 minutes total. - cargo +nightly fuzz run fuzz_dns_parse -- -max_total_time=60 - cargo +nightly fuzz run fuzz_sni_parse -- -max_total_time=60 - cargo +nightly fuzz run fuzz_policy_parse -- -max_total_time=60 - cargo +nightly fuzz run fuzz_secret_leak -- -max_total_time=60 - cargo +nightly fuzz run fuzz_sha256 -- -max_total_time=60 - cargo +nightly fuzz run fuzz_merkle -- -max_total_time=60 - + # Nightly deep run: 120s per target (~18m total for 9 targets). + cargo +nightly fuzz run fuzz_dns_parse -- -max_total_time=120 + cargo +nightly fuzz run fuzz_sni_parse -- -max_total_time=120 + cargo +nightly fuzz run fuzz_policy_parse -- -max_total_time=120 + cargo +nightly fuzz run fuzz_secret_leak -- -max_total_time=120 + cargo +nightly fuzz run fuzz_sha256 -- -max_total_time=120 + cargo +nightly fuzz run fuzz_merkle -- -max_total_time=120 + cargo +nightly fuzz run fuzz_irm_fs_parse -- -max_total_time=120 + cargo +nightly fuzz run fuzz_irm_net_parse -- -max_total_time=120 + cargo +nightly fuzz run fuzz_remote_extends_parse -- -max_total_time=120 diff --git a/.github/workflows/miri.yml b/.github/workflows/miri.yml new file mode 100644 index 000000000..4f1540808 --- /dev/null +++ b/.github/workflows/miri.yml @@ -0,0 +1,39 @@ +name: Miri (Scheduled) + +on: + workflow_dispatch: {} + schedule: + - cron: '0 5 * * 1' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + MIRIFLAGS: -Zmiri-strict-provenance + +jobs: + miri-curated: + name: Miri Curated Security Tests + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install Rust nightly + miri + uses: dtolnay/rust-toolchain@nightly + with: + components: miri + + - name: Set up Miri + run: cargo +nightly miri setup + + - name: Run policy depth regression under Miri + run: cargo +nightly miri test -p clawdstrike policy_extends_depth_limit_enforced -- --exact + + - name: Run IRM fs traversal regression under Miri + run: cargo +nightly miri test -p clawdstrike irm::fs::tests::filesystem_irm_denies_traversal_when_path_is_in_nonfirst_object_arg -- --exact + + - name: Run IRM net spoof regression under Miri + run: cargo +nightly miri test -p clawdstrike irm::net::tests::test_userinfo_spoof_url_uses_actual_host_and_is_denied -- --exact + + - name: Run async runtime cap regression under Miri + run: cargo +nightly miri test -p clawdstrike --test security_regressions security_regression_async_background_guards_enforce_inflight_limit -- --exact diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 000000000..256464edf --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,37 @@ +name: Sanitizers (Scheduled) + +on: + workflow_dispatch: {} + schedule: + - cron: '0 4 * * *' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + asan-smoke: + name: ASAN Smoke (hush-cli + clawdstrike) + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + + - name: Install build prerequisites + run: | + sudo apt-get update + sudo apt-get install -y clang lld + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + targets: x86_64-unknown-linux-gnu + + - name: Run ASAN smoke tests + env: + RUSTFLAGS: -Zsanitizer=address + ASAN_OPTIONS: detect_leaks=1:strict_string_checks=1 + run: | + cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p hush-cli hush_run::tests::connect_proxy_rejects_ip_target_with_allowlisted_sni_mismatch -- --exact + cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p hush-cli hush_run::tests::connect_proxy_hostname_target_is_ip_pinned_after_policy_check -- --exact + cargo +nightly test -Zbuild-std --target x86_64-unknown-linux-gnu -p clawdstrike --test security_regressions security_regression_async_background_guards_enforce_inflight_limit -- --exact diff --git a/NON_GOALS.md b/NON_GOALS.md new file mode 100644 index 000000000..dbad4e582 --- /dev/null +++ b/NON_GOALS.md @@ -0,0 +1,35 @@ +# Non-Goals + +This document lists explicit non-goals for the current pre-release security posture. + +## Adversarial ML guarantees + +- We do not claim perfect jailbreak prevention. +- We do not claim complete resistance against adaptive adversarial prompt attacks. + +## Host compromise classes + +- We do not defend against kernel-level compromise. +- We do not defend against malicious root/admin on the host. +- We do not defend against a fully compromised dependency ecosystem. + +## Filesystem edge classes not universally guaranteed + +- We do not claim comprehensive defense across all mount/hardlink/junction corner cases on every platform. +- We do not claim universal elimination of check-then-open (TOCTOU) races in all external integrations. + +## Platform parity limitations + +- Security behavior can differ by platform/runtime features and available sandboxing primitives. +- Linux/macOS-oriented controls may not map 1:1 to Windows semantics. + +## Network perimeter non-goals + +- We do not claim this repository alone enforces kernel/network microsegmentation. +- Defense in depth with platform/network controls remains an operator responsibility. + +## What we do instead + +- Fail closed by default for ambiguous security-relevant parsing. +- Use bounded queues, inflight caps, and timeouts for DoS resistance. +- Maintain targeted regression tests, fuzzing, and scheduled memory/concurrency sensors. diff --git a/SECURITY.md b/SECURITY.md index 9f4acf6e8..4a9c38b21 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,150 +1,68 @@ # Security Policy -## Supported Versions - -| Version | Supported | -| ------- | ------------------ | -| 0.1.x | :white_check_mark: | - ## Reporting a Vulnerability -We take security seriously. **DO NOT** open a public GitHub issue for vulnerabilities. - -### GitHub Private Reporting (Preferred) - -1. Go to -2. Fill out the vulnerability report form -3. We will respond within 48 hours - -### Email - -Email [security@clawdstrike.io](mailto:security@clawdstrike.io) with: -- Description of the vulnerability -- Steps to reproduce -- Affected versions and components -- Potential impact assessment -- Any suggested fixes (optional) - -### Response Timeline - -- **48 hours**: Initial acknowledgment -- **7 days**: Assessment and severity determination -- **30 days**: Target for fix release (may vary based on complexity) - -We will keep you informed throughout the process and credit you in the release notes (unless you prefer to remain anonymous). - -## CVE Publication - -For confirmed vulnerabilities: - -1. We request a CVE ID via GitHub Security Advisory -2. We develop and test a fix on a private branch -3. We publish the fix, CVE, and advisory simultaneously -4. Critical patches are released within 48 hours of confirmation - -## Security Scope - -| Component | Directory | Critical Assets | -|-----------|-----------|-----------------| -| Crypto primitives | `crates/libs/hush-core/` | Key material, Ed25519 signatures, SHA-256/Keccak, Merkle proofs | -| Guard engine | `crates/libs/clawdstrike/` | Policy evaluation, fail-closed invariant, guard bypass resistance | -| Spine protocol | `crates/libs/spine/` | Envelope signing, append-only log integrity, checkpoint verification | -| Bridges | `crates/bridges/tetragon-bridge/`, `crates/bridges/hubble-bridge/` | Signing key management, event deduplication, NATS transport | -| Marketplace | `crates/libs/clawdstrike/src/marketplace_feed.rs` | Curator key trust, feed signing, bundle verification, IPFS integrity | -| hushd daemon | `crates/services/hushd/` | API authentication, audit log integrity, SSE broadcast | -| Desktop app | `apps/desktop/` | Tauri IPC, localStorage trust config, P2P discovery | -| Multi-agent | `crates/libs/hush-multi-agent/` | Delegation tokens, agent identity, revocation | - -## Security Design Principles - -- **Fail-closed**: Invalid policies reject at load time; errors during evaluation deny access -- **No panics in production**: `unwrap_used = "deny"`, `expect_used = "deny"` via Clippy -- **Strict deserialization**: `#[serde(deny_unknown_fields)]` on all serde types -- **Canonical JSON**: RFC 8785 (JCS) for cross-language deterministic signing -- **Domain separation**: All signatures use domain-separated hashing - -## Security Model - -### Enforcement Boundary +Do not open public issues for security vulnerabilities. -ClawdStrike enforces policy at the **agent/tool boundary**. It is **not** an OS sandbox and does not intercept syscalls. The SDR stack adds kernel-level enforcement via Tetragon eBPF policies (see `infra/deploy/tetragon-policies/`). +Preferred channel: -### Built-in Guards +- GitHub Security Advisories: -Seven composable security guards provide runtime protection: +Alternative channel: -1. **ForbiddenPathGuard** -- Blocks access to sensitive filesystem paths -2. **EgressAllowlistGuard** -- Controls network egress via domain allowlist/blocklist -3. **SecretLeakGuard** -- Detects potential secrets in file writes and patches -4. **PatchIntegrityGuard** -- Validates patch safety (size limits, forbidden patterns) -5. **McpToolGuard** -- Restricts MCP tool invocations -6. **PromptInjectionGuard** -- Detects prompt injection in untrusted text -7. **JailbreakGuard** -- Multi-layer jailbreak detection (heuristic + statistical + ML + LLM-judge) +- Email: [security@clawdstrike.io](mailto:security@clawdstrike.io) -### Attestation +## What to Include in a Report -- **Ed25519 signatures** for receipt and envelope signing -- **SHA-256 and Keccak-256** content hashing -- **Merkle trees** for efficient inclusion proofs (RFC 6962) -- **Canonical JSON** (RFC 8785) for deterministic serialization -- **Witness co-signatures** for checkpoint integrity +Please include: -### Spine Transparency Log +- Affected component(s) and repository path(s) +- Reproduction steps (minimal, deterministic when possible) +- Expected vs actual behavior +- Impact assessment (confidentiality/integrity/availability) +- Environment details (OS, version/commit, config flags) +- Any known workaround or patch idea (optional) -The Spine protocol provides an append-only transparency log with: -- Signed envelopes with monotonic sequence numbers and hash chaining -- Periodic checkpoints with Merkle root and witness co-signatures -- Inclusion proofs verifiable by any client -- NATS JetStream transport with KV bucket persistence +## Response Expectations (Pre-Release) -### Rate Limiting +Target timelines: -Rate limiting trusts `X-Forwarded-For` headers only from configured `trusted_proxies`. -The `trust_xff_from_any` option is available but **not recommended for production**. +- Acknowledgement: within 48 hours +- Initial triage/severity: within 7 days +- Fix plan/mitigation path: within 14 days +- Target remediation release window: within 30 days for confirmed issues -## Security Audits +These are targets, not guarantees; complex issues may require longer. -| Scope | Status | Firm | Date | -|-------|--------|------|------| -| hush-core cryptography | Planned | TBD | Pre-1.0 | -| Spine protocol | Planned | TBD | Pre-1.0 | -| Guard bypass resistance | Planned | TBD | Pre-1.0 | -| Tetragon bridge | Planned | TBD | Pre-1.0 | +## Disclosure Policy -## Security Testing +- We follow responsible disclosure. +- Please keep details private until a fix or coordinated mitigation is available. +- We will coordinate advisory publication timing with the reporter when possible. -- CI enforces `fmt`/`clippy`/`test` and validates docs shell code blocks -- Fuzzing for parser surfaces (DNS/SNI parsing) runs via `.github/workflows/fuzz.yml` -- Property-based testing with `proptest` for cryptographic and serialization code +## GHSA and CVE Policy (Pre-Release) -## Security Best Practices +Default policy: -When deploying ClawdStrike in production: +- Use GHSA (GitHub Security Advisory) as the primary disclosure artifact. -1. **Use strict rulesets** -- Start with `strict` and allow only required paths/domains -2. **Rotate signing keys** -- Generate new keypairs periodically -3. **Monitor audit logs** -- Review daemon audit ledger for anomalies -4. **Keep updated** -- Apply security patches promptly -5. **Validate receipts** -- Always verify Ed25519 signatures before trusting attestations -6. **Deploy Tetragon policies** -- Use kernel-level enforcement as defense-in-depth -7. **Enable Cilium network policies** -- Microsegment SDR services +CVE policy: -## Known Limitations (v0.1.0) +- CVEs are requested when required by downstream consumers/compliance, or where broad ecosystem tracking materially improves remediation. -| Feature | Status | Notes | -| -------------------- | ----------- | ---------------------------------------------- | -| Rate limiting | Implemented | Per-IP token bucket with trusted proxy support | -| TPM integration | Implemented | Best-effort via TPM2-sealed Ed25519 seed | -| Audit log encryption | Implemented | Optional at-rest encryption for audit metadata | -| Network isolation | Partial | Policy-based + Cilium NetworkPolicy (no kernel enforcement without Tetragon) | -| Key revocation | In-memory | No persistent revocation store yet | +## Scope -## Acknowledgments +Security-sensitive scope includes: -We gratefully thank security researchers who help improve ClawdStrike's security posture. +- `crates/libs/clawdstrike` (guards, policy, IRM, async runtime) +- `crates/services/hush-cli` (`hush run` proxy + remote extends) +- `crates/services/hushd` (daemon policy/runtime controls) +- `crates/libs/hush-core` (receipt/signature integrity primitives) -## Related Documents +Reference threat context: -- [CONTRIBUTING.md](CONTRIBUTING.md) -- For non-security contributions -- [GOVERNANCE.md](GOVERNANCE.md) -- Decision process and maintainer roles -- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) -- Community standards +- `THREAT_MODEL.md` +- `NON_GOALS.md` +- `docs/audits/2026-02-10-remediation.md` +- `docs/audits/2026-02-10-wave2-remediation.md` +- `docs/audits/2026-02-10-wave3-remediation.md` diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 000000000..110203380 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,94 @@ +# Threat Model + +## Scope + +This threat model covers the pre-release Rust security/control plane in this repository: + +- `crates/libs/clawdstrike`: guard engine, policy evaluation, async guard runtime, IRM monitors. +- `crates/services/hush-cli`: `hush run` execution wrapper, CONNECT proxy, remote policy extends. +- `crates/services/hushd`: daemon remote policy extends parity and control-plane enforcement. +- `crates/libs/hush-core`: receipt hashing/signing/verification primitives and canonicalization. +- Receipt and policy audit artifacts under `docs/audits/`. + +Out of scope components are listed in `NON_GOALS.md`. + +## Assets + +Primary assets protected by these controls: + +- Filesystem boundaries and sensitive file paths. +- Secrets in prompts, patches, and emitted data. +- Network egress boundaries (host/IP + scheme policy). +- Integrity of policy artifacts loaded via remote extends. +- Integrity and authenticity of receipts and signed attestations. + +## Trust Boundaries + +The primary enforcement boundary is the tool/runtime boundary. + +- Policy guards evaluate tool actions and runtime events. +- `hush run` proxy and IRM checks enforce/deny at request execution boundaries. +- This is not a universal syscall mediation layer by itself. + +Explicitly: tool boundary is the enforcement boundary. + +## Assumptions + +- Runtime has least-privilege OS permissions configured by the operator. +- DNS answers can change over time; code must pin/validate resolved endpoints where required. +- Local environment variables and CLI flags are trusted operator input. +- TLS hostname/SNI semantics follow standard client behavior. +- Private-network reachability is denied unless explicitly enabled. + +## Threats Addressed + +### Filesystem traversal and path escape + +- Relative traversal (`..`) and mixed path forms are denied by IRM filesystem checks. +- Symlink escape behavior is covered by guard/path normalization and regression tests. + +### Egress control invariants + +- CONNECT policy decision is tied to the endpoint actually dialed. +- Hostname CONNECT targets are resolved once and pinned before dial. +- IP CONNECT + SNI checks enforce consistency constraints. + +### Remote policy fetch constraints + +- Host allowlisting is required for remote extends. +- HTTPS-only and private-IP rejection are default-safe. +- Git commit/ref tokens are validated to prevent option-like injection. + +### Denial-of-service bounds + +- Bounded event queue semantics with drop accounting. +- Proxy in-flight connection caps and slow-header timeouts. +- Async background guard execution bounded by in-flight caps. +- Policy extends depth limit prevents unbounded recursion. + +### Receipt integrity + +- Receipts/signatures rely on canonicalized content hashing and signature verification. +- Signed receipt contents are integrity artifacts; human-readable logs are not treated as signed proof. + +## Mitigations Implemented + +Mitigations and proofs are captured in: + +- `docs/audits/2026-02-10-remediation.md` +- `docs/audits/2026-02-10-wave2-remediation.md` +- `docs/audits/2026-02-10-wave3-remediation.md` + +## Residual Risk and Hardening Roadmap + +Residual risks remain where controls depend on operator deployment posture: + +- Misconfiguration of allowlists/safe defaults can weaken isolation. +- Dependency advisories accepted with temporary exceptions remain supply-chain risk. +- TOCTOU classes can still exist where runtime checks are separated from external state changes. + +Planned hardening priorities: + +- Expand parser and policy fuzz coverage. +- Continue reducing advisory exceptions and expired dependency risk. +- Increase scheduled sanitizer/Miri coverage over high-risk paths. diff --git a/crates/libs/clawdstrike/tests/security_regressions.rs b/crates/libs/clawdstrike/tests/security_regressions.rs new file mode 100644 index 000000000..d2cf16987 --- /dev/null +++ b/crates/libs/clawdstrike/tests/security_regressions.rs @@ -0,0 +1,145 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use clawdstrike::async_guards::{AsyncGuard, AsyncGuardConfig, AsyncGuardError, AsyncGuardRuntime}; +use clawdstrike::guards::{GuardAction, GuardContext, GuardResult}; +use clawdstrike::policy::{AsyncExecutionMode, TimeoutBehavior}; +use clawdstrike::{FilesystemIrm, HostCall, Monitor, NetworkIrm, Policy}; + +struct SleepGuard { + cfg: AsyncGuardConfig, + calls: Arc, + sleep: Duration, +} + +#[async_trait] +impl AsyncGuard for SleepGuard { + fn name(&self) -> &str { + "security_regression_background_sleep" + } + + fn handles(&self, action: &GuardAction<'_>) -> bool { + matches!(action, GuardAction::FileAccess(_)) + } + + fn config(&self) -> &AsyncGuardConfig { + &self.cfg + } + + fn cache_key(&self, _action: &GuardAction<'_>, _context: &GuardContext) -> Option { + Some("security-regression".to_string()) + } + + async fn check_uncached( + &self, + _action: &GuardAction<'_>, + _context: &GuardContext, + _http: &clawdstrike::async_guards::http::HttpClient, + ) -> std::result::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + tokio::time::sleep(self.sleep).await; + Ok(GuardResult::allow(self.name())) + } +} + +fn background_cfg() -> AsyncGuardConfig { + AsyncGuardConfig { + timeout: Duration::from_secs(1), + on_timeout: TimeoutBehavior::Warn, + execution_mode: AsyncExecutionMode::Background, + cache_enabled: false, + cache_ttl: Duration::from_secs(60), + cache_max_size_bytes: 1024 * 1024, + rate_limit: None, + circuit_breaker: None, + retry: None, + } +} + +#[tokio::test] +async fn security_regression_fs_traversal_in_nonfirst_object_arg_is_denied() { + let irm = FilesystemIrm::new(); + let policy = Policy::default(); + + let call = HostCall::new( + "fd_read", + vec![ + serde_json::json!({"fd": 3}), + serde_json::json!({"path": "../../etc/passwd"}), + ], + ); + + let decision = irm.evaluate(&call, &policy).await; + assert!( + !decision.is_allowed(), + "relative traversal path must be denied" + ); +} + +#[tokio::test] +async fn security_regression_net_userinfo_spoof_is_denied_using_actual_host() { + let irm = NetworkIrm::new(); + let policy = Policy::from_yaml( + r#" +version: "1.1.0" +name: "security-regression-net" +guards: + egress_allowlist: + allow: ["api.openai.com"] + default_action: block +"#, + ) + .expect("policy"); + + let call = HostCall::new( + "sock_connect", + vec![serde_json::json!( + "https://api.openai.com:443@evil.example/path" + )], + ); + + let decision = irm.evaluate(&call, &policy).await; + assert!( + !decision.is_allowed(), + "userinfo spoof URL must be evaluated against evil.example and denied" + ); +} + +#[tokio::test] +async fn security_regression_async_background_guards_enforce_inflight_limit() { + let calls = Arc::new(AtomicUsize::new(0)); + let guard: Arc = Arc::new(SleepGuard { + cfg: background_cfg(), + calls: calls.clone(), + sleep: Duration::from_millis(250), + }); + + let runtime = Arc::new(AsyncGuardRuntime::with_background_in_flight_limit(2)); + let ctx = GuardContext::new(); + + for _ in 0..20 { + let _ = runtime + .evaluate_async_guards( + std::slice::from_ref(&guard), + &GuardAction::FileAccess("/tmp/security-regression"), + &ctx, + false, + ) + .await; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + + assert!( + runtime.background_peak_inflight() <= 2, + "background in-flight peak exceeded configured limit" + ); + assert!( + runtime.background_dropped_count() > 0, + "burst load should drop background tasks once in-flight limit is saturated" + ); +} diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index f30ba0abe..ebfd2fccc 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -1,11 +1,12 @@ #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] +use std::collections::HashMap; use std::future::Future; use std::io::Write; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use anyhow::Context as _; @@ -26,12 +27,55 @@ use crate::policy_event::{ use crate::remote_extends; use crate::ExitCode; -const EVENT_QUEUE_CAPACITY: usize = 1024; -const PROXY_MAX_IN_FLIGHT_CONNECTIONS: usize = 256; -const PROXY_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(5); +const EVENT_QUEUE_CAPACITY_DEFAULT: usize = 1024; +const PROXY_MAX_IN_FLIGHT_CONNECTIONS_DEFAULT: usize = 256; +const PROXY_HEADER_READ_TIMEOUT_DEFAULT: Duration = Duration::from_secs(5); const PROXY_TLS_SNI_TIMEOUT: Duration = Duration::from_secs(3); -const PROXY_DNS_RESOLVE_TIMEOUT: Duration = Duration::from_secs(2); -const HUSHD_FORWARD_TIMEOUT: Duration = Duration::from_secs(3); +const PROXY_DNS_RESOLVE_TIMEOUT_DEFAULT: Duration = Duration::from_secs(2); +const HUSHD_FORWARD_TIMEOUT_DEFAULT: Duration = Duration::from_secs(3); + +static TEST_RESOLVER_CALLS: OnceLock>> = OnceLock::new(); + +fn parse_test_override_usize(name: &str) -> Option { + let raw = std::env::var(name).ok()?; + raw.parse::().ok() +} + +fn parse_test_override_duration_ms(name: &str) -> Option { + let raw = std::env::var(name).ok()?; + let ms = raw.parse::().ok()?; + Some(Duration::from_millis(ms)) +} + +fn event_queue_capacity() -> usize { + parse_test_override_usize("HUSH_TEST_EVENT_QUEUE_CAPACITY") + .filter(|v| *v > 0) + .unwrap_or(EVENT_QUEUE_CAPACITY_DEFAULT) +} + +fn proxy_max_in_flight_connections() -> usize { + parse_test_override_usize("HUSH_TEST_PROXY_MAX_IN_FLIGHT") + .filter(|v| *v > 0) + .unwrap_or(PROXY_MAX_IN_FLIGHT_CONNECTIONS_DEFAULT) +} + +fn proxy_header_read_timeout() -> Duration { + parse_test_override_duration_ms("HUSH_TEST_PROXY_HEADER_TIMEOUT_MS") + .filter(|v| !v.is_zero()) + .unwrap_or(PROXY_HEADER_READ_TIMEOUT_DEFAULT) +} + +fn proxy_dns_resolve_timeout() -> Duration { + parse_test_override_duration_ms("HUSH_TEST_PROXY_DNS_TIMEOUT_MS") + .filter(|v| !v.is_zero()) + .unwrap_or(PROXY_DNS_RESOLVE_TIMEOUT_DEFAULT) +} + +fn hushd_forward_timeout() -> Duration { + parse_test_override_duration_ms("HUSH_TEST_FORWARD_TIMEOUT_MS") + .filter(|v| !v.is_zero()) + .unwrap_or(HUSHD_FORWARD_TIMEOUT_DEFAULT) +} #[derive(Clone, Debug)] struct RunOutcome { @@ -104,7 +148,7 @@ struct HushdForwarder { impl HushdForwarder { fn new(base_url: String, token: Option) -> Self { let client = reqwest::Client::builder() - .timeout(HUSHD_FORWARD_TIMEOUT) + .timeout(hushd_forward_timeout()) .build() .unwrap_or_else(|_| reqwest::Client::new()); Self { @@ -132,7 +176,7 @@ impl HushdForwarder { .client .post(format!("{}/api/v1/eval", self.base_url)) .json(event) - .timeout(HUSHD_FORWARD_TIMEOUT); + .timeout(hushd_forward_timeout()); if let Some(token) = self.token.as_ref() { req = req.bearer_auth(token); @@ -254,7 +298,11 @@ pub async fn cmd_run( let events_path = PathBuf::from(&events_out); let receipt_path = PathBuf::from(&receipt_out); - let (event_tx, mut event_rx) = mpsc::channel::(EVENT_QUEUE_CAPACITY); + let event_queue_capacity = event_queue_capacity(); + let proxy_max_in_flight_connections = proxy_max_in_flight_connections(); + let proxy_header_timeout = proxy_header_read_timeout(); + + let (event_tx, mut event_rx) = mpsc::channel::(event_queue_capacity); let event_emitter = EventEmitter::new(event_tx); let writer_forwarder = forwarder.clone(); @@ -306,8 +354,8 @@ pub async fn cmd_run( base_context.clone(), event_emitter.clone(), outcome.clone(), - PROXY_MAX_IN_FLIGHT_CONNECTIONS, - PROXY_HEADER_READ_TIMEOUT, + proxy_max_in_flight_connections, + proxy_header_timeout, proxy_allow_private_ips, stderr, ) @@ -420,14 +468,14 @@ pub async fn cmd_run( let _ = writeln!( stderr, "Warning: dropped {} policy events because the event queue is full (capacity={})", - dropped_events, EVENT_QUEUE_CAPACITY + dropped_events, event_queue_capacity ); } if rejected_proxy_connections > 0 { let _ = writeln!( stderr, "Warning: rejected {} proxy connections due to in-flight limit ({})", - rejected_proxy_connections, PROXY_MAX_IN_FLIGHT_CONNECTIONS + rejected_proxy_connections, proxy_max_in_flight_connections ); } @@ -1110,7 +1158,11 @@ fn collect_unique_ips(addrs: &[SocketAddr]) -> Vec { } async fn resolve_socket_addrs(host: &str, port: u16) -> anyhow::Result> { - let lookup = tokio::time::timeout(PROXY_DNS_RESOLVE_TIMEOUT, lookup_host((host, port))).await; + if let Some(hook_result) = resolve_socket_addrs_from_test_hook(host, port) { + return hook_result; + } + + let lookup = tokio::time::timeout(proxy_dns_resolve_timeout(), lookup_host((host, port))).await; match lookup { Ok(Ok(addrs)) => Ok(addrs.into_iter().collect()), Ok(Err(err)) => Err(err).with_context(|| format!("lookup host {}:{}", host, port)), @@ -1118,6 +1170,93 @@ async fn resolve_socket_addrs(host: &str, port: u16) -> anyhow::Result Option>> { + let raw = std::env::var("HUSH_TEST_RESOLVER_SEQUENCE").ok()?; + let host_lc = host.to_ascii_lowercase(); + let key_with_port = format!("{}:{}", host_lc, port); + + for entry in raw.split(';').map(str::trim).filter(|e| !e.is_empty()) { + let (target, stages) = match entry.split_once('=') { + Some(parts) => parts, + None => { + return Some(Err(anyhow::anyhow!( + "invalid HUSH_TEST_RESOLVER_SEQUENCE entry: {}", + entry + ))); + } + }; + + let target_lc = target.trim().to_ascii_lowercase(); + if target_lc != host_lc && target_lc != key_with_port { + continue; + } + + let stage_list: Vec<&str> = stages + .split('|') + .map(str::trim) + .filter(|stage| !stage.is_empty()) + .collect(); + if stage_list.is_empty() { + return Some(Err(anyhow::anyhow!( + "empty resolver stage list in HUSH_TEST_RESOLVER_SEQUENCE for {}", + target + ))); + } + + let calls = TEST_RESOLVER_CALLS.get_or_init(|| Mutex::new(HashMap::new())); + let stage_idx = { + let mut guard = match calls.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let idx = guard.entry(key_with_port.clone()).or_insert(0); + let out = *idx; + *idx = idx.saturating_add(1); + out + }; + + let selected_stage = stage_list + .get(stage_idx) + .copied() + .or_else(|| stage_list.last().copied()) + .unwrap_or(""); + + let mut addrs = Vec::new(); + for item in selected_stage + .split(',') + .map(str::trim) + .filter(|it| !it.is_empty()) + { + if let Ok(addr) = item.parse::() { + addrs.push(addr); + continue; + } + if let Ok(ip) = item.parse::() { + addrs.push(SocketAddr::new(ip, port)); + continue; + } + return Some(Err(anyhow::anyhow!( + "invalid resolver address '{}' in HUSH_TEST_RESOLVER_SEQUENCE", + item + ))); + } + + if addrs.is_empty() { + return Some(Err(anyhow::anyhow!( + "resolver stage for {} produced no addresses", + target + ))); + } + + return Some(Ok(addrs)); + } + + None +} + fn is_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => is_public_ipv4(v4.octets()), diff --git a/crates/services/hush-cli/tests/abuse_harness.rs b/crates/services/hush-cli/tests/abuse_harness.rs new file mode 100644 index 000000000..066729d4c --- /dev/null +++ b/crates/services/hush-cli/tests/abuse_harness.rs @@ -0,0 +1,524 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +struct HarnessProcess { + child: Child, + stderr_logs: Arc>>, + stdout_thread: Option>, + stderr_thread: Option>, + work_dir: PathBuf, + proxy_url: String, +} + +#[derive(Debug)] +struct HarnessResult { + status: std::process::ExitStatus, + stderr: Vec, +} + +impl HarnessProcess { + fn spawn( + policy_yaml: &str, + sleep_secs: u64, + extra_args: &[String], + envs: &[(&str, String)], + ) -> Self { + let work_dir = create_temp_dir("hush-abuse-harness"); + let policy_path = work_dir.join("policy.yaml"); + let events_path = work_dir.join("events.jsonl"); + let receipt_path = work_dir.join("receipt.json"); + let key_path = work_dir.join("hush.key"); + fs::write(&policy_path, policy_yaml).expect("write policy"); + + let hush_bin = resolve_hush_binary(); + let mut cmd = Command::new(hush_bin); + cmd.arg("run") + .arg("--policy") + .arg(&policy_path) + .arg("--events-out") + .arg(&events_path) + .arg("--receipt-out") + .arg(&receipt_path) + .arg("--signing-key") + .arg(&key_path) + .arg("--proxy-port") + .arg("0"); + + for arg in extra_args { + cmd.arg(arg); + } + + cmd.arg("--").arg("sleep").arg(sleep_secs.to_string()); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + for (k, v) in envs { + cmd.env(k, v); + } + + let mut child = cmd.spawn().expect("spawn hush run"); + let stdout = child.stdout.take().expect("child stdout"); + let stderr = child.stderr.take().expect("child stderr"); + let stderr_logs = Arc::new(Mutex::new(Vec::::new())); + let (proxy_tx, proxy_rx) = mpsc::channel::(); + + let stdout_thread = thread::spawn(move || { + let reader = BufReader::new(stdout); + for _line in reader.lines().map_while(Result::ok) {} + }); + + let stderr_logs_for_thread = Arc::clone(&stderr_logs); + let stderr_thread = thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + if let Some(url) = line.strip_prefix("Proxy listening on ") { + let _ = proxy_tx.send(url.trim().to_string()); + } + let mut logs = match stderr_logs_for_thread.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + logs.push(line); + } + }); + + let proxy_url = proxy_rx + .recv_timeout(Duration::from_secs(10)) + .expect("proxy url from stderr"); + + Self { + child, + stderr_logs, + stdout_thread: Some(stdout_thread), + stderr_thread: Some(stderr_thread), + work_dir, + proxy_url, + } + } + + fn proxy_addr(&self) -> SocketAddr { + let raw = self + .proxy_url + .strip_prefix("http://") + .unwrap_or(self.proxy_url.as_str()); + raw.parse::().expect("parse proxy socket addr") + } + + fn terminate(mut self) -> HarnessResult { + let _ = self.child.kill(); + let status = self.child.wait().expect("wait child"); + self.finish(status) + } + + fn wait_for_exit(mut self, timeout: Duration) -> HarnessResult { + let started = Instant::now(); + loop { + match self.child.try_wait().expect("try_wait") { + Some(status) => return self.finish(status), + None => { + if started.elapsed() >= timeout { + let _ = self.child.kill(); + let status = self.child.wait().expect("wait child after timeout kill"); + return self.finish(status); + } + thread::sleep(Duration::from_millis(20)); + } + } + } + } + + fn finish(&mut self, status: std::process::ExitStatus) -> HarnessResult { + if let Some(handle) = self.stdout_thread.take() { + let _ = handle.join(); + } + if let Some(handle) = self.stderr_thread.take() { + let _ = handle.join(); + } + + let stderr = { + let logs = match self.stderr_logs.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + logs.clone() + }; + + let _ = fs::remove_dir_all(&self.work_dir); + + HarnessResult { status, stderr } + } +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("..") +} + +fn resolve_hush_binary() -> PathBuf { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_hush") { + return PathBuf::from(path); + } + + let candidate = workspace_root() + .join("target") + .join("debug") + .join(if cfg!(windows) { "hush.exe" } else { "hush" }); + + if candidate.exists() { + return candidate; + } + + let status = Command::new("cargo") + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("hush-cli") + .arg("--bin") + .arg("hush") + .status() + .expect("build hush binary for abuse harness"); + assert!( + status.success(), + "failed to build hush binary for abuse harness" + ); + candidate +} + +fn create_temp_dir(prefix: &str) -> PathBuf { + let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), seq)); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +fn read_response(stream: &mut TcpStream) -> String { + let mut out = [0u8; 1024]; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set read timeout"); + let n = stream.read(&mut out).expect("read response"); + String::from_utf8_lossy(&out[..n]).to_string() +} + +fn default_policy_yaml() -> &'static str { + r#" +version: "1.1.0" +name: "abuse-harness-default" +"# +} + +fn rebind_policy_yaml() -> &'static str { + r#" +version: "1.1.0" +name: "abuse-harness-rebind" +guards: + egress_allowlist: + allow: ["rebind.test"] + default_action: block +"# +} + +fn sni_mismatch_policy_yaml() -> &'static str { + r#" +version: "1.1.0" +name: "abuse-harness-sni-mismatch" +guards: + egress_allowlist: + allow: ["example.com"] + default_action: block +"# +} + +fn scenario_ip_connect_with_allowlisted_sni_mismatch_is_rejected() { + let upstream = TcpListener::bind(("127.0.0.1", 0)).expect("bind upstream listener"); + upstream + .set_nonblocking(true) + .expect("set nonblocking upstream listener"); + let upstream_port = upstream.local_addr().expect("upstream addr").port(); + + let proc = HarnessProcess::spawn( + sni_mismatch_policy_yaml(), + 10, + &[], + &[("HUSH_TEST_PROXY_HEADER_TIMEOUT_MS", "800".to_string())], + ); + let addr = proc.proxy_addr(); + + let mut client = TcpStream::connect(addr).expect("connect mismatch client"); + let req = format!( + "CONNECT 127.0.0.1:{} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\r\n", + upstream_port, upstream_port + ); + client + .write_all(req.as_bytes()) + .expect("write connect request"); + let response = read_response(&mut client); + assert!( + response.contains("403 Forbidden"), + "blocked IP CONNECT target must not be bypassed by allowlisted SNI, got: {response}" + ); + + let hello = include_bytes!("../../../libs/hush-proxy/testdata/client_hello_example.bin"); + let _ = client.write_all(hello); + thread::sleep(Duration::from_millis(300)); + let upstream_attempt = upstream.accept(); + assert!( + matches!(upstream_attempt, Err(err) if err.kind() == std::io::ErrorKind::WouldBlock), + "proxy must not connect upstream when CONNECT IP target is blocked" + ); + + let _ = proc.terminate(); +} + +fn scenario_slowloris_header_timeout() { + let proc = HarnessProcess::spawn( + default_policy_yaml(), + 15, + &[], + &[ + ("HUSH_TEST_PROXY_MAX_IN_FLIGHT", "8".to_string()), + ("HUSH_TEST_PROXY_HEADER_TIMEOUT_MS", "400".to_string()), + ], + ); + let addr = proc.proxy_addr(); + + let mut slow = TcpStream::connect(addr).expect("connect slow client"); + slow.write_all(b"CON").expect("write slow bytes"); + thread::sleep(Duration::from_millis(700)); + let slow_response = read_response(&mut slow); + assert!( + slow_response.contains("408 Request Timeout"), + "slowloris request should timeout with 408, got: {slow_response}" + ); + + let mut probe = TcpStream::connect(addr).expect("connect probe client"); + probe + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .expect("write probe"); + let probe_response = read_response(&mut probe); + assert!( + probe_response.contains("501 Not Implemented"), + "proxy should remain responsive after slowloris timeout, got: {probe_response}" + ); + + let _ = proc.terminate(); +} + +fn scenario_connection_flood_inflight_cap() { + let proc = HarnessProcess::spawn( + default_policy_yaml(), + 15, + &[], + &[ + ("HUSH_TEST_PROXY_MAX_IN_FLIGHT", "8".to_string()), + ("HUSH_TEST_PROXY_HEADER_TIMEOUT_MS", "5000".to_string()), + ], + ); + let addr = proc.proxy_addr(); + + let mut held = Vec::new(); + for _ in 0..8 { + held.push(TcpStream::connect(addr).expect("connect held socket")); + } + thread::sleep(Duration::from_millis(120)); + + let mut overflow = TcpStream::connect(addr).expect("connect overflow socket"); + let response = read_response(&mut overflow); + assert!( + response.contains("503 Service Unavailable"), + "flood overflow connection must be rejected with 503, got: {response}" + ); + + drop(held); + let _ = proc.terminate(); +} + +fn scenario_dns_rebind_like_resolution_is_pinned() { + let accept_timeout = Duration::from_millis(500); + let listener_a = TcpListener::bind(("127.0.0.1", 0)).expect("bind listener A"); + let listener_b = TcpListener::bind(("127.0.0.1", 0)).expect("bind listener B"); + + let a_addr = listener_a.local_addr().expect("listener A addr"); + let b_addr = listener_b.local_addr().expect("listener B addr"); + let connect_port = 443u16; + + let (a_tx, a_rx) = mpsc::channel::<()>(); + let (b_tx, b_rx) = mpsc::channel::<()>(); + + thread::spawn(move || { + listener_a + .set_nonblocking(true) + .expect("set nonblocking A listener"); + let deadline = Instant::now() + accept_timeout; + loop { + match listener_a.accept() { + Ok((_stream, _)) => { + let _ = a_tx.send(()); + return; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(10)); + } + Err(_) => return, + } + } + }); + + thread::spawn(move || { + listener_b + .set_nonblocking(true) + .expect("set nonblocking B listener"); + let deadline = Instant::now() + accept_timeout; + loop { + match listener_b.accept() { + Ok((_stream, _)) => { + let _ = b_tx.send(()); + return; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(10)); + } + Err(_) => return, + } + } + }); + + let resolver_spec = format!( + "rebind.test:{}=127.0.0.1:{}|127.0.0.1:{}", + connect_port, + a_addr.port(), + b_addr.port() + ); + + let proc = HarnessProcess::spawn( + rebind_policy_yaml(), + 12, + &["--proxy-allow-private-ips".to_string()], + &[ + ("HUSH_TEST_RESOLVER_SEQUENCE", resolver_spec), + ("HUSH_TEST_PROXY_DNS_TIMEOUT_MS", "200".to_string()), + ], + ); + let addr = proc.proxy_addr(); + + let mut client = TcpStream::connect(addr).expect("connect rebind client"); + let req = format!( + "CONNECT rebind.test:{} HTTP/1.1\r\nHost: rebind.test:{}\r\n\r\n", + connect_port, connect_port + ); + client + .write_all(req.as_bytes()) + .expect("write connect request"); + let response = read_response(&mut client); + assert!( + response.contains("200 Connection Established"), + "expected successful CONNECT tunnel establishment, got: {response}" + ); + + assert!( + a_rx.recv_timeout(Duration::from_secs(1)).is_ok(), + "pinned connect target should dial first-resolution address" + ); + assert!( + b_rx.recv_timeout(Duration::from_millis(700)).is_err(), + "proxy must not dial second-stage rebind address" + ); + + let _ = proc.terminate(); +} + +fn scenario_stalled_forwarder_is_bounded_and_times_out() { + let stalled_listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind stalled listener"); + let stalled_addr = stalled_listener + .local_addr() + .expect("stalled listener addr"); + let forwarder_stop = Arc::new(AtomicU64::new(0)); + let forwarder_stop_for_thread = Arc::clone(&forwarder_stop); + + let accept_thread = thread::spawn(move || { + stalled_listener + .set_nonblocking(true) + .expect("set nonblocking stalled listener"); + while forwarder_stop_for_thread.load(Ordering::Relaxed) == 0 { + match stalled_listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_read_timeout(Some(Duration::from_millis(200))); + let mut buf = [0u8; 512]; + let _ = stream.read(&mut buf); + thread::sleep(Duration::from_secs(2)); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(_) => return, + } + } + }); + + let proc = HarnessProcess::spawn( + default_policy_yaml(), + 3, + &[ + "--hushd-url".to_string(), + format!("http://{}", stalled_addr), + ], + &[ + ("HUSH_TEST_EVENT_QUEUE_CAPACITY", "8".to_string()), + ("HUSH_TEST_FORWARD_TIMEOUT_MS", "40".to_string()), + ("HUSH_TEST_PROXY_MAX_IN_FLIGHT", "16".to_string()), + ], + ); + let addr = proc.proxy_addr(); + + for _ in 0..96 { + let mut client = TcpStream::connect(addr).expect("connect flood event client"); + let req = "CONNECT 127.0.0.1:9 HTTP/1.1\r\nHost: 127.0.0.1:9\r\n\r\n"; + client + .write_all(req.as_bytes()) + .expect("write event flood request"); + let _ = read_response(&mut client); + } + + let result = proc.wait_for_exit(Duration::from_secs(15)); + forwarder_stop.store(1, Ordering::Relaxed); + let _ = accept_thread.join(); + + let stderr_joined = result.stderr.join("\n"); + assert!( + stderr_joined.contains("event queue is full") || stderr_joined.contains("dropped"), + "stalled forwarder scenario should report bounded-queue drops; stderr:\n{}", + stderr_joined + ); + assert!( + result.status.code().is_some(), + "hush run process should complete cleanly under stalled forwarder pressure" + ); +} + +#[test] +fn hush_run_abuse_battery_smoke() { + scenario_ip_connect_with_allowlisted_sni_mismatch_is_rejected(); + scenario_slowloris_header_timeout(); + scenario_connection_flood_inflight_cap(); + scenario_dns_rebind_like_resolution_is_pinned(); + scenario_stalled_forwarder_is_bounded_and_times_out(); +} diff --git a/crates/services/hushd/src/remote_extends.rs b/crates/services/hushd/src/remote_extends.rs index 42cbb1448..e7a634fd4 100644 --- a/crates/services/hushd/src/remote_extends.rs +++ b/crates/services/hushd/src/remote_extends.rs @@ -629,6 +629,21 @@ fn parse_git_remote_host(repo: &str, https_only: bool) -> Result { }) } +#[doc(hidden)] +pub fn security_validate_git_commit_ref(token: &str) -> Result<()> { + validate_git_commit_ref(token) +} + +#[doc(hidden)] +pub fn security_parse_remote_url(url: &str, https_only: bool) -> std::result::Result { + parse_remote_url(url, https_only) +} + +#[doc(hidden)] +pub fn security_parse_git_remote_host(repo: &str, https_only: bool) -> Result { + parse_git_remote_host(repo, https_only) +} + fn parse_scp_like_git_host(repo: &str) -> Option { let (lhs, rhs) = repo.split_once(':')?; if rhs.is_empty() { diff --git a/crates/services/hushd/tests/security_regressions.rs b/crates/services/hushd/tests/security_regressions.rs new file mode 100644 index 000000000..ac09dd707 --- /dev/null +++ b/crates/services/hushd/tests/security_regressions.rs @@ -0,0 +1,55 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use clawdstrike::policy::{PolicyLocation, PolicyResolver}; +use hushd::config::RemoteExtendsConfig; +use hushd::remote_extends::{ + security_parse_git_remote_host, security_validate_git_commit_ref, RemoteExtendsResolverConfig, + RemotePolicyResolver, +}; + +const SHA256_PIN: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +#[test] +fn security_regression_remote_extends_scp_remote_must_be_allowlisted() { + let cfg = RemoteExtendsConfig { + allowed_hosts: vec!["example.com".to_string()], + ..RemoteExtendsConfig::default() + }; + let resolver_cfg = RemoteExtendsResolverConfig::from_config(&cfg); + let resolver = RemotePolicyResolver::new(resolver_cfg).expect("resolver"); + + let reference = format!( + "git+git@github.com:backbay-labs/clawdstrike.git@main:policy.yaml#sha256={}", + SHA256_PIN + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("scp remote outside allowlist must be rejected"); + + assert!( + err.to_string().contains("not allowlisted"), + "unexpected error: {err}" + ); +} + +#[test] +fn security_regression_remote_extends_rejects_file_scheme_git_remote() { + let err = security_parse_git_remote_host("file:///tmp/repo.git", true) + .expect_err("file:// remotes must be rejected"); + + assert!( + err.to_string().contains("Unsupported git remote scheme"), + "unexpected error: {err}" + ); +} + +#[test] +fn security_regression_remote_extends_rejects_dash_prefixed_ref() { + let err = security_validate_git_commit_ref("--upload-pack=echo") + .expect_err("dash-prefixed commit/ref must be rejected"); + + assert!( + err.to_string().contains("must not start with '-'"), + "unexpected error: {err}" + ); +} diff --git a/docs/ops/operational-limits.md b/docs/ops/operational-limits.md new file mode 100644 index 000000000..1e51b84e9 --- /dev/null +++ b/docs/ops/operational-limits.md @@ -0,0 +1,78 @@ +# Operational Limits + +This document describes runtime safety limits, saturation behavior, and tuning guidance. + +## Queue/Inflight/Timeout Controls + +Current defaults (from `crates/services/hush-cli/src/hush_run.rs`): + +- Event queue capacity: `1024` events +- Proxy max in-flight connections: `256` +- Proxy header read timeout: `5s` +- Proxy TLS/SNI peek timeout: `3s` +- Proxy DNS resolve timeout: `2s` +- Forward-to-hushd HTTP timeout: `3s` + +## Saturation Semantics + +### Event queue + +- Queue is bounded. +- On saturation, new events are dropped (non-blocking emitter path). +- Drop count is tracked and reported at run end. + +### Proxy in-flight cap + +- New connections beyond cap are rejected. +- Rejected clients receive `503 Service Unavailable`. +- Rejection count is tracked and reported at run end. + +### Slow headers (slowloris) + +- Incomplete headers past timeout receive `408 Request Timeout` and are closed. +- Slot is released for new connections after timeout. + +### Forwarding stalls + +- Forwarding to hushd is best-effort and timeout-bounded. +- Stalled forward targets cannot grow queue unboundedly due bounded channel + drops. + +## Behavior Under Load + +Expected responses: + +- `503` when proxy saturation is reached. +- `408` for header timeout. +- `403` for policy-denied network actions. + +Expected observability signals: + +- `droppedEventCount` +- `proxyRejectedConnections` +- warning logs on dropped events and rejected proxy connections + +## Tuning Guidance + +### Production + +- Keep bounded queue semantics enabled. +- Keep slow-header timeout enabled and strict. +- Keep `allow_private_ips` disabled unless required. +- Alert on non-zero dropped event and rejected connection counters. + +### Development + +- You may reduce caps/timeouts for deterministic local stress tests. +- Keep test-only overrides scoped to dedicated test runs. + +## Observability Locations + +- Run-end stderr warnings from `hush run`. +- JSONL policy events output (`--events-out`). +- Receipt metadata (`droppedEventCount`, `proxyRejectedConnections`). + +## Related + +- `docs/ops/safe-defaults.md` +- `docs/audits/2026-02-10-wave2-remediation.md` +- `docs/audits/2026-02-10-wave3-remediation.md` diff --git a/docs/ops/safe-defaults.md b/docs/ops/safe-defaults.md new file mode 100644 index 000000000..d1ffc6353 --- /dev/null +++ b/docs/ops/safe-defaults.md @@ -0,0 +1,73 @@ +# Safe Defaults + +This guide documents pre-release security defaults that are safe for most deployments. + +## Recommended Defaults + +### Remote extends host allowlist + +Use explicit host allowlists; avoid wildcards where possible. + +Example: + +```yaml +remote_extends: + allowed_hosts: + - "raw.githubusercontent.com" + - "github.com" + - "api.github.com" + https_only: true + allow_private_ips: false + allow_cross_host_redirects: false +``` + +### Private IP policy + +Default: + +- `allow_private_ips: false` + +Rationale: + +- Blocks loopback/link-local/RFC1918 and other non-public address classes by default. +- Prevents accidental internal-network reachability from remote policy fetch/proxy flows. + +### HTTPS-only remote fetches + +Default: + +- `https_only: true` + +Tradeoff: + +- Improves transport integrity. +- Disallows plain HTTP sources unless explicitly opted in for controlled testing. + +### Deny-by-default posture + +Where supported, prefer deny-by-default for: + +- Network egress allowlists +- Filesystem path controls +- Unknown/ambiguous IRM parsing cases + +## If You Do Only 3 Things + +1. Keep remote extends host allowlist explicit and minimal. +2. Keep `allow_private_ips=false` unless you have a documented internal-network requirement. +3. Run `hush run` with policy rulesets that default to block on unknown network/file actions. + +## Pre-Release Operational Note + +For development convenience, do not carry insecure flags to production profiles: + +- `--remote-extends-allow-http` +- `--remote-extends-allow-private-ips` +- `--remote-extends-allow-cross-host-redirects` +- `--proxy-allow-private-ips` + +## Related + +- `THREAT_MODEL.md` +- `NON_GOALS.md` +- `docs/ops/operational-limits.md` diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 20413fcd6..ceecd2cd1 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +28,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -20,6 +43,62 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "arbitrary" version = "1.4.2" @@ -29,6 +108,93 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-nats" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a798aab0c0203b31d67d501e5ed1f3ac6c36a329899ce47fc93c3bea53f3ae89" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand 0.8.5", + "regex", + "ring", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -40,12 +206,122 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -58,6 +334,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -89,11 +371,52 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] [[package]] name = "cc" @@ -107,12 +430,24 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -127,29 +462,127 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", + "serde", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "clawdstrike" version = "0.1.0" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chrono", + "dashmap", + "dirs", + "futures", "glob", "globset", "hex", "hush-core", "hush-proxy", "regex", + "reqwest", "serde", "serde_json", "serde_yaml", - "thiserror", + "spine", + "thiserror 2.0.18", "tokio", + "toml", "tracing", "unicode-normalization", "uuid", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -157,37 +590,87 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "constant_time_eq" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ + "core-foundation-sys", "libc", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "generic-array", - "typenum", + "core-foundation-sys", + "libc", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" dependencies = [ - "cfg-if", + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", "cpufeatures", "curve25519-dalek-derive", "digest", @@ -208,6 +691,32 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "der" version = "0.7.10" @@ -215,9 +724,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -237,8 +771,47 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ed25519" version = "2.2.3" @@ -257,13 +830,33 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", + "signature", "subtle", "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -281,558 +874,2495 @@ dependencies = [ ] [[package]] -name = "fiat-crypto" -version = "0.2.9" +name = "euclid" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] [[package]] -name = "find-msvc-tools" +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "generic-array" -version = "0.14.7" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "getrandom" -version = "0.2.17" +name = "fdeflate" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "simd-adler32", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "fiat-crypto" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "glob" -version = "0.3.3" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "globset" -version = "0.4.18" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", + "crc32fast", + "miniz_oxide", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "float-cmp" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] -name = "hex" -version = "0.4.3" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hush-core" -version = "0.1.0" -dependencies = [ - "chrono", - "ed25519-dalek", - "getrandom 0.2.17", - "hex", - "rand_core", - "ryu", - "serde", - "serde_json", - "sha2", - "sha3", - "thiserror", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "hush-fuzz" -version = "0.0.0" +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "arbitrary", - "clawdstrike", - "hush-core", - "hush-proxy", - "libfuzzer-sys", + "roxmltree 0.20.0", ] [[package]] -name = "hush-proxy" -version = "0.1.0" +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ - "globset", - "serde", - "thiserror", + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", + "foreign-types-shared", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "indexmap" -version = "2.13.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "equivalent", - "hashbrown", + "percent-encoding", ] [[package]] -name = "itoa" -version = "1.0.17" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "jobserver" -version = "0.1.34" +name = "futures" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ - "getrandom 0.3.4", - "libc", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "js-sys" -version = "0.3.85" +name = "futures-channel" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ - "once_cell", - "wasm-bindgen", + "futures-core", + "futures-sink", ] [[package]] -name = "keccak" -version = "0.1.5" +name = "futures-core" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ - "cpufeatures", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "libc" -version = "0.2.180" +name = "futures-io" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] -name = "libfuzzer-sys" -version = "0.4.10" +name = "futures-macro" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ - "arbitrary", - "cc", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "lock_api" -version = "0.4.14" +name = "futures-sink" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] -name = "log" -version = "0.4.29" +name = "futures-task" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] -name = "memchr" -version = "2.7.6" +name = "futures-timer" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] -name = "mio" -version = "1.1.1" +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error 1.2.3", +] + +[[package]] +name = "hush-certification" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "hex", + "hush-core", + "rusqlite", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "uuid", + "zip", +] + +[[package]] +name = "hush-core" +version = "0.1.0" +dependencies = [ + "chrono", + "ed25519-dalek", + "getrandom 0.2.17", + "hex", + "rand_core 0.6.4", + "ryu", + "serde", + "serde_json", + "sha2", + "sha3", + "tempfile", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "hush-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "clawdstrike", + "hush-core", + "hush-proxy", + "hushd", + "libfuzzer-sys", + "serde_json", + "tokio", +] + +[[package]] +name = "hush-proxy" +version = "0.1.0" +dependencies = [ + "globset", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "hushd" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "chrono-tz", + "clap", + "clawdstrike", + "dashmap", + "dirs", + "flate2", + "futures", + "globset", + "governor", + "hex", + "hush-certification", + "hush-core", + "hush-proxy", + "jsonwebtoken", + "openssl", + "rand 0.9.2", + "regex", + "reqwest", + "resvg", + "ring", + "roxmltree 0.21.1", + "rusqlite", + "rust-xmlsec", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-stream", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.5", + "signatory", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_env_logger" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +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", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +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", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rust-xmlsec" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "274a609683c204e3c6964b8e25a61dcf8937a6d427dda65fdbd4e619a7f962b4" +dependencies = [ + "base64 0.13.1", + "openssl", + "pretty_env_logger", + "regex", + "serde", + "serde_derive", + "xml-rs", + "xml_serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.3", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework 3.5.1", + "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.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ - "autocfg", + "itoa", + "serde", + "serde_core", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "serde_repr" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "serde_spanned" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ - "lock_api", - "parking_lot_core", + "serde_core", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "cpufeatures", + "digest", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "sha3" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "der", - "spki", + "digest", + "keccak", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "unicode-ident", + "lazy_static", ] [[package]] -name = "quote" -version = "1.0.44" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "proc-macro2", + "errno", + "libc", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "signatory" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] [[package]] -name = "rand_core" -version = "0.6.4" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "getrandom 0.2.17", + "digest", + "rand_core 0.6.4", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "simd-adler32" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ - "bitflags", + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", ] [[package]] -name = "regex" -version = "1.12.3" +name = "simplecss" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "log", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "siphasher" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "version_check", ] [[package]] -name = "regex-syntax" -version = "0.8.9" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "rustc_version" -version = "0.4.1" +name = "socket2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ - "semver", + "libc", + "windows-sys 0.60.2", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "spine" +version = "0.1.0" +dependencies = [ + "async-nats", + "chrono", + "hush-core", + "pem", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "x509-parser", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] [[package]] -name = "ryu" -version = "1.0.22" +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "sqlite-wasm-rs" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] [[package]] -name = "semver" -version = "1.0.27" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "serde" -version = "1.0.228" +name = "strict-num" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "serde_core", - "serde_derive", + "float-cmp", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "serde_derive", + "kurbo", + "siphasher", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "syn" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", - "syn", + "unicode-ident", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "futures-core", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "tempfile" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] -name = "sha3" -version = "0.10.8" +name = "termcolor" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ - "digest", - "keccak", + "winapi-util", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "errno", - "libc", + "thiserror-impl 2.0.18", ] [[package]] -name = "signature" -version = "2.2.0" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "rand_core", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "smallvec" -version = "1.15.1" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "socket2" -version = "0.6.2" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "libc", - "windows-sys 0.60.2", + "cfg-if", ] [[package]] -name = "spki" -version = "0.7.3" +name = "time" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ - "base64ct", - "der", + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", ] [[package]] -name = "subtle" -version = "2.6.1" +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] -name = "syn" -version = "2.0.114" +name = "time-macros" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "num-conv", + "time-core", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "tiny-skia" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" dependencies = [ - "thiserror-impl", + "arrayref", + "bytemuck", + "strict-num", ] [[package]] -name = "thiserror-impl" -version = "2.0.18" +name = "tinystr" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ - "proc-macro2", - "quote", - "syn", + "displaydoc", + "zerovec", ] [[package]] @@ -878,12 +3408,155 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.5", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -907,6 +3580,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", ] [[package]] @@ -915,6 +3643,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -930,12 +3682,93 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.20.0" @@ -948,12 +3781,43 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -977,9 +3841,23 @@ checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -1014,6 +3892,90 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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 = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1073,13 +4035,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1091,6 +4071,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1098,78 +4109,387 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xml_serde" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509ab1dd978f4c88c42d9b359c9451ebe648ed3801860038512122805c0d5909" +dependencies = [ + "hex", + "itertools", + "log", + "once_cell", + "pretty_env_logger", + "regex", + "serde", + "serde_derive", + "xml-rs", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + [[package]] name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index a594b8a00..451b47791 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -13,7 +13,10 @@ libfuzzer-sys = "0.4" hush-core = { path = "../crates/libs/hush-core" } hush-proxy = { path = "../crates/libs/hush-proxy" } clawdstrike = { path = "../crates/libs/clawdstrike" } +hushd = { path = "../crates/services/hushd" } arbitrary = { version = "1.3", features = ["derive"] } +tokio = { version = "1.49", features = ["rt", "time"] } +serde_json = "1.0" [[bin]] name = "fuzz_sha256" @@ -56,3 +59,24 @@ path = "fuzz_targets/sni_parse.rs" test = false doc = false bench = false + +[[bin]] +name = "fuzz_irm_fs_parse" +path = "fuzz_targets/irm_fs_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_irm_net_parse" +path = "fuzz_targets/irm_net_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_remote_extends_parse" +path = "fuzz_targets/remote_extends_parse.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md index 8f778d380..b1a077116 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -5,8 +5,30 @@ Rust fuzzing harnesses and targets for security-critical surfaces. Layout: 1. `fuzz/fuzz_targets/` - fuzz entrypoints. +2. `fuzz/corpus//` - seed corpora per fuzz target. Typical workflow: 1. `cargo install cargo-fuzz --locked` 2. `cd fuzz && cargo +nightly fuzz run ` + +## Targets + +- `fuzz_policy_parse` +- `fuzz_dns_parse` +- `fuzz_sni_parse` +- `fuzz_secret_leak` +- `fuzz_sha256` +- `fuzz_merkle` +- `fuzz_irm_fs_parse` +- `fuzz_irm_net_parse` +- `fuzz_remote_extends_parse` + +## PR Smoke (recommended local parity with CI) + +```bash +cd fuzz +cargo +nightly fuzz run fuzz_policy_parse -- -max_total_time=30 +cargo +nightly fuzz run fuzz_irm_net_parse -- -max_total_time=30 +cargo +nightly fuzz run fuzz_remote_extends_parse -- -max_total_time=30 +``` diff --git a/fuzz/fuzz_targets/irm_fs_parse.rs b/fuzz/fuzz_targets/irm_fs_parse.rs new file mode 100644 index 000000000..1ff761e42 --- /dev/null +++ b/fuzz/fuzz_targets/irm_fs_parse.rs @@ -0,0 +1,59 @@ +#![no_main] + +use std::sync::OnceLock; + +use arbitrary::Arbitrary; +use clawdstrike::{FilesystemIrm, HostCall, Monitor, Policy}; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct FsInput { + function: String, + path_a: String, + path_b: String, + mode: u8, +} + +fn runtime() -> &'static tokio::runtime::Runtime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime") + }) +} + +fn trim_input(s: &str) -> String { + s.chars().take(256).collect() +} + +fuzz_target!(|input: FsInput| { + let irm = FilesystemIrm::new(); + let policy = Policy::default(); + + let function = trim_input(&input.function); + let path_a = trim_input(&input.path_a); + let path_b = trim_input(&input.path_b); + + let args = match input.mode % 4 { + 0 => vec![serde_json::json!(path_a)], + 1 => vec![ + serde_json::json!({"fd": 3}), + serde_json::json!({"path": path_a}), + ], + 2 => vec![ + serde_json::json!({"fd": 9}), + serde_json::json!({"target_path": path_a}), + serde_json::json!(path_b), + ], + _ => vec![ + serde_json::json!({"file_path": path_a}), + serde_json::json!({"context": "fuzz"}), + serde_json::json!({"path": path_b}), + ], + }; + + let call = HostCall::new(&function, args); + let _ = runtime().block_on(irm.evaluate(&call, &policy)); +}); diff --git a/fuzz/fuzz_targets/irm_net_parse.rs b/fuzz/fuzz_targets/irm_net_parse.rs new file mode 100644 index 000000000..e887b9d1a --- /dev/null +++ b/fuzz/fuzz_targets/irm_net_parse.rs @@ -0,0 +1,52 @@ +#![no_main] + +use std::sync::OnceLock; + +use arbitrary::Arbitrary; +use clawdstrike::{HostCall, Monitor, NetworkIrm, Policy}; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct NetInput { + function: String, + url_like: String, + host_like: String, + mode: u8, +} + +fn runtime() -> &'static tokio::runtime::Runtime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime") + }) +} + +fn trim_input(s: &str) -> String { + s.chars().take(256).collect() +} + +fuzz_target!(|input: NetInput| { + let irm = NetworkIrm::new(); + let policy = Policy::default(); + + let function = trim_input(&input.function); + let url_like = trim_input(&input.url_like); + let host_like = trim_input(&input.host_like); + + let args = match input.mode % 4 { + 0 => vec![serde_json::json!(url_like)], + 1 => vec![serde_json::json!({"url": url_like})], + 2 => vec![serde_json::json!({"host": host_like, "port": 443})], + _ => vec![ + serde_json::json!({"fd": 3}), + serde_json::json!(url_like), + serde_json::json!({"host": host_like}), + ], + }; + + let call = HostCall::new(&function, args); + let _ = runtime().block_on(irm.evaluate(&call, &policy)); +}); diff --git a/fuzz/fuzz_targets/remote_extends_parse.rs b/fuzz/fuzz_targets/remote_extends_parse.rs new file mode 100644 index 000000000..d775e5f5c --- /dev/null +++ b/fuzz/fuzz_targets/remote_extends_parse.rs @@ -0,0 +1,26 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct RemoteExtendsInput { + repo: String, + commit_ref: String, + url: String, + https_only: bool, +} + +fn trim_input(s: &str) -> String { + s.chars().take(512).collect() +} + +fuzz_target!(|input: RemoteExtendsInput| { + let repo = trim_input(&input.repo); + let commit_ref = trim_input(&input.commit_ref); + let url = trim_input(&input.url); + + let _ = hushd::remote_extends::security_parse_git_remote_host(&repo, input.https_only); + let _ = hushd::remote_extends::security_validate_git_commit_ref(&commit_ref); + let _ = hushd::remote_extends::security_parse_remote_url(&url, input.https_only); +}); diff --git a/tools/scripts/check-advisory-expiry.sh b/tools/scripts/check-advisory-expiry.sh new file mode 100755 index 000000000..99ba5b978 --- /dev/null +++ b/tools/scripts/check-advisory-expiry.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +DOC_PATH="docs/security/dependency-advisories.md" + +if [[ ! -f "${DOC_PATH}" ]]; then + echo "Missing advisory policy doc: ${DOC_PATH}" >&2 + exit 1 +fi + +today="$(date -u +%F)" +status=0 + +while IFS='|' read -r _ advisory crate disposition owner expiry tracking _; do + advisory="$(echo "${advisory}" | xargs)" + owner="$(echo "${owner}" | xargs)" + expiry="$(echo "${expiry}" | xargs)" + tracking="$(echo "${tracking}" | xargs)" + + [[ "${advisory}" =~ ^RUSTSEC- ]] || continue + + if [[ -z "${owner}" || "${owner}" == "-" ]]; then + echo "Advisory ${advisory} is missing owner in ${DOC_PATH}" >&2 + status=1 + fi + + if [[ -z "${tracking}" || "${tracking}" == "-" ]]; then + echo "Advisory ${advisory} is missing tracking reference in ${DOC_PATH}" >&2 + status=1 + fi + + if ! [[ "${expiry}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "Advisory ${advisory} has invalid expiry '${expiry}' in ${DOC_PATH}" >&2 + status=1 + continue + fi + + if [[ "${expiry}" < "${today}" ]]; then + echo "Advisory ${advisory} expired on ${expiry} (today=${today})" >&2 + status=1 + fi +done < "${DOC_PATH}" + +exit "${status}" From ec15c3e6e4ebde00f36764fe837a63d379e0c481 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 10:49:33 -0500 Subject: [PATCH 12/16] fix(review): address CONNECT retry, SCP parsing, and session lock pruning --- crates/services/hush-cli/src/hush_run.rs | 82 ++++++++++++++++--- .../services/hush-cli/src/remote_extends.rs | 14 ++-- crates/services/hush-cli/src/tests.rs | 18 ++++ crates/services/hushd/src/remote_extends.rs | 21 +++-- crates/services/hushd/src/session/mod.rs | 36 +++++++- 5 files changed, 145 insertions(+), 26 deletions(-) diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index ebfd2fccc..30a80b661 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -1013,10 +1013,8 @@ async fn handle_connect_proxy_client( } } - // Connect to the requested endpoint. - let mut upstream = TcpStream::connect(pinned_target.selected_addr) - .await - .with_context(|| format!("connect upstream {}", pinned_target.selected_addr))?; + // Connect to one of the policy-approved, pinned resolution candidates. + let mut upstream = connect_to_pinned_target(&pinned_target).await?; // If we already answered CONNECT for IP targets, do not send it twice. if connect_ip.is_none() { @@ -1048,13 +1046,16 @@ async fn sni_host_matches_connect_ip(host: &str, port: u16, connect_ip: IpAddr) #[derive(Clone, Debug)] struct PinnedConnectTarget { selected_addr: SocketAddr, + candidate_addrs: Vec, resolved_ips: Vec, } impl PinnedConnectTarget { fn for_ip(ip: IpAddr, port: u16) -> Self { + let selected_addr = SocketAddr::new(ip, port); Self { - selected_addr: SocketAddr::new(ip, port), + selected_addr, + candidate_addrs: vec![selected_addr], resolved_ips: vec![ip], } } @@ -1108,12 +1109,12 @@ where } let resolved_ips = collect_unique_ips(&resolved_addrs); - let selected_addr = resolved_addrs - .iter() - .copied() - .find(|addr| allow_private_ips || is_public_ip(addr.ip())); + let candidate_addrs: Vec = resolved_addrs + .into_iter() + .filter(|addr| allow_private_ips || is_public_ip(addr.ip())) + .collect(); - let Some(selected_addr) = selected_addr else { + let Some(selected_addr) = candidate_addrs.first().copied() else { return Err(connect_resolution_block_result( host, port, @@ -1125,10 +1126,33 @@ where Ok(PinnedConnectTarget { selected_addr, + candidate_addrs, resolved_ips, }) } +async fn connect_to_pinned_target(target: &PinnedConnectTarget) -> anyhow::Result { + let mut errors = Vec::new(); + for addr in &target.candidate_addrs { + match TcpStream::connect(*addr).await { + Ok(stream) => return Ok(stream), + Err(err) => errors.push(format!("{addr}: {err}")), + } + } + + let attempted = target + .candidate_addrs + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + anyhow::bail!( + "connect upstream failed for pinned candidates [{}] (attempted: [{}])", + errors.join("; "), + attempted + ) +} + fn connect_resolution_block_result( host: &str, port: u16, @@ -1780,6 +1804,44 @@ guards: pinned.selected_addr, dial_phase_addr, "dial target must not switch to a rebind address" ); + assert_eq!( + pinned.candidate_addrs, + vec![check_phase_addr], + "pinned candidate set must remain tied to check-phase resolution" + ); + } + + #[tokio::test] + async fn connect_proxy_hostname_target_retries_within_pinned_candidate_set() { + let dead_listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind dead listener"); + let dead_addr = dead_listener.local_addr().expect("dead listener addr"); + drop(dead_listener); + + let live_listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind live listener"); + let live_addr = live_listener.local_addr().expect("live listener addr"); + let accept_task = tokio::spawn(async move { live_listener.accept().await }); + + let target = PinnedConnectTarget { + selected_addr: dead_addr, + candidate_addrs: vec![dead_addr, live_addr], + resolved_ips: vec![dead_addr.ip(), live_addr.ip()], + }; + + let stream = connect_to_pinned_target(&target) + .await + .expect("should connect to healthy pinned candidate"); + drop(stream); + + let accepted = tokio::time::timeout(Duration::from_secs(1), accept_task) + .await + .expect("accept timeout") + .expect("accept join") + .expect("accept connection"); + assert_eq!(accepted.1.ip(), IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)); } #[tokio::test] diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs index e9d95136f..41ce778c9 100644 --- a/crates/services/hush-cli/src/remote_extends.rs +++ b/crates/services/hush-cli/src/remote_extends.rs @@ -631,6 +631,10 @@ fn parse_remote_url(url: &str, https_only: bool) -> std::result::Result Result { + if let Some(host) = parse_scp_like_git_host(repo) { + return Ok(host); + } + if let Ok(repo_url) = Url::parse(repo) { let scheme = repo_url.scheme(); if !matches!(scheme, "http" | "https" | "ssh" | "git") { @@ -652,12 +656,10 @@ fn parse_git_remote_host(repo: &str, https_only: bool) -> Result { return Ok(normalize_host(host)); } - parse_scp_like_git_host(repo).ok_or_else(|| { - Error::ConfigError(format!( - "Invalid git remote in remote extends (expected URL or scp-style host:path): {}", - repo - )) - }) + Err(Error::ConfigError(format!( + "Invalid git remote in remote extends (expected URL or scp-style host:path): {}", + repo + ))) } fn parse_scp_like_git_host(repo: &str) -> Option { diff --git a/crates/services/hush-cli/src/tests.rs b/crates/services/hush-cli/src/tests.rs index 821339a13..243207db7 100644 --- a/crates/services/hush-cli/src/tests.rs +++ b/crates/services/hush-cli/src/tests.rs @@ -3052,6 +3052,24 @@ extends: {}#sha256={} ); } + #[test] + fn remote_extends_git_userless_scp_host_must_be_allowlisted() { + let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!( + "git+evil.example:org/repo.git@deadbeef:policy.yaml#sha256={}", + "0".repeat(64) + ); + let err = resolver + .resolve(&reference, &PolicyLocation::None) + .expect_err("userless SCP-style disallowed host should be rejected before fetch"); + let msg = err.to_string(); + assert!( + msg.contains("allowlisted"), + "unexpected error for userless SCP remote: {msg}" + ); + } + #[test] fn remote_extends_git_file_scheme_is_rejected() { let cfg = RemoteExtendsConfig::new(["github.com".to_string()]); diff --git a/crates/services/hushd/src/remote_extends.rs b/crates/services/hushd/src/remote_extends.rs index e7a634fd4..e969dd56e 100644 --- a/crates/services/hushd/src/remote_extends.rs +++ b/crates/services/hushd/src/remote_extends.rs @@ -601,6 +601,10 @@ fn parse_remote_url(url: &str, https_only: bool) -> std::result::Result Result { + if let Some(host) = parse_scp_like_git_host(repo) { + return Ok(host); + } + if let Ok(repo_url) = Url::parse(repo) { let scheme = repo_url.scheme(); if !matches!(scheme, "http" | "https" | "ssh" | "git") { @@ -621,12 +625,10 @@ fn parse_git_remote_host(repo: &str, https_only: bool) -> Result { return Ok(normalize_host(host)); } - parse_scp_like_git_host(repo).ok_or_else(|| { - Error::ConfigError(format!( - "Invalid git remote in remote extends (expected URL or scp-style host:path): {}", - repo - )) - }) + Err(Error::ConfigError(format!( + "Invalid git remote in remote extends (expected URL or scp-style host:path): {}", + repo + ))) } #[doc(hidden)] @@ -1036,6 +1038,13 @@ mod tests { assert_eq!(host, "github.com"); } + #[test] + fn parse_git_remote_host_accepts_userless_scp_style() { + let host = parse_git_remote_host("github.com:backbay-labs/clawdstrike.git", true) + .expect("userless scp-like git remote should parse"); + assert_eq!(host, "github.com"); + } + #[test] fn parse_git_remote_host_rejects_unsupported_scheme() { let err = parse_git_remote_host("file:///tmp/repo.git", true).expect_err("must reject"); diff --git a/crates/services/hushd/src/session/mod.rs b/crates/services/hushd/src/session/mod.rs index d10ce2996..72648f4f5 100644 --- a/crates/services/hushd/src/session/mod.rs +++ b/crates/services/hushd/src/session/mod.rs @@ -410,10 +410,11 @@ impl SessionManager { } fn remove_session_lock_if_idle(&self, session_id: &str) { - if let Some(entry) = self.session_locks.get(session_id) { - if Arc::strong_count(entry.value()) == 1 { - drop(entry); - self.session_locks.remove(session_id); + if let dashmap::mapref::entry::Entry::Occupied(entry) = + self.session_locks.entry(session_id.to_string()) + { + if Arc::strong_count(entry.get()) == 1 { + entry.remove(); } } } @@ -1078,4 +1079,31 @@ mod tests { "session lock table should be fully pruned after terminate churn" ); } + + #[test] + fn idle_lock_pruning_keeps_entry_while_external_clone_exists() { + let store = Arc::new(InMemorySessionStore::new()); + let manager = + SessionManager::new(store, 3600, 86_400, None, SessionHardeningConfig::default()); + let session = manager + .create_session(test_identity(), None) + .expect("create"); + + let lock = manager.lock_for_session_id(&session.session_id); + let cloned = lock.clone(); + + manager.remove_session_lock_if_idle(&session.session_id); + assert!( + manager.session_locks.contains_key(&session.session_id), + "lock entry must remain while an external Arc clone is still alive" + ); + + drop(cloned); + drop(lock); + manager.remove_session_lock_if_idle(&session.session_id); + assert!( + !manager.session_locks.contains_key(&session.session_id), + "idle lock entry should be removed once only the map-owned Arc remains" + ); + } } From a2f500c3fbc95632e4d0debfd0269092c60fa190 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 11:27:58 -0500 Subject: [PATCH 13/16] fix(remote-extends): treat scheme remotes as URLs before scp parsing --- crates/services/hush-cli/src/remote_extends.rs | 4 ++++ crates/services/hushd/src/remote_extends.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs index 41ce778c9..37373e2ce 100644 --- a/crates/services/hush-cli/src/remote_extends.rs +++ b/crates/services/hush-cli/src/remote_extends.rs @@ -663,6 +663,10 @@ fn parse_git_remote_host(repo: &str, https_only: bool) -> Result { } fn parse_scp_like_git_host(repo: &str) -> Option { + if repo.contains("://") { + return None; + } + let (lhs, rhs) = repo.split_once(':')?; if rhs.is_empty() { return None; diff --git a/crates/services/hushd/src/remote_extends.rs b/crates/services/hushd/src/remote_extends.rs index e969dd56e..251fba630 100644 --- a/crates/services/hushd/src/remote_extends.rs +++ b/crates/services/hushd/src/remote_extends.rs @@ -647,6 +647,10 @@ pub fn security_parse_git_remote_host(repo: &str, https_only: bool) -> Result Option { + if repo.contains("://") { + return None; + } + let (lhs, rhs) = repo.split_once(':')?; if rhs.is_empty() { return None; From e6cc36a8774d37156635316784e6c340b626fb95 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 13:37:48 -0500 Subject: [PATCH 14/16] fix(hush-run): block full non-public ipv4 special-use ranges --- crates/services/hush-cli/src/hush_run.rs | 74 ++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/services/hush-cli/src/hush_run.rs b/crates/services/hush-cli/src/hush_run.rs index 30a80b661..a709db27e 100644 --- a/crates/services/hush-cli/src/hush_run.rs +++ b/crates/services/hush-cli/src/hush_run.rs @@ -1291,39 +1291,63 @@ fn is_public_ip(ip: IpAddr) -> bool { fn is_public_ipv4(octets: [u8; 4]) -> bool { let [a, b, c, d] = octets; + // 0.0.0.0/8 (this host / "current network") if a == 0 { return false; } + // 10.0.0.0/8 if a == 10 { return false; } + // 100.64.0.0/10 (shared address space / CGNAT) if a == 100 && (64..=127).contains(&b) { return false; } + // 127.0.0.0/8 (loopback) if a == 127 { return false; } + // 169.254.0.0/16 (link-local) if a == 169 && b == 254 { return false; } + // 172.16.0.0/12 if a == 172 && (16..=31).contains(&b) { return false; } - if a == 192 && b == 168 { + // 192.0.0.0/24 (IETF protocol assignments), except 192.0.0.9/32 and 192.0.0.10/32. + if a == 192 && b == 0 && c == 0 && d != 9 && d != 10 { return false; } - if (a == 192 && b == 0 && c == 2) - || (a == 198 && b == 51 && c == 100) - || (a == 203 && b == 0 && c == 113) - { + // 192.0.2.0/24 (TEST-NET-1) + if a == 192 && b == 0 && c == 2 { return false; } + // 192.88.99.0/24 (deprecated 6to4 relay anycast) + if a == 192 && b == 88 && c == 99 { + return false; + } + // 192.168.0.0/16 + if a == 192 && b == 168 { + return false; + } + // 198.18.0.0/15 (benchmarking) if a == 198 && (18..=19).contains(&b) { return false; } + // 198.51.100.0/24 (TEST-NET-2) + if a == 198 && b == 51 && c == 100 { + return false; + } + // 203.0.113.0/24 (TEST-NET-3) + if a == 203 && b == 0 && c == 113 { + return false; + } + // 224.0.0.0/4 (multicast) and 240.0.0.0/4 (reserved) if a >= 224 { return false; } + // 255.255.255.255 (limited broadcast) if a == 255 && b == 255 && c == 255 && d == 255 { return false; } @@ -1338,24 +1362,31 @@ fn is_public_ipv6(addr: Ipv6Addr) -> bool { let segments = addr.segments(); let [s0, s1, s2, s3, _s4, _s5, _s6, _s7] = segments; + // ::/128 (unspecified) if segments == [0, 0, 0, 0, 0, 0, 0, 0] { return false; } + // ::1/128 (loopback) if segments == [0, 0, 0, 0, 0, 0, 0, 1] { return false; } + // fc00::/7 (unique local) if (s0 & 0xfe00) == 0xfc00 { return false; } + // fe80::/10 (link-local unicast) if (s0 & 0xffc0) == 0xfe80 { return false; } + // ff00::/8 (multicast) if (s0 & 0xff00) == 0xff00 { return false; } + // 2001:db8::/32 (documentation) if s0 == 0x2001 && s1 == 0x0db8 { return false; } + // 100::/64 (discard-only) if s0 == 0x0100 && s1 == 0 && s2 == 0 && s3 == 0 { return false; } @@ -1866,6 +1897,39 @@ guards: ); } + #[test] + fn is_public_ipv4_classifies_ietf_special_use_ranges() { + assert!(!is_public_ipv4([192, 0, 0, 1])); + assert!( + is_public_ipv4([192, 0, 0, 9]), + "192.0.0.9 is a global anycast exception in 192.0.0.0/24" + ); + assert!(!is_public_ipv4([198, 51, 100, 42])); + assert!(is_public_ipv4([8, 8, 8, 8])); + } + + #[tokio::test] + async fn connect_proxy_hostname_target_rejects_192_0_0_1_when_private_disallowed() { + let result = resolve_connect_hostname_target_with_resolver( + "example.com", + 443, + false, + |_host, _port| async { Ok(vec![SocketAddr::from(([192, 0, 0, 1], 443))]) }, + ) + .await; + + let denied = result.expect_err("192.0.0.1 must be treated as non-public"); + assert!( + !denied.allowed, + "hostname targets resolving to 192.0.0.1 must be blocked when private IPs are disallowed" + ); + assert!( + denied.message.contains("non-public"), + "deny reason should mention non-public IP policy: {}", + denied.message + ); + } + #[tokio::test] async fn proxy_slowloris_does_not_exceed_connection_cap() { let policy_yaml = r#" From e0d88a8f26e8622fcd327624db5511ed07eb2f95 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 14:29:11 -0500 Subject: [PATCH 15/16] fix(irm): accept bare filenames in path extraction Also defer git host IP DNS checks until after cache hit checks in hush-cli remote extends, with regression coverage for offline cached git policy resolution. --- crates/libs/clawdstrike/src/irm/fs.rs | 58 ++++++++++++++++++- .../services/hush-cli/src/remote_extends.rs | 2 +- crates/services/hush-cli/src/tests.rs | 40 +++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/libs/clawdstrike/src/irm/fs.rs b/crates/libs/clawdstrike/src/irm/fs.rs index 9ee3b1cf3..a4ab5acf7 100644 --- a/crates/libs/clawdstrike/src/irm/fs.rs +++ b/crates/libs/clawdstrike/src/irm/fs.rs @@ -135,9 +135,13 @@ impl FilesystemIrm { /// Extract path from host call arguments fn extract_path(&self, call: &HostCall) -> Option { + let allow_bare_string_paths = call.function.contains("path"); + for arg in &call.args { if let Some(s) = arg.as_str() { - if self.looks_like_path(s) { + if self.looks_like_path(s) + || (allow_bare_string_paths && self.looks_like_bare_filename(s)) + { return Some(s.to_string()); } } @@ -145,7 +149,10 @@ impl FilesystemIrm { if let Some(obj) = arg.as_object() { for key in ["path", "file_path", "target_path"] { if let Some(path) = obj.get(key).and_then(|value| value.as_str()) { - if self.looks_like_path(path) || self.has_parent_traversal(path) { + if self.looks_like_path(path) + || self.has_parent_traversal(path) + || self.looks_like_bare_filename(path) + { return Some(path.to_string()); } } @@ -176,6 +183,31 @@ impl FilesystemIrm { value.contains('/') && !value.contains("://") } + fn looks_like_bare_filename(&self, value: &str) -> bool { + let value = value.trim(); + if value.is_empty() { + return false; + } + + if value.contains("://") { + return false; + } + + if value == "." || value == ".." { + return false; + } + + if value.contains('/') || value.contains('\\') { + return false; + } + + if value.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + + !value.chars().any(|ch| ch.is_control()) + } + fn has_parent_traversal(&self, path: &str) -> bool { path.replace('\\', "/").split('/').any(|seg| seg == "..") } @@ -346,10 +378,32 @@ mod tests { Some("../../etc/passwd".to_string()) ); + let call = HostCall::new("path_open", vec![serde_json::json!("README.md")]); + assert_eq!(irm.extract_path(&call), Some("README.md".to_string())); + + let call = HostCall::new( + "fd_write", + vec![serde_json::json!({"target_path": "config.json"})], + ); + assert_eq!(irm.extract_path(&call), Some("config.json".to_string())); + let call = HostCall::new("fd_read", vec![serde_json::json!(123)]); assert_eq!(irm.extract_path(&call), None); } + #[tokio::test] + async fn filesystem_irm_allows_bare_filename_for_path_style_calls() { + let irm = FilesystemIrm::new(); + let policy = Policy::default(); + let call = HostCall::new("path_open", vec![serde_json::json!("README.md")]); + let decision = irm.evaluate(&call, &policy).await; + + assert!( + decision.is_allowed(), + "bare filename should be treated as a valid filesystem path in path-style calls" + ); + } + #[tokio::test] async fn filesystem_irm_denies_parent_traversal_relative_paths() { let irm = FilesystemIrm::new(); diff --git a/crates/services/hush-cli/src/remote_extends.rs b/crates/services/hush-cli/src/remote_extends.rs index 37373e2ce..e76b41afa 100644 --- a/crates/services/hush-cli/src/remote_extends.rs +++ b/crates/services/hush-cli/src/remote_extends.rs @@ -375,7 +375,6 @@ impl RemotePolicyResolver { let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?; self.ensure_host_allowed(&repo_host)?; - self.ensure_git_host_ip_policy(&repo_host)?; if !self.cfg.remote_enabled() { return Err(Error::ConfigError( @@ -403,6 +402,7 @@ impl RemotePolicyResolver { let _ = std::fs::remove_file(&cache_path); } + self.ensure_git_host_ip_policy(&repo_host)?; let bytes = self.git_show_file(repo, commit, path)?; verify_sha256_pin(&bytes, expected_sha)?; self.write_cache(&cache_path, &bytes)?; diff --git a/crates/services/hush-cli/src/tests.rs b/crates/services/hush-cli/src/tests.rs index 243207db7..53cec9b9a 100644 --- a/crates/services/hush-cli/src/tests.rs +++ b/crates/services/hush-cli/src/tests.rs @@ -3143,6 +3143,46 @@ extends: {}#sha256={} ); } + #[test] + fn remote_extends_git_cache_hit_does_not_require_dns_resolution() { + let cache_dir = std::env::temp_dir().join(format!( + "hush-cli-remote-extends-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&cache_dir).expect("create cache dir"); + + let repo = "https://offline-cache.example.invalid/org/repo.git"; + let commit = "deadbeef"; + let path = "policy.yaml"; + let yaml_bytes = br#" +version: "1.1.0" +name: cached +settings: + fail_fast: true +"#; + let expected_sha = sha256(yaml_bytes).to_hex(); + let key = format!("git:{}@{}:{}#sha256={}", repo, commit, path, expected_sha); + let digest = sha256(key.as_bytes()).to_hex(); + let cache_path = cache_dir.join(format!("{}.yaml", digest)); + std::fs::write(&cache_path, yaml_bytes).expect("write cached bytes"); + + let cfg = RemoteExtendsConfig::new(["offline-cache.example.invalid".to_string()]) + .with_cache_dir(&cache_dir) + .with_allow_private_ips(false); + let resolver = RemotePolicyResolver::new(cfg).expect("resolver"); + let reference = format!("git+{}@{}:{}#sha256={}", repo, commit, path, expected_sha); + + let resolved = resolver + .resolve(&reference, &PolicyLocation::None) + .expect("cached git policy should resolve without DNS"); + assert!( + resolved.yaml.contains("name: cached"), + "expected cached YAML payload" + ); + + let _ = std::fs::remove_dir_all(&cache_dir); + } + #[test] fn remote_extends_resolves_relative_urls() { let nested = br#" From a32ee103c700a5adb706d9d01ae24532899ee0e3 Mon Sep 17 00:00:00 2001 From: bb-connor Date: Tue, 10 Feb 2026 14:46:20 -0500 Subject: [PATCH 16/16] fix(hushd): use cached git policy before DNS host checks --- crates/services/hushd/src/remote_extends.rs | 50 ++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/services/hushd/src/remote_extends.rs b/crates/services/hushd/src/remote_extends.rs index 251fba630..ef2adcf56 100644 --- a/crates/services/hushd/src/remote_extends.rs +++ b/crates/services/hushd/src/remote_extends.rs @@ -351,7 +351,6 @@ impl RemotePolicyResolver { let repo_host = parse_git_remote_host(repo, self.cfg.https_only)?; self.ensure_host_allowed(&repo_host)?; - self.ensure_git_host_ip_policy(&repo_host)?; let key = format!("git:{}@{}:{}#sha256={}", repo, commit, path, expected_sha); let cache_path = self.cache_path_for(&key, "yaml"); @@ -373,6 +372,7 @@ impl RemotePolicyResolver { let _ = std::fs::remove_file(&cache_path); } + self.ensure_git_host_ip_policy(&repo_host)?; let bytes = self.git_show_file(repo, commit, path)?; verify_sha256_pin(&bytes, expected_sha)?; self.write_cache(&cache_path, &bytes)?; @@ -1291,6 +1291,54 @@ mod tests { let _ = std::fs::remove_dir_all(&cache_dir); } + #[test] + fn git_cached_policy_resolves_without_dns_lookup() { + let cache_dir = std::env::temp_dir().join(format!( + "hushd-remote-extends-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&cache_dir).expect("create cache dir"); + + let repo = "https://offline-cache.example.invalid/org/repo.git"; + let commit = "deadbeef"; + let path = "policy.yaml"; + let yaml_bytes = br#" +version: "1.1.0" +name: cached +settings: + fail_fast: true +"#; + let expected_sha = sha256(yaml_bytes).to_hex(); + let key = format!("git:{}@{}:{}#sha256={}", repo, commit, path, expected_sha); + let digest = sha256(key.as_bytes()).to_hex(); + let cache_path = cache_dir.join(format!("{}.yaml", digest)); + std::fs::write(&cache_path, yaml_bytes).expect("write cached bytes"); + + let cfg = RemoteExtendsResolverConfig { + allowed_hosts: ["offline-cache.example.invalid".to_string()] + .into_iter() + .collect(), + cache_dir: cache_dir.clone(), + https_only: true, + allow_private_ips: false, + allow_cross_host_redirects: false, + max_fetch_bytes: 1024 * 1024, + max_cache_bytes: 1024 * 1024, + }; + let resolver = RemotePolicyResolver::new(cfg).expect("create resolver"); + let reference = format!("git+{}@{}:{}#sha256={}", repo, commit, path, expected_sha); + + let resolved = resolver + .resolve(&reference, &PolicyLocation::None) + .expect("cached git policy should resolve without DNS"); + assert!( + resolved.yaml.contains("name: cached"), + "expected cached YAML payload" + ); + + let _ = std::fs::remove_dir_all(&cache_dir); + } + #[test] fn allow_private_ips_allows_fetching_localhost() { let (port, _calls, stop, handle) = spawn_server(|stream| {