fix(wasm-edge): sanitize non-finite host floats at the WASM↔host frame boundary (#1102)
Closing beyond-SOTA security review of wifi-densepose-wasm-edge (ADR-040,
~70 edge modules). The two WASM↔host boundaries (lib.rs::on_frame/on_timer
and bin/ghost_hunter.rs::on_frame) read raw IEEE-754 f32 from the csi_get_*
imports with no finiteness check — the crate had zero is_finite/is_nan
guards and its clamp helpers propagate NaN. A single non-finite host value
latches NaN into long-lived per-module accumulators (EMA / Welford / phasor
sums / anomaly baselines), after which detectors fail degraded (stuck gate
state, silently-disabled checks) — silent corruption, not a crash.
Add sanitize_host_f32() (non-finite -> 0.0, core-only for no_std) applied at
every host_get_* float read: one chokepoint covering all downstream modules,
mirroring the existing M-01 negative-n_subcarriers boundary clamp. LOW /
defense-in-depth (the Tier-2 DSP firmware supplies the imports, a semi-trusted
boundary).
Pinned by boundary_tests::{sanitize_passes_finite_values_through,
sanitize_maps_non_finite_to_zero,
coherence_monitor_nan_latches_without_sanitize_but_not_with} — the last
asserts on the current CoherenceMonitor that a raw NaN frame latches the
smoothed score while the sanitized path stays finite.
Other review dimensions attested clean with evidence (see CHANGELOG): no
hot-path panics (all unwrap/expect are test-only or std-gated RVF builder),
all bounds min()-clamped, all index-by-cast const-bounded or guarded, no
leaking closures (no move||/forget/leak), no secrets.
Verified: host `cargo test --features std,medical-experimental` 672 passed /
0 failed (+3 new tests); all three wasm32-unknown-unknown release artifacts
build clean (lib default no_std/panic=abort, ghost_hunter standalone-bin,
medical-experimental); Python proof VERDICT PASS, hash unchanged.
This commit is contained in:
parent
c859f6f743
commit
cafbeb1e81
File diff suppressed because one or more lines are too long
|
|
@ -20,6 +20,7 @@ use wifi_densepose_wasm_edge::{
|
|||
host_get_phase, host_get_amplitude, host_get_variance,
|
||||
host_get_presence, host_get_motion_energy,
|
||||
host_emit_event, host_log,
|
||||
sanitize_host_f32,
|
||||
exo_ghost_hunter::GhostHunterDetector,
|
||||
};
|
||||
|
||||
|
|
@ -64,14 +65,16 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
|||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amplitudes[i] = host_get_amplitude(i as i32);
|
||||
variances[i] = host_get_variance(i as i32);
|
||||
// Sanitize at the boundary: a non-finite host value would otherwise
|
||||
// latch NaN into the detector's persistent anomaly-energy state.
|
||||
phases[i] = sanitize_host_f32(host_get_phase(i as i32));
|
||||
amplitudes[i] = sanitize_host_f32(host_get_amplitude(i as i32));
|
||||
variances[i] = sanitize_host_f32(host_get_variance(i as i32));
|
||||
}
|
||||
}
|
||||
|
||||
let presence = unsafe { host_get_presence() };
|
||||
let motion_energy = unsafe { host_get_motion_energy() };
|
||||
let motion_energy = sanitize_host_f32(unsafe { host_get_motion_energy() });
|
||||
|
||||
let detector = unsafe { &mut *core::ptr::addr_of_mut!(DETECTOR) };
|
||||
let events = detector.process_frame(
|
||||
|
|
|
|||
|
|
@ -572,6 +572,35 @@ pub mod event_types {
|
|||
pub const HEALING_COMPLETE: i32 = 888;
|
||||
}
|
||||
|
||||
/// Sanitize a raw `f32` read from the host CSI imports.
|
||||
///
|
||||
/// ## NaN-state-poisoning guard (ADR-040 boundary hardening)
|
||||
///
|
||||
/// The `csi_get_phase`/`csi_get_amplitude`/`csi_get_variance`/… host imports
|
||||
/// return raw IEEE-754 `f32`. A single non-finite value (NaN / ±∞) — from a
|
||||
/// firmware DSP bug, an uninitialised buffer, or a hostile host — propagates
|
||||
/// silently into the long-lived per-module accumulators (EMA, Welford,
|
||||
/// phasor sums, baseline means). Once latched, every downstream comparison
|
||||
/// against the poisoned state evaluates `false`, so detectors fail *degraded*
|
||||
/// (stuck gate state, suppressed anomaly checks) rather than recovering.
|
||||
///
|
||||
/// This is the single chokepoint: every one of the ~70 edge modules receives
|
||||
/// its frame data from the `on_frame` boundaries below, so mapping non-finite
|
||||
/// host floats to `0.0` here protects the entire surface without per-module
|
||||
/// churn. Mirrors the M-01 negative-`n_subcarriers` clamp at the same site.
|
||||
///
|
||||
/// `0.0` is the neutral choice: a zero phase/amplitude/variance reads as a
|
||||
/// quiet subcarrier, which the detectors already handle (it cannot, itself,
|
||||
/// trip an anomaly the way a poisoned NaN can permanently disable one).
|
||||
#[inline]
|
||||
pub fn sanitize_host_f32(v: f32) -> f32 {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a message string to the ESP32 console (via host_log import).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn log_msg(msg: &str) {
|
||||
|
|
@ -650,8 +679,10 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
|||
|
||||
for i in 0..max_sc {
|
||||
unsafe {
|
||||
phases[i] = host_get_phase(i as i32);
|
||||
amps[i] = host_get_amplitude(i as i32);
|
||||
// Sanitize at the boundary: a non-finite host value would otherwise
|
||||
// latch NaN into the gesture/coherence/anomaly persistent state.
|
||||
phases[i] = sanitize_host_f32(host_get_phase(i as i32));
|
||||
amps[i] = sanitize_host_f32(host_get_amplitude(i as i32));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -677,10 +708,71 @@ pub extern "C" fn on_frame(n_subcarriers: i32) {
|
|||
pub extern "C" fn on_timer() {
|
||||
// Periodic summary.
|
||||
let state = unsafe { &*core::ptr::addr_of!(STATE) };
|
||||
let motion = unsafe { host_get_motion_energy() };
|
||||
let motion = sanitize_host_f32(unsafe { host_get_motion_energy() });
|
||||
emit(event_types::CUSTOM_METRIC, motion);
|
||||
|
||||
if state.frame_count % 100 == 0 {
|
||||
log_msg("wasm-edge: heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boundary-hardening tests (ADR-040) ───────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod boundary_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_passes_finite_values_through() {
|
||||
assert_eq!(sanitize_host_f32(0.0), 0.0);
|
||||
assert_eq!(sanitize_host_f32(-3.5), -3.5);
|
||||
assert_eq!(sanitize_host_f32(1234.5), 1234.5);
|
||||
assert_eq!(sanitize_host_f32(f32::MIN), f32::MIN);
|
||||
assert_eq!(sanitize_host_f32(f32::MAX), f32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_maps_non_finite_to_zero() {
|
||||
// NaN / ±∞ from a buggy or hostile host must not reach module state.
|
||||
assert_eq!(sanitize_host_f32(f32::NAN), 0.0);
|
||||
assert_eq!(sanitize_host_f32(f32::INFINITY), 0.0);
|
||||
assert_eq!(sanitize_host_f32(f32::NEG_INFINITY), 0.0);
|
||||
// A subnormal-resulting NaN (0.0 * inf) is also caught.
|
||||
assert_eq!(sanitize_host_f32(0.0f32 * f32::INFINITY), 0.0);
|
||||
}
|
||||
|
||||
/// Demonstrates the downstream hazard the boundary guard prevents:
|
||||
/// feeding a raw NaN phase into a persistent module permanently latches
|
||||
/// its smoothed state, whereas a boundary-sanitized 0.0 keeps it healthy.
|
||||
#[test]
|
||||
fn coherence_monitor_nan_latches_without_sanitize_but_not_with() {
|
||||
use crate::coherence::CoherenceMonitor;
|
||||
|
||||
// Without sanitize: a single NaN frame poisons the EMA forever.
|
||||
let mut poisoned = CoherenceMonitor::new();
|
||||
poisoned.process_frame(&[0.1, 0.2, 0.3]); // init
|
||||
let _ = poisoned.process_frame(&[f32::NAN, 0.2, 0.3]); // raw host NaN
|
||||
// Subsequent *clean* frames can never restore a finite score.
|
||||
for _ in 0..50 {
|
||||
poisoned.process_frame(&[0.1, 0.2, 0.3]);
|
||||
}
|
||||
assert!(
|
||||
poisoned.coherence_score().is_nan(),
|
||||
"raw NaN should latch the smoothed coherence (documents the hazard)"
|
||||
);
|
||||
|
||||
// With the boundary guard applied (what on_frame now does), the NaN is
|
||||
// mapped to a finite value before it ever reaches the module.
|
||||
let mut guarded = CoherenceMonitor::new();
|
||||
let f = |x: f32| sanitize_host_f32(x);
|
||||
guarded.process_frame(&[f(0.1), f(0.2), f(0.3)]); // init
|
||||
let _ = guarded.process_frame(&[f(f32::NAN), f(0.2), f(0.3)]);
|
||||
for _ in 0..50 {
|
||||
guarded.process_frame(&[f(0.1), f(0.2), f(0.3)]);
|
||||
}
|
||||
assert!(
|
||||
guarded.coherence_score().is_finite(),
|
||||
"boundary-sanitized input keeps the module state finite"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue