Skip to content

Bound and scope the convergence grid: per-cell hits and per-signal detection grow unbounded under signal flood #223

Description

@forkwright

Finding

The convergence grid retains and materializes full per-cell hit history even though the detection path only ever needs a bounded per-cell distinct-domain count. This surfaces as two reinforcing failures. (1) ConvergenceGrid::ingest appends a DomainHit to a per-cell Vec unconditionally — no per-cell cap, no per-domain dedup — so only time-based eviction reclaims entries and within the eviction window a single attacker-chosen coordinate accumulates hits at the input rate. (2) SemainoPipeline::handle_aggregated runs ConvergenceGrid::detect once per notable aggregated signal; detect iterates every grid cell and clones every in-window DomainHit into the returned Convergence values, yet the only field any consumer reads is domain_count (itself capped at 7 distinct domains).

Evidence

crates/semaino/src/pipeline.rs:219 .detect(self.min_convergence_domains, self.time_window, now); runs for each notable aggregated signal (handle_aggregated is invoked at pipeline.rs:173).
crates/semaino/src/convergence.rs:117 self.cells.entry(cell).or_default().push(DomainHit { appends every located signal; eviction at convergence.rs:170 only drops hits older than the window, so within the window a cell's Vec grows with input rate.
crates/semaino/src/convergence.rs:158 hits: window_hits.iter().map(|h| (*h).clone()).collect(), clones all windowed hits across all cells on every detect.
domain_count is bounded: crates/semaino/src/convergence.rs:194 kind_discriminant yields at most 7 distinct domains, so storing or cloning more than one hit per domain per cell is pure overhead.
Downstream only domain_count is read — alert.rs:227 c.domain_count >= 3, alert.rs:300 conv.domain_count; Convergence.hits has no non-test consumer.

Why this matters

The documented trust model lets an OTA adversary stream many frames at one spoofed coordinate and treats OTA-driven resource exhaustion as directly harmful. The matched cell's Vec then holds attacker-rate × window DomainHits (each cloning a SignalKind) with no hard ceiling and no backpressure beyond channel buffering — attacker-controlled memory growth inside the correlation engine. That same redundant storage amplifies detect: per-signal work and allocation scale with the total in-window hit count across the entire grid rather than the one relevant cell, so under a flood that makes many signals notable this is O(N^2) CPU plus large transient allocations of data that is immediately discarded — all on the single-threaded main select! loop, degrading alert latency and providing an OTA-driven CPU/memory DoS amplifier.

Desired correction

Represent per-cell state as a bounded distinct-domain structure — at most one retained hit per of the ≤7 domain discriminants (a 7-entry map) or a bounded ring — so per-cell memory is independent of input rate; scope detect to the cell the triggering signal landed in (or maintain incremental per-cell distinct-domain counts) rather than scanning the whole grid; and stop materializing the hits clone no consumer reads. Done when: a sustained flood at a single coordinate cannot grow a cell's stored hits beyond a fixed bound, and per-aggregated-signal convergence work is bounded by the relevant cell's contents with no Vec<DomainHit> clone produced for cells or fields the alert path never consumes.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions