Files
ukmesh/docs/spam-detection.md
T
gadgethd 93cc3e1b38 Expand network intelligence and harden operations (#9)
Consolidates the stacked backend, privacy, network-intelligence, frontend, operations, mobile, and owner-cache changes after resolving main conflicts and passing the full CI suite.
2026-07-23 20:50:47 +01:00

259 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Message-Spam Detection (Spam Watch)
Detects people spamming the MeshCore mesh by finding **repeated, near-duplicate
messages** sent in short time windows, groups them into incidents, and estimates
a **coarse** origin area. Powers the public `ukmesh.com/spam` dashboard.
This is distinct from the older advert/identity-spoof detector
(`backend/src/mqtt/spamDetector.ts`), which scores repeater/device adverts. This
system is about **chat-message** abuse.
## How it works
```
packets (type 5, decoded channel msgs)
│ loadRecentMessages() backend/src/spam/repository.ts
MessageRecord[] (one per packet_hash, with observers)
│ clusterMessages() backend/src/spam/cluster.ts
Incident[] (near-duplicate text, close in time)
│ estimateOrigin() backend/src/spam/origin.ts
│ sanitizeIncident() backend/src/spam/sanitize.ts
spam_message_incidents / spam_message_members (public_json is pre-sanitized)
│ getPublic*() backend/src/spam/repository.ts
/api/spam/messages/* → Spam Watch dashboard (frontend SpamPage)
```
Only decoded channel messages are used (MeshCore public/known-key channels where
`payload.decrypted.message` exists). Direct messages and unknown-key channels stay
encrypted and are never inspected.
### Pipeline stages
1. **Normalize** (`normalize.ts`) — lowercase, trim, collapse whitespace,
normalize punctuation, collapse stretched repeats (`heyyyy``heyy`), and
strip/canonicalize URLs (drop scheme/`www.`/query so tracking variants
collapse). `ukmesh.com/spam` is recognised as a marker — supporting evidence,
never sufficient on its own.
2. **Fuzzy match** (`similarity.ts`) — message similarity blends trigram Dice,
token-set Jaccard and Levenshtein ratio; a shared canonical URL is a strong
signal. Username similarity adds prefix/substring affinity so `John`,
`John2`, `John_UK` read as related.
3. **Cluster** (`cluster.ts`) — time-ordered greedy clustering inside a join
window. Similar usernames lower the text-similarity bar but never create a
cluster alone. A qualification gate then separates abuse from normal chatter
(see below).
4. **Estimate origin** — two methods, path-based preferred:
**a. Relay-path adjacency chain-walk** (`spamResolver.ts`, used first for
public incidents). Every reception carries its ordered relay path; the **first
repeater is the source of truth** (the spammer transmitted into it). We take
the **closest receivers** (lowest hop count) and **walk each path backward from
the observer**, hop by hop: at each step we pick the candidate node that has a
confirmed RF link (`node_links`) to the previously-resolved node, else the
nearest candidate within range. Because a consecutive `(AA,BB)` hash pair is
only realisable where two such repeaters sit within range, the adjacency chain
disambiguates the otherwise-ambiguous 1-byte hashes — and the **random
deviations in the middle of the paths** give many independent chains that
converge on the same source. The chain's first repeaters are then combined into
a geographic consensus. Confidence is **tempered by the closest observer's hop
count**: a 2-hop reception is trusted (short chain); a source only heard 57
hops out is honestly less certain (long chain, compounding per-hop error) even
when the chains agree. (`spamResolver.ts` is an independent copy of the lazy
path resolver so the production resolver is never touched.)
**b. Observer-signal fallback** (`origin.ts`) — when paths can't be resolved
(no path hashes, or no confident consensus), fall back to the observer
estimate below. A flood radiates outward from its source,
so an observer that heard it at a **low hop count** sits close to the source
while distant relays only heard it after it crossed the network. The estimate
is therefore **anchored on the closest-receiver cohort** (the lowest hop
counts available — `min-hop + originNearHopSlack`, at least everyone within
`originNearHopMax` hops); their location gives the centroid, and the radius is
their spread plus a per-hop allowance. Confidence comes from how close the
nearest reception was — both absolutely and *relative* to how far the flood
otherwise travelled (a 2-hop reception of something that otherwise relayed 14
hops is strong localisation). This works UK-wide: dense areas yield tight,
high-confidence pins; sparse areas (where the closest reception is several
hops out) honestly produce a broad radius and lower confidence rather than a
false-precise point. Snaps to a coarse named UK region.
**Coverage note:** localisation quality tracks observer density — strong
across England, broader/lower-confidence in the far north of Scotland and the
islands. With fewer than `originMinObservers` geolocated receivers the origin
is reported as `insufficient` (no map zone) rather than guessed.
5. **Sanitize** (`sanitize.ts`) — the single gate to public output (see Privacy).
6. **Persist** (`repository.ts`) — upsert incidents; `first_seen` preserved via
`LEAST`; in-window incidents that no longer cluster are removed; older history
is frozen.
## What counts as spam (and what doesn't)
A cluster only becomes an incident when it is genuine abuse, not normal traffic:
- **Requires** `minTransmissions` near-duplicates with `minBurst` inside one
burst window.
- **Requires substantive content** — at least `minContentChars`, *or* a URL,
*or* the spam marker. Trivial/connectivity messages (`test`, `ack`, `morning`,
`gm`, …) never qualify on their own.
- **Requires a suspicious sender pattern** — a single repeating sender, one
dominant sender, or **rotating similar usernames**. Many *dissimilar* people
sending the same thing is treated as broad legitimate chatter, not one abuser.
- The **test channel is excluded by default** (it carries repeated test traffic
by design). Configurable via `SPAM_MESSAGE_EXCLUDE_CHANNELS`. A flood that
*also* hits another channel (e.g. Public) is still caught via those copies.
- Content is dampened only when it is **dominated by** connectivity/test tokens
— i.e. once benign words, bare numbers and 12-char fragments are removed,
nothing substantive is left (`test`, `rx ack 73`, `test 123`). A templated
message that merely *contains* the word `test` (e.g. an automated
`ANDY KIRBY MESHCORE(TM) 00:16:4: !test` flood) keeps real words and is **not**
treated as testing — so this camouflage no longer hides spam.
- A **sustained high-volume flood** (≥ `floodMinMessages` near-duplicates) is a
strong automated-abuse signal on its own and is scored up — but a benign
test/connectivity flood is never rewarded, so it stays below the public floor
no matter how large it grows.
## Incident lifecycle
- **active / ongoing** while the last matching message is within
`SPAM_MESSAGE_ONGOING_WINDOW_MIN`.
- **closed** after that cooldown elapses with no new matching messages.
The periodic analyzer recomputes recent incidents every
`SPAM_MESSAGE_INTERVAL_MS` and re-evaluates active→closed transitions each run.
## Configuration
All knobs live in `backend/src/spam/config.ts` and read from the environment.
| Env var | Default | Meaning |
| --- | --- | --- |
| `SPAM_MESSAGE_ANALYZER_ENABLED` | `true` | Run the in-process periodic analyzer |
| `SPAM_MESSAGE_INTERVAL_MS` | `300000` | Analyzer interval (5 min) |
| `SPAM_MESSAGE_WINDOW_HOURS` | `24` | Rolling window the analyzer (re)clusters |
| `SPAM_MESSAGE_TEXT_SIM` | `0.82` | Min text similarity to join a cluster |
| `SPAM_MESSAGE_TEXT_SIM_NAME` | `0.6` | Lower text bar when usernames are similar |
| `SPAM_MESSAGE_NAME_SIM` | `0.7` | Min username similarity (evidence) |
| `SPAM_MESSAGE_JOIN_WINDOW_MIN` | `30` | Max gap to join the same incident |
| `SPAM_MESSAGE_MIN_TRANSMISSIONS` | `8` | Min distinct messages for an incident |
| `SPAM_MESSAGE_BURST_WINDOW_MIN` | `10` | Burst window |
| `SPAM_MESSAGE_MIN_BURST` | `3` | Min messages inside one burst window |
| `SPAM_MESSAGE_MIN_CONTENT_CHARS` | `12` | Min canonical length (unless URL/marker) |
| `SPAM_MESSAGE_MAX_INDEPENDENT_SENDERS` | `4` | Above this (dissimilar) → broad chatter |
| `SPAM_MESSAGE_DOMINANT_SENDER_SHARE` | `0.6` | One sender at this share qualifies |
| `SPAM_MESSAGE_FLOOD_MIN_MESSAGES` | `25` | ≥ this many near-duplicates → sustained-flood boost |
| `SPAM_MESSAGE_EXCLUDE_CHANNELS` | `test` | Channel labels skipped (comma list) |
| `SPAM_MESSAGE_PUBLIC_MIN_SCORE` | `0.5` | Confidence floor for public list / status |
| `SPAM_MESSAGE_ONGOING_WINDOW_MIN` | `30` | How long an incident stays "ongoing" |
| `SPAM_MESSAGE_ORIGIN_USE_PATHS` | `true` | Anchor origin on the relay-path first repeater |
| `SPAM_MESSAGE_ORIGIN_PATH_MAX_PACKETS` | `40` | Max closest-receiver transmissions resolved |
| `SPAM_MESSAGE_ORIGIN_PATH_MIN_VOTES` | `3` | Min resolved paths before a path anchor is trusted |
| `SPAM_MESSAGE_ORIGIN_PATH_CLUSTER_KM` | `45` | Consensus cluster radius (outliers beyond it dropped) |
| `SPAM_MESSAGE_ORIGIN_PATH_AMBIGUOUS_WEIGHT` | `0.4` | Weight of an ambiguous vs certain repeater resolution |
| `SPAM_MESSAGE_ORIGIN_MIN_OBSERVERS` | `2` | Min observers before estimating origin (fallback) |
| `SPAM_MESSAGE_ORIGIN_NEAR_HOP_MAX` | `2` | Receivers within this many hops count as "near source" |
| `SPAM_MESSAGE_ORIGIN_NEAR_HOP_SLACK` | `1` | Width (hops) of the closest-receiver cohort that anchors it |
| `SPAM_MESSAGE_ORIGIN_PER_HOP_KM` | `12` | Radius allowance per hop (≈ LoRa link range) |
| `SPAM_MESSAGE_ORIGIN_MIN_RADIUS_KM` | `8` | Floor on reported radius (privacy) |
| `SPAM_MESSAGE_COARSEN_STEP_DEG` | `0.1` | Public coordinate coarsening (~11 km) |
| `SPAM_MESSAGE_REGION_SNAP_KM` | `70` | Max distance to snap to a named region |
**Tuning sensitivity:** lower `SPAM_MESSAGE_PUBLIC_MIN_SCORE` to surface more
(noisier) clusters; raise `SPAM_MESSAGE_MIN_TRANSMISSIONS` / `MIN_CONTENT_CHARS`
to be stricter.
## Confidence scores
Two separate confidences are shown:
- **Detection confidence** (incident `confidence`, 01) — how strongly the
pattern resembles spam. Built from volume, burst density, sustained-flood
size, repeated links, the spam marker, and rotating sender names; content
dominated by connectivity/test tokens is dampened. `High ≥ 0.66`,
`Medium ≥ 0.4`, else `Low`. Only incidents at/above
`SPAM_MESSAGE_PUBLIC_MIN_SCORE` appear publicly by default.
- **Origin confidence** (origin `confidence` + `level`) — how trustworthy the
reported *zone* is (region + radius), driven mainly by how close the nearest
reception was (low hop count) and how much it stands out from the flood's
overall reach, plus corroboration from other near-source receivers.
`insufficient` means too few geolocated observers to triangulate; the map
zone is then omitted. Note that confidence is confidence in the *zone* — a
larger radius absorbs the uncertainty, so "high confidence" means the source
is very likely inside that (coarse) area, not that it is pinpointed.
A single low-hop receiver gives a confident *region* but a coarse radius; more
near-source receivers tighten the radius and raise confidence further. On the
dashboard, click **Estimated origin** (or "View area on map") to see the coarse
confidence circle on a map.
## Privacy model
Abuse mitigation only. The public API serves the pre-computed `public_json`
column, which is produced solely by `sanitizeIncident()`:
- **Sender names** are redacted to a short non-identifying hint (`John_UK``Jo…`)
that still conveys variant similarity. Raw names stay in local-only columns.
- **Message samples** strip URLs (`[link]`, marker → `[spam-link]`), `@mentions`
(`[mention]`) and long hex/number runs (`[id]`), and are length-bounded.
- **Locations** are coarse only: a region label, a confidence level, and an
optional broad heat zone (centre coarsened to a ~0.1° grid, radius floored at
`ORIGIN_MIN_RADIUS_KM` and bucketed to 5 km). Exact coordinates, observer ids
and node metadata are never published.
- Clusters are labelled **"suspected spam cluster"** — neutral, not an
accusation of any named individual.
- No IPs, credentials, public keys or channel keys are exposed.
## API
| Endpoint | Description |
| --- | --- |
| `GET /api/spam/messages/status` | Current status: ongoing?, counts, last activity |
| `GET /api/spam/messages/incidents?status=&minConfidence=&limit=&offset=` | Sanitized incident list |
| `GET /api/spam/messages/incidents/:id` | One incident + sanitized timeline |
`minConfidence` defaults to `SPAM_MESSAGE_PUBLIC_MIN_SCORE`; pass `0` to include
lower-confidence clusters (the dashboard's "show lower-confidence" toggle).
## Deployment
The derived tables are created by migration
`backend/src/db/migrations/005_spam_message_incidents.sql` (additive, no
destructive changes). The one-shot `db-migrate` service applies unrecorded
migrations before the backend starts. Do not apply the SQL file or insert its
migration-ledger row manually.
For a normal deployment, use:
```bash
docker compose up -d --build
```
To inspect or apply pending migrations manually after building the image:
```bash
docker compose build db-migrate
docker compose run --rm db-migrate
```
The in-process analyzer starts automatically. To backfill historical incidents
or run a one-off pass:
```bash
# dry run over 30 days (no writes)
docker compose exec backend node dist/tools/recomputeSpamMessages.js --hours 720
# apply
docker compose exec backend node dist/tools/recomputeSpamMessages.js --hours 720 --apply
```
## Tests
Pure logic (normalization, fuzzy matching, username similarity, clustering,
ongoing/closed status, origin scoring, sanitization) is covered by unit tests:
```bash
cd backend && npm ci && npm test
```