mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
42dcf49f4d
* fix(signal): circular phase variance for ghost-tap guard (ADR-154 §7.4 #1) `phase_variance` computed a LINEAR sample variance over phase angles that wrap at ±π, so a tightly-clustered set straddling the branch cut reported spuriously HIGH dispersion — false-tripping the `> TAU` ghost-tap guard on real, tightly-clustered CIR taps. Replace with Mardia's circular variance V = 1 − R̄, bounded [0,1] and invariant to where the cluster sits on the circle. Re-derive the guard against the bounded metric via a named const `GHOST_TAP_CIRCULAR_VARIANCE_MAX` (the old TAU-scaled threshold is meaningless on [0,1]). Grade: metric fix MEASURED; threshold value DATA-GATED — a clean single-path ramp also sweeps the circle, so V alone cannot separate clean from unsanitized without labelled frames. Conservative default (0.99) errs toward never false-rejecting, strictly more permissive at the wrap boundary than the buggy linear guard. Fails-on-old test: `phase_variance_circular_not_fooled_by_branch_cut` — inlines the old linear variance to show it exceeds TAU on wrap-straddling phases while circular V≈0 and the guard no longer trips. Plus `phase_variance_circular_is_bounded_and_extremal` (V∈[0,1], V≈0 identical, V≈1 uniform). cargo test -p wifi-densepose-signal --no-default-features --features cir --lib → 432 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(signal): pin Welford n=0/n=1 finiteness guard (ADR-154 §7.4 #10) The shared `WelfordStats` (field_model.rs, used by longitudinal.rs and others) relies on `count < 2` guards in `variance`/`sample_variance`/`std_dev`/ `z_score` to stay finite at the boundaries. The guards existed but the n=0 boundary was UNTESTED — exactly the §4 divide-by-(n−1) family the ADR groups this with. Add `welford_finite_at_n0_and_n1` asserting every statistic is finite and returns the documented sentinel (0.0) at n=0 and n=1, plus load-bearing doc comments on the two guards. Fails-on-old proof: with the `sample_variance` guard removed, the test FAILS with "attempt to subtract with overflow" at the `(self.count - 1)` underflow (0usize − 1); `variance` would similarly yield 0.0/0.0 = NaN. The guard is restored; the test pins it so a future regression is caught. Grade: MEASURED (boundary finiteness is asserted; the guard is the §4-family fix made testable). cargo test -p wifi-densepose-signal --no-default-features --lib field_model → 22 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic adversarial thresholds + boundary tests (ADR-154 §7.4 #13) Lift the bare numeric literals buried in `check`/`check_consistency` into named, documented module consts (FIELD_MODEL_GINI_VIOLATION=0.8, ENERGY_RATIO_HIGH_VIOLATION=2.0, ENERGY_RATIO_LOW_VIOLATION=0.1, CONSISTENCY_ACTIVE_FRACTION_OF_MEAN=0.1, SCORE_W_* weights). VALUES UNCHANGED — each const equals the original literal; only names + pinning tests are new. Grade: DATA-GATED. The operating values stay empirical (defensible values need labelled spoofed/clean CSI — Wi-Spoof, §6.2/§7.3). The de-magicking + characterization tests are MEASURED: `tuning_consts_unchanged_from_literals`, `energy_ratio_high_boundary`, `energy_ratio_low_boundary`, `field_model_gini_boundary`, `consistency_active_fraction_boundary` pin the decision boundaries at/just-below/just-above each threshold, so a future data-driven retune is a visible, tested change. Fails-on-change proof: bumping ENERGY_RATIO_HIGH_VIOLATION 2.0→3.0 makes `energy_ratio_high_boundary` FAIL (restored). Operating values explicitly NOT changed. cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::adversarial → 20 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * refactor(signal): de-magic coherence drift/gate thresholds (ADR-154 §7.4 #9) Lift the bare detection literals in `coherence.rs::classify_drift` (DRIFT_STABLE_SCORE=0.85, DRIFT_STEP_CHANGE_MAX_STALE=10) and the `coherence_gate.rs` Default impl (DEFAULT_ACCEPT_THRESHOLD=0.85, DEFAULT_REJECT_THRESHOLD=0.5, DEFAULT_MAX_STALE_FRAMES=200, DEFAULT_PREDICT_ONLY_NOISE=3.0) into named, documented consts. VALUES UNCHANGED. The gate already exposed these via GatePolicyConfig (config seam); this names + pins the defaults. Grade: DATA-GATED. Operating values stay empirical (defensible Z-score thresholds need labelled stable/drifting coherence traces). De-magicking + boundary tests are MEASURED: `classify_drift_stable_score_boundary`, `classify_drift_stale_count_boundary` pin the at/just-below/just-above decisions; `drift_consts_unchanged_from_literals` / `gate_default_consts_unchanged_from_literals` pin the values. Operating values explicitly NOT changed. cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::coherence → 40 passed, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-154): mark §7.4 P1 backlog cleared — Milestone-1 (#1,#10 RESOLVED; #9,#13 DATA-GATED) Update ADR-154 §7.4 backlog rows #1, #9, #10, #13 with commit refs + grades, the §7.4 intro count (four P1 items cleared, ~41 P2/P3 remain), the Horizon-ledger one-liner (Milestone-1 DONE), and the §8 honest-limits #1 line (metric now correct; threshold still DATA-GATED). Add CHANGELOG [Unreleased] entry. Grades: #1 RESOLVED (MEASURED metric / DATA-GATED threshold), #10 RESOLVED (MEASURED), #9 & #13 RESOLVED-PARTIAL (DATA-GATED — de-magicked + boundary tested, operating values unchanged). Validation: cargo test --workspace --no-default-features → 2057 passed, 0 failed; wifi-densepose-signal lib → 442 passed (no-default + --features cir); python archive/v1/data/proof/verify.py → VERDICT: PASS, hash f8e76f21…46f7a UNCHANGED (CIR ghost-tap guard is not on the deterministic proof path). Co-Authored-By: claude-flow <ruv@ruv.net> * fix(sensing-server): stop leaking internal errors in HTTP responses (ADR-080 #2) Six handlers in `main.rs` serialized the internal error `Display` straight into the JSON response body, leaking server internals to any client (ADR-080 finding #2, CWE-209; reframed onto the Rust boundary by ADR-164 G11): - edge_registry_endpoint: a panicked spawn_blocking `JoinError` ("task … panicked") in a 500, and the raw upstream error in a 503 - delete_model / delete_recording / start_recording: std::io::Error strings carrying OS detail / filesystem paths - calibration_start / calibration_stop: the FieldModel error chain New `error_response` module: `internal_error` / `internal_error_json` / `upstream_unavailable` log the full detail server-side only (tagged with a correlation id) and return a generic body (`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file paths, no Debug chain. The correlation id lets an operator join a client report to the exact server log line without ever shipping the detail. Pinned by 5 error_response tests, incl. a leak-substring guard (internal_error_body_does_not_leak_detail) verified to FAIL on the reverted old body (returns the panic message / path / "os error"). The HOMECORE sweep (ADR-161) covered homecore-server, not this crate. Co-Authored-By: claude-flow <ruv@ruv.net> * test(sensing-server): pin XFF-immunity + no-query-token (ADR-080 #1, #3) Findings #1 (XFF-spoofing bypass) and #3 (JWT-in-URL, CWE-598) were logged against the Python v1 API but are VERIFIED ABSENT on the current Rust sensing-server, so they get regression tests rather than redundant fixes: - #1 XFF: there is no IP-based rate-limiter or IP-allowlist to bypass, and neither security middleware reads a forwarded header. Added bearer_auth::xff_header_never_affects_auth_decision (spoofed X-Forwarded-For never flips a 401<->200 decision) and host_validation::forwarded_headers_never_bypass_host_allowlist (spoofed X-Forwarded-Host: localhost never lets Host: evil.com past the allowlist). - #3 JWT-in-URL: require_bearer reads the token only from the Authorization header; WS handlers take no query token; the sole Query extractor (EdgeRegistryParams) is a non-secret refresh flag. Added bearer_auth::query_string_token_is_never_accepted — ?token= / ?access_token= in the URL never authenticates (stays 401) while the header path still 200s. Verified to FAIL when a query-token path is injected into require_bearer. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-080): mark P0 security findings #1-#3 RESOLVED; close ADR-164 G11 - ADR-080: Status note + per-finding closure (#1 XFF and #3 JWT-in-URL verified absent + regression-pinned; #2 leaked errors fixed via the error_response module). Records the v1-vs-Rust boundary distinction explicitly: v1 paths remain archived; this closure governs the shipped Rust sensing-server. - ADR-164: Gap Register G11 and the Open/Gated Backlog entry marked RESOLVED with the fix + branch reference. - CHANGELOG: [Unreleased] -> ### Security entry covering all three findings. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr): renumber 6 displaced ADRs to resolve duplicate-number collisions (ADR-164 G1) Resolves the 5 duplicate ADR numbers (6 displaced files) flagged by ADR-164 Gap Register item G1. Canonical keeper per number = first file committed at that number (date tie-broken by inbound cross-reference count / parent-appendix relationship). Displaced files renumbered to the next free numbers (166-171): 050 keeps provisioning-tool-enhancements (5 refs vs 1) -> ADR-166-quality-engineering-security-hardening 052 keeps tauri-desktop-frontend (parent ADR) -> ADR-167-ddd-bounded-contexts (its appendix) 147 keeps nvidia-cosmos/OccWorld (the actual ADR, has Status header) -> ADR-168-benchmark-proof (proof companion, no Status) -> ADR-169-adam-mode-light-theme (was untracked) 148 keeps drone-swarm-control-system (committed #862) -> ADR-170-yoga-mode-pose-system (was untracked) 149 keeps public-community-leaderboard-huggingface (committed 16:47 vs 17:38) -> ADR-171-swarm-benchmarking-evaluation-methodology Updates in-file `# ADR-NNN` headers and intra-file self-references (yoga-modes * docs(adr): repoint inbound cross-references to renumbered ADRs (166-171) Follow-up to the ADR renumbering (ADR-164 G1). Updates every inbound reference that pointed at a displaced ADR, disambiguating shared numbers by title/slug so only references to the DISPLACED topic move and keeper references stay put. ADR-168 (was 147 benchmark-proof): README, CHANGELOG, user-guide, proof-of-capabilities, research docs 00/03 — all path/label refs updated. ADR-169 (was 147 adam-mode) / ADR-170 (was 148 yoga-mode): docs/adr/README index. ADR-171 (was 149 swarm-benchmarking): all ruview-swarm eval code+docs (Cargo.toml, evals/, eval_swarm.rs, metrics/mod/report/runner.rs), research doc 03 (every §-ref matched ADR-171 sections, not AetherArena), 00-system-review, series README, CHANGELOG, and ADR-148's forward/"open issues" pointers. ADR-166 (was 050 quality-engineering / security-hardening): disambiguated from the ADR-050 provisioning KEEPER by topic. The HMAC/secure_tdm, directory-traversal, bind-address, and OTA-PSK-auth references in code comments (wifi-densepose-hardware Cargo.toml + secure_tdm.rs, sensing-server main.rs) and in ADR-052-tauri / ADR-167 all describe the security-hardening ADR -> ADR-166. ADR-167 (was 052 ddd-appendix): inbound appendix references. Index/registry updates: docs/adr/README.md, gap-analysis/census.md (rows + header count), gap-analysis/lens-findings.md (collision table marked RESOLVED), and ADR-164 Gap Register G1 marked RESOLVED with the full renumber map. Keeper references deliberately untouched: all ADR-147 OccWorld code, all ADR-148 drone-swarm code/docs, all ADR-149 AetherArena refs (incl. ADR-150's SSL/resampling refs, which ADR-150 explicitly binds to the AetherArena benchmark), ADR-050 provisioning refs, ADR-052 tauri refs. The frozen GitHub blob URLs in docs/adr/.issue-177-body.md (pinned to an old branch) are left as historical. Comment-only code edits; no behavior change. wifi-densepose-hardware compiles clean; the sensing-server build's sole blocker is the pre-existing upstream midstreamer-temporal-compare@0.2.1 registry crate, unrelated to these edits. Co-Authored-By: claude-flow <ruv@ruv.net>
252 lines
10 KiB
Rust
252 lines
10 KiB
Rust
//! Generic, leak-free error responses for the sensing-server HTTP API.
|
|
//!
|
|
//! ## ADR-080 finding #2 — leaked internal errors in responses
|
|
//!
|
|
//! Several handlers historically serialized the *internal* error `Display`
|
|
//! (`format!("{e}")`, `err.to_string()`, a panicked `JoinError`) straight into
|
|
//! the JSON response body. That leaks server internals to any client: OS error
|
|
//! strings can carry filesystem paths, a `JoinError` carries the panic message
|
|
//! (`task … panicked`), and an upstream-fetch error can carry an internal URL.
|
|
//! ADR-080 flagged this HIGH (CWE-209: Generation of Error Message Containing
|
|
//! Sensitive Information). The HOMECORE/M7 sweep (ADR-161) covered
|
|
//! `homecore-server`, **not** this crate, so the finding stayed open.
|
|
//!
|
|
//! ## Contract
|
|
//!
|
|
//! [`internal_error`] logs the full detail **server-side only** (at `error`
|
|
//! level, tagged with a correlation id) and returns a *generic* body to the
|
|
//! client:
|
|
//!
|
|
//! ```json
|
|
//! { "error": "internal_error", "correlation_id": "a1b2c3d4e5f60718", "success": false }
|
|
//! ```
|
|
//!
|
|
//! The correlation id lets an operator grep the server log for the matching
|
|
//! detail line without ever shipping that detail to the client. The body
|
|
//! deliberately contains no `Display`/`Debug` of the underlying error, no file
|
|
//! paths, and never the word `panicked`.
|
|
//!
|
|
//! Handlers that previously returned `Json<serde_json::Value>` keep doing so via
|
|
//! [`internal_error_json`]; handlers that return `(StatusCode, Json<…>)` use
|
|
//! [`internal_error`]. A "service unavailable" flavor ([`upstream_unavailable`])
|
|
//! exists for the 503 upstream-fetch path so it, too, stops leaking the raw
|
|
//! upstream error.
|
|
|
|
use std::fmt::Display;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
use axum::{http::StatusCode, response::Json};
|
|
use serde_json::json;
|
|
|
|
/// Monotonic component of the correlation id, so two errors in the same
|
|
/// nanosecond still get distinct ids. Wraps harmlessly.
|
|
static CORRELATION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// Generate a short, opaque correlation id (16 lowercase hex chars). Built from
|
|
/// a nanosecond timestamp XORed with a monotonic counter — unique enough to tie
|
|
/// a client-visible id back to a single server-side log line without pulling in
|
|
/// a UUID dependency. It is **not** a security token; it is only an opaque
|
|
/// log-join key, so a non-cryptographic source is fine.
|
|
pub fn correlation_id() -> String {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos() as u64)
|
|
.unwrap_or(0);
|
|
let seq = CORRELATION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
// Mix the counter into the high bits so concurrent calls in the same
|
|
// nanosecond don't collide.
|
|
let mixed = nanos ^ seq.rotate_left(40);
|
|
format!("{mixed:016x}")
|
|
}
|
|
|
|
/// Build a generic internal-error response **and log the real detail
|
|
/// server-side**. The client sees only `{"error":"internal_error",
|
|
/// "correlation_id":…,"success":false}` with a `500` status; the detail is
|
|
/// written to the `error`-level log tagged with the same correlation id.
|
|
///
|
|
/// `context` is a short, *static* description of where the error happened
|
|
/// (e.g. `"model delete"`); it is safe to log but is **not** sent to the
|
|
/// client.
|
|
pub fn internal_error(context: &str, detail: impl Display) -> (StatusCode, Json<serde_json::Value>) {
|
|
let cid = correlation_id();
|
|
// Server-side only — this is where the real detail lives.
|
|
tracing::error!(
|
|
correlation_id = %cid,
|
|
context = context,
|
|
detail = %detail,
|
|
"internal error (detail logged server-side only; client received a generic body)"
|
|
);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({
|
|
"error": "internal_error",
|
|
"correlation_id": cid,
|
|
"success": false,
|
|
})),
|
|
)
|
|
}
|
|
|
|
/// Same as [`internal_error`] but returns a bare `Json` body (HTTP `200` at the
|
|
/// transport layer) for the legacy handlers that are typed
|
|
/// `-> Json<serde_json::Value>` and signal failure via `"success": false`
|
|
/// rather than an HTTP status code. The detail is still logged server-side and
|
|
/// never reaches the client.
|
|
pub fn internal_error_json(context: &str, detail: impl Display) -> Json<serde_json::Value> {
|
|
let cid = correlation_id();
|
|
tracing::error!(
|
|
correlation_id = %cid,
|
|
context = context,
|
|
detail = %detail,
|
|
"internal error (detail logged server-side only; client received a generic body)"
|
|
);
|
|
Json(json!({
|
|
"error": "internal_error",
|
|
"correlation_id": cid,
|
|
"success": false,
|
|
}))
|
|
}
|
|
|
|
/// Generic `503 Service Unavailable` for an upstream dependency that failed,
|
|
/// without leaking the raw upstream error (which can carry an internal URL or
|
|
/// connection detail). Detail is logged server-side with a correlation id.
|
|
pub fn upstream_unavailable(
|
|
context: &str,
|
|
detail: impl Display,
|
|
) -> (StatusCode, Json<serde_json::Value>) {
|
|
let cid = correlation_id();
|
|
tracing::warn!(
|
|
correlation_id = %cid,
|
|
context = context,
|
|
detail = %detail,
|
|
"upstream unavailable (detail logged server-side only; client received a generic body)"
|
|
);
|
|
(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
Json(json!({
|
|
"error": "upstream_unavailable",
|
|
"correlation_id": cid,
|
|
})),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A "detail" string carrying the kind of internal information the old
|
|
/// `format!("{e}")` path would have leaked: a filesystem path, an OS error,
|
|
/// and the word `panicked`.
|
|
const LEAKY_DETAIL: &str =
|
|
"task 42 panicked at 'C:\\Users\\ruv\\secret\\models\\foo.rvf': No such file or directory (os error 2)";
|
|
|
|
/// Recursively collect every string value in a JSON document, so a test can
|
|
/// assert no leaky substring appears *anywhere* in the body (not just in a
|
|
/// single known field).
|
|
fn all_strings(v: &serde_json::Value, out: &mut Vec<String>) {
|
|
match v {
|
|
serde_json::Value::String(s) => out.push(s.clone()),
|
|
serde_json::Value::Array(a) => a.iter().for_each(|x| all_strings(x, out)),
|
|
serde_json::Value::Object(o) => o.values().for_each(|x| all_strings(x, out)),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn body_strings(body: &Json<serde_json::Value>) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
all_strings(&body.0, &mut out);
|
|
out
|
|
}
|
|
|
|
/// REGRESSION (ADR-080 #2): the response body must NOT contain the panic
|
|
/// message, the filesystem path, or the OS error string. The pre-fix code
|
|
/// returned `format!("{e}")` / `join_err.to_string()` directly, so the body
|
|
/// *did* contain `panicked`, the path, and `os error 2` — this test fails
|
|
/// on that old behavior.
|
|
#[test]
|
|
fn internal_error_body_does_not_leak_detail() {
|
|
let (status, body) = internal_error("unit-test", LEAKY_DETAIL);
|
|
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
|
for s in body_strings(&body) {
|
|
assert!(
|
|
!s.contains("panicked"),
|
|
"response body leaked the panic message: {s:?}"
|
|
);
|
|
assert!(
|
|
!s.contains("secret"),
|
|
"response body leaked a filesystem path: {s:?}"
|
|
);
|
|
assert!(
|
|
!s.contains("os error"),
|
|
"response body leaked an OS error string: {s:?}"
|
|
);
|
|
assert!(
|
|
!s.contains(".rvf"),
|
|
"response body leaked a file name/path: {s:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The generic body still carries a correlation id so an operator can join
|
|
/// the client report to the server log line that *does* hold the detail.
|
|
#[test]
|
|
fn internal_error_body_is_generic_with_correlation_id() {
|
|
let (_status, body) = internal_error("unit-test", LEAKY_DETAIL);
|
|
assert_eq!(body.0["error"], "internal_error");
|
|
assert_eq!(body.0["success"], false);
|
|
let cid = body.0["correlation_id"]
|
|
.as_str()
|
|
.expect("correlation_id must be a string");
|
|
assert_eq!(cid.len(), 16, "correlation id should be 16 hex chars");
|
|
assert!(
|
|
cid.chars().all(|c| c.is_ascii_hexdigit()),
|
|
"correlation id should be hex: {cid:?}"
|
|
);
|
|
}
|
|
|
|
/// Same leak guarantee for the bare-`Json` (legacy "success: false")
|
|
/// variant used by handlers that don't return an HTTP status.
|
|
#[test]
|
|
fn internal_error_json_does_not_leak_detail() {
|
|
let body = internal_error_json("unit-test", LEAKY_DETAIL);
|
|
assert_eq!(body.0["error"], "internal_error");
|
|
assert_eq!(body.0["success"], false);
|
|
for s in body_strings(&body) {
|
|
assert!(!s.contains("panicked"), "leaked panic message: {s:?}");
|
|
assert!(!s.contains("secret"), "leaked filesystem path: {s:?}");
|
|
assert!(!s.contains("os error"), "leaked OS error: {s:?}");
|
|
}
|
|
}
|
|
|
|
/// The 503 upstream flavor must likewise not echo the raw upstream error
|
|
/// (which can carry an internal URL / connection string).
|
|
#[test]
|
|
fn upstream_unavailable_does_not_leak_detail() {
|
|
let (status, body) = upstream_unavailable(
|
|
"edge-registry",
|
|
"https://internal-host.local:9000/app-registry.json: connection refused",
|
|
);
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
|
for s in body_strings(&body) {
|
|
assert!(
|
|
!s.contains("internal-host"),
|
|
"leaked internal upstream host: {s:?}"
|
|
);
|
|
assert!(
|
|
!s.contains("connection refused"),
|
|
"leaked upstream connection detail: {s:?}"
|
|
);
|
|
}
|
|
assert_eq!(body.0["error"], "upstream_unavailable");
|
|
assert!(body.0["correlation_id"].is_string());
|
|
}
|
|
|
|
/// Correlation ids are unique across rapid successive calls (so two errors
|
|
/// can be told apart in the log even under load).
|
|
#[test]
|
|
fn correlation_ids_are_unique() {
|
|
let a = correlation_id();
|
|
let b = correlation_id();
|
|
assert_ne!(a, b, "successive correlation ids must differ: {a} == {b}");
|
|
}
|
|
}
|