Date: 2026-03-03
Auditor: Security Auditor Agent (Claude Opus 4.6)
Scope: All 29 .rs files in rust-port/wifi-densepose-rs/crates/wifi-densepose-wasm-edge/src/
Crate version: 0.3.0
Target: wasm32-unknown-unknown (ESP32-S3 WASM3 interpreter)
The wifi-densepose-wasm-edge crate implements 29 no_std WASM modules for on-device CSI signal processing. The code is generally well-written with consistent patterns for memory management, bounds checking, and event rate limiting. No heap allocations leak into no_std builds. All host API calls are properly gated behind cfg(target_arch = "wasm32").
Total issues found: 15
- CRITICAL: 1
- HIGH: 3
- MEDIUM: 6
- LOW: 5
Severity: CRITICAL
Files: All 26 modules that use static mut EVENTS pattern
Example: occupancy.rs:161, vital_trend.rs:175, intrusion.rs:121, sig_coherence_gate.rs:180, sig_flash_attention.rs:107, spt_pagerank_influence.rs:195, spt_micro_hnsw.rs:267,284, tmp_pattern_sequence.rs:153, lrn_dtw_gesture_learn.rs:146, lrn_anomaly_attractor.rs:140, ais_prompt_shield.rs:158, qnt_quantum_coherence.rs:132, sig_sparse_recovery.rs:138, sig_temporal_compress.rs:246,309, and 10+ more
Description: Every module uses static mut arrays inside function bodies to return event slices without heap allocation:
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
// ... write to EVENTS ...
unsafe { &EVENTS[..n_events] }While this is safe in WASM3's single-threaded execution model, the returned &[(i32, f32)] reference has 'static lifetime but the data is mutated on the next call. If a caller stores the returned slice reference across two process_frame() calls, the first reference observes silently mutated data.
Risk: In the current ESP32 WASM3 single-threaded deployment, this is mitigated. However, if the crate is ever used in a multi-threaded context or if event slices are stored across calls, data corruption occurs silently with no panic or error.
Recommendation: Document this contract explicitly in every function's doc comment: "The returned slice is only valid until the next call to this function." Consider adding a #[doc(hidden)] comment or wrapping in a newtype that prevents storing across calls. The current approach is an acceptable trade-off for no_std/no-heap constraints but must be documented.
Status: NOT FIXED (documentation-level issue; no code change warranted for embedded WASM target)
Severity: HIGH
File: coherence.rs:94
Description: The CoherenceMonitor::process_frame() function computes n_sc as min(phases.len(), MAX_SC) at line 69, which can be 0 if phases is empty. However, at line 94, the code divides by n (which is n_sc as f32) without a zero check:
let n = n_sc as f32;
let mean_re = sum_re / n; // Division by zero if phases is empty
let mean_im = sum_im / n;While the initialized check at line 71 catches the first call with an early return, the second call with an empty phases slice will reach the division.
Impact: Produces NaN/Inf which propagates through the EMA-smoothed coherence score, permanently corrupting the monitor state.
Recommendation: Add if n_sc == 0 { return self.smoothed_coherence; } after the initialized check.
Severity: HIGH
File: occupancy.rs:92-112
Description: When n_sc == 2 or n_sc == 3, zone_count = (n_sc / 4).min(MAX_ZONES).max(1) = 1 and subs_per_zone = n_sc / zone_count = n_sc. The loop computes count = (end - start) as f32 which is valid. However, when n_sc == 1, the function returns early at line 83-85. The real risk is if n_sc == 0 somehow passes through -- but the check at line 83 n_sc < 2 guards this. This is actually safe but fragile.
However, a more serious issue: the count variable at line 99 is computed as (end - start) as f32 and used as a divisor at lines 105 and 112. If subs_per_zone == 0 (which can happen if zone_count > n_sc), count would be 0, causing division by zero. Currently zone_count is capped by n_sc / 4 so this cannot happen with n_sc >= 2, but the logic is fragile.
Recommendation: Add a guard if count < 1.0 { continue; } before the division at line 105.
Severity: HIGH
File: rvf.rs:209-215 (std-only builder code)
Description: The patch_signature function reads wasm_len from the header bytes and computes an offset, then copies into rvf[offset..offset + RVF_SIGNATURE_LEN] without checking that offset + RVF_SIGNATURE_LEN <= rvf.len():
pub fn patch_signature(rvf: &mut [u8], signature: &[u8; RVF_SIGNATURE_LEN]) {
let sig_offset = RVF_HEADER_SIZE + RVF_MANIFEST_SIZE;
let wasm_len = u32::from_le_bytes([rvf[12], rvf[13], rvf[14], rvf[15]]) as usize;
let offset = sig_offset + wasm_len;
rvf[offset..offset + RVF_SIGNATURE_LEN].copy_from_slice(signature);
}If called with a truncated or malformed RVF buffer, or if wasm_len in the header has been tampered with, this panics at runtime. Since this is std-only builder code (behind #[cfg(feature = "std")]), it does not affect the WASM target, but it is a potential denial-of-service in build tooling.
Recommendation: Add bounds check: if offset + RVF_SIGNATURE_LEN > rvf.len() { return; } or return a Result.
Severity: MEDIUM
File: lib.rs:391
Description: The exported on_frame(n_subcarriers: i32) casts to usize: let n_sc = n_subcarriers as usize;. If the host passes a negative value (e.g., -1), this wraps to usize::MAX on a 32-bit WASM target (4294967295). The subsequent clamping if n_sc > 32 { 32 } else { n_sc } handles this safely, producing max_sc = 32. However, the semantic intent is broken: a negative input should be treated as 0.
Recommendation: Add: let n_sc = if n_subcarriers < 0 { 0 } else { n_subcarriers as usize };
Severity: MEDIUM
File: coherence.rs:142-144
Description: The mean_phasor_angle() method computes atan2f(self.phasor_im, self.phasor_re), but phasor_re and phasor_im are initialized to 0.0 in new() and never updated in process_frame(). The running phasor sums computed in process_frame() use local variables sum_re and sum_im but never store them back into self.phasor_re/self.phasor_im.
Impact: mean_phasor_angle() always returns atan2(0, 0) = 0.0, which is incorrect.
Recommendation: Store the per-frame mean phasor components: self.phasor_re = mean_re; self.phasor_im = mean_im; at the end of process_frame().
Severity: MEDIUM
File: gesture.rs:200
Description: The dtw_distance function allocates [[f32::MAX; 40]; 60] = 2400 * 4 = 9600 bytes on the stack. This is within WASM3's default 64 KB stack, but combined with the caller's stack frame (GestureDetector is ~360 bytes + locals), total stack pressure approaches 11-12 KB per gesture check.
The vendor_common.rs DTW functions use [[f32::MAX; 64]; 64] = 16384 bytes, which is more concerning.
Impact: If multiple DTW calls are nested or if WASM stack is configured smaller than 32 KB, stack overflow occurs (infinite loop in WASM3 since panic handler loops).
Recommendation: Document minimum WASM stack requirement (32 KB recommended). Consider reducing DTW_MAX_LEN in vendor_common.rs from 64 to 48 to bring stack usage under 10 KB per call.
Severity: MEDIUM
Files: All modules with frame_count: u32
Description: At 20 Hz frame rate, u32::MAX / 20 / 3600 / 24 = 2.48 days. After overflow, any frame_count % N == 0 periodic emission logic changes timing. The sig_temporal_compress.rs:231 uses wrapping_add explicitly, but most modules use += 1 which panics in debug mode.
Impact: On embedded release builds (panic=abort), the += 1 compiles to wrapping arithmetic, so no crash occurs. However, modules that compare frame_count against thresholds (e.g., lrn_anomaly_attractor.rs:192: self.frame_count >= MIN_FRAMES_FOR_CLASSIFICATION) will re-trigger learning phases after overflow.
Recommendation: Use .wrapping_add(1) explicitly in all modules for clarity. For modules with threshold comparisons, add a saturating flag to prevent re-triggering.
Severity: MEDIUM
File: tmp_pattern_sequence.rs:159
Description: The write index is DAY_LEN + self.minute_counter as usize. When minute_counter equals DAY_LEN - 1 (1439), the index is 2879, which is the last valid index in the history: [u8; DAY_LEN * 2] array. This is fine. However, the bounds check at line 160 if idx < DAY_LEN * 2 is a safety net that suggests awareness of a possible off-by-one. The check is correct and prevents overflow.
Actually, the issue is that minute_counter is u16 and is compared against DAY_LEN as u16 (1440). If somehow minute_counter is incremented past DAY_LEN without triggering the rollover check at line 192 (which checks >=), no OOB occurs because of the guard at line 160. This is defensive and safe.
Downgrading concern: This is actually well-handled. Keeping as MEDIUM because the pattern of computing DAY_LEN + minute_counter without the guard would be dangerous.
Severity: MEDIUM
File: spt_micro_hnsw.rs:187,197
Description: Neighbor indices are stored as u8 in HnswNode::neighbors. The code stores to as u8 at line 187/197. With MAX_VECTORS = 64, this is safe. However, if MAX_VECTORS is ever increased above 255, indices silently truncate, causing incorrect graph edges that could lead to wrong nearest-neighbor results.
Recommendation: Add a compile-time assertion: const _: () = assert!(MAX_VECTORS <= 255);
Severity: LOW
File: lib.rs:35
Description: This suppresses warnings about missing # Safety sections on unsafe functions. Given the extensive use of unsafe for static mut access and FFI calls, documenting safety invariants would improve maintainability.
Severity: LOW
Files: All 26 modules with static mut EVENTS in function bodies
Description: The static mut EVENTS buffers are declared inside functions that are not gated by cfg(target_arch = "wasm32"). This means they exist on all targets, including host tests. While this is necessary for the functions to compile and be testable on the host, it means the soundness argument ("single-threaded WASM") does not hold during cargo test with parallel test threads.
Impact: Tests are currently single-threaded per module function, so no data race occurs in practice. Rust's test harness runs tests in parallel threads, but each test creates its own instance and calls the method sequentially.
Recommendation: Run tests with -- --test-threads=1 or add a note in the test configuration.
L-03: lrn_dtw_gesture_learn.rs:357 -- next_id wraps at 255, potentially colliding with built-in gesture IDs
Severity: LOW
File: lrn_dtw_gesture_learn.rs:357
Description: self.next_id = self.next_id.wrapping_add(1) starts at 100 and wraps from 255 to 0, potentially overlapping with built-in gesture IDs 1-4 from gesture.rs.
Recommendation: Use wrapping_add(1).max(100) or saturating_add to stay in the 100-255 range.
L-04: ais_prompt_shield.rs:294 -- FNV-1a hash quantization resolution may cause false replay positives
Severity: LOW
File: ais_prompt_shield.rs:292-308
Description: The replay detection hashes quantized features at 0.01 resolution ((mean_phase * 100.0) as i32). Two genuinely different frames with mean_phase values differing by less than 0.01 will hash identically, triggering a false replay alert. At 20 Hz with slowly varying CSI, this can happen frequently.
Recommendation: Increase quantization resolution to 0.001 or add a secondary discriminator (e.g., include a frame sequence counter in the hash).
Severity: LOW
File: qnt_quantum_coherence.rs:188
Description: let inv_n = 1.0 / (n_sc as f32); -- While n_sc < 2 is checked at line 94, the pattern of dividing without an explicit guard is inconsistent with other modules.
| Check | Status | Notes |
|---|---|---|
Host API calls behind cfg(target_arch = "wasm32") |
PASS | All FFI in lib.rs:100-137, log_msg, emit properly gated |
| No std dependencies in no_std builds | PASS | Vec, String, Box only in rvf.rs behind #[cfg(feature = "std")] |
| Panic handler defined exactly once | PASS | lib.rs:349-353, gated by cfg(target_arch = "wasm32") |
| No heap allocation in no_std code | PASS | All storage uses fixed-size arrays and stack allocation |
static mut STATE gated |
PASS | lib.rs:361 behind cfg(target_arch = "wasm32") |
| Check | Status | Notes |
|---|---|---|
| Adversarial CSI input crash resistance | PASS | All modules clamp n_sc to MAX_SC (32), handle empty input |
| Configurable thresholds | PARTIAL | Thresholds are const values, not runtime-configurable via NVS. Acceptable for WASM modules loaded per-purpose |
| Event IDs match ADR-041 registry | PASS | Core (0-99), Medical (100-199), Security (200-299), Smart Building (300-399), Signal (700-729), Adaptive (730-749), Spatial (760-773), Temporal (790-803), AI Security (820-828), Quantum (850-857), Autonomous (880-888) |
| Bounded event emission rate | PASS | All modules use cooldown counters, periodic emission (% N == 0), and static buffer caps (max 4-12 events per call) |
Risk Level: LOW-MEDIUM
The codebase demonstrates strong security practices for an embedded no_std WASM target:
- No heap allocation in sensing modules
- Consistent bounds checking on all array accesses
- Event rate limiting via cooldown counters and periodic emission
- Host API properly isolated behind target-arch cfg gates
- Single panic handler, correctly gated
The primary concern (C-01) is an inherent limitation of returning references to static mut data in no_std environments. This is a known pattern in embedded Rust and is acceptable given the single-threaded WASM3 execution model, but must be documented.
The HIGH issues (H-01, H-02, H-03) involve potential division-by-zero and unchecked buffer access in edge cases. H-01 is the most actionable and should be fixed before production deployment.
The following CRITICAL and HIGH issues were fixed directly in source files:
- H-01: Added zero-length guard in
coherence.rs:process_frame() - H-02: Added zero-count guard in
occupancy.rszone variance computation - M-01: Added negative input guard in
lib.rs:on_frame() - M-02: Fixed stale phasor fields in
coherence.rs:process_frame() - M-06: Added compile-time assertion in
spt_micro_hnsw.rs
H-03 (rvf.rs patch_signature) is std-only builder code and was not fixed to avoid scope creep; a bounds check should be added before the builder is used in CI/CD pipelines.