Skip to content

M29 — The SuckerPot network (opt-in, anonymous)

Status: proposal for review. Scope: docs/ROADMAP.md, section M29. Every address and name in this document is a placeholder: documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24), documentation ASNs and example.org. The hub has no real domain yet: hub.example.org is a placeholder.

Many people run SuckerPot. Together their sensors see the same scanners, droppers and C2 servers from many places. The network pools what they see, confirms a target only when several independent sensors agree, sends one strong abuse report per target instead of many weak ones, follows the takedown, and shows every member what the network achieved.

Hard requirements (from the owner):

  • Opt-in only. Off by default, never forced, one click to leave, and leaving deletes the node’s data at the hub.
  • Anonymous in every direction. Nobody (hub operator, members, attackers, abuse desks, the public) can trace a node to a person, a home address, a domain or a host name. A node chooses at most a pseudonym and, if it wants, a country.
  • Members see who participates: pseudonyms, coarse country (opt-in, k-anonymous), activity and contribution, network totals.
PieceWhereRuns on
Protocol library suckerpot_netprotonew package netproto/ (pure, no I/O)imported by hub and ingest
Hubnew top-level hub/ (own Dockerfile, own hub/docker-compose.yml, own Alembic)project infrastructure, never a member’s home connection
Node workeringest/ingest/network/each member’s ingest, through the VPN egress gate only
Node APIapi/api/network.pyeach member’s api (no route out)
Network UIweb/src/pages/network*.tsxdashboard
Protocol specdocs/network-protocol.md (normative copy of §5)docs
node ingest ──(egress gate, vpn proxy, HTTPS, signed)──► hub.example.org (Caddy) ──► hub-api ──► hub-db
hub-worker ─┘
│ RDAP, SMTP (project mailbox)
▼
abuse desks
AssetMust not be learned by
Link node ↔ person (name, mailbox, payment, account)everyone. There is no account, no mail, no payment
Link node ↔ home address (the sensor’s public IP)everyone, including the hub
Link node ↔ domain or host name (dashboard URL, DynDNS)everyone
“Address X runs a SuckerPot sensor”attackers and the public (reduced, not eliminated: §3.3)
Which node saw which attacker at what timeattackers, members, the public. The hub knows it at day granularity for at most 14 days
#AdversaryCapabilities
A1Scanning attackerControls source IPs and payload URLs, knows which addresses it hit and when, can be a member, reads everything a member can read
A2Malicious member / Sybil farmEnrols many nodes, submits forged observations to get an innocent address reported or to poison the list
A3Curious hub operatorReads the hub database and the hub’s traffic
A4Hub compromise or seizureFull copy of hub database and disk at one point in time
A5Network observerSees node ↔ VPN and VPN ↔ hub traffic
A6Report recipientAbuse desk, often forwards reports to its customer (the attacker)
A7Fake hub / MITMTries to impersonate the hub to nodes
AttackMitigationResidual
A1 publishes “node X saw A at T” → locates XNever published per node. Public and member views show only consensus aggregates (≥ k nodes). Times rounded to the UTC day; a day is sent only after it ends + random 2 to 20 hNone from published data
A1 per-target canaries (one unique source IP, URL or file per target)A canary is seen by one node → never reaches consensus → never published. URLs and domains are sent blinded (hashed) and revealed only after consensus (§5.6), so a per-target URL never leaves the node in clearA canary IP or file hash sits in the hub DB for ≤ 14 days (matters only under A4)
A1 group testing (one source IP hits a set S of targets; listed ⇒ S holds ≥ k nodes)k = 5 eligible nodes, at least 2 distinct days, severity rule (scans alone are never reportable), randomised publication delayMany probe IPs over weeks can reveal that an address is a SuckerPot node, never which pseudonym. Stated in the docs. The sensor address is already exposed to attackers who connect to it
A1 correlates a pseudonym’s counters with a burst it causedMember stats are coarse tiers (0 to 4, log scale) and active days of the last full week, published a week late. No per-node counts, no real-time statsNegligible
Small-country pseudonym + countryA country is shown on the member list only if the member opted in and ≥ 3 members share that country; otherwise ”—”. Country counts in reports only when ≥ 3None
A3 learns node source IPsNodes connect through their VPN exits. The hub stores no IP, Caddy logs nothing, the app never logs or stores client addresses (rate limiting keys on an in-memory HMAC with a daily random key)The VPN exit address passes the hub’s TCP stack (it is a VPN exit)
A3 + A1 collude: the collector’s fetch of URL U from exit E at time T, and E seen at the hubHub keeps no IP. Submissions are daily with random delay, unrelated to fetch timesIf the hub operator logged IPs against policy and colluded with an attacker, an exit address could be linked to a pseudonym. Still a VPN exit, not a home address
A4 seizes hub DBObservations kept 14 days at day granularity; URLs and domains blinded until consensus; no IP, no mail, no free text except pseudonyms; enrolment and activity stored as daysWith an attacker’s own target logs, the 14-day observation sets could narrow a node’s address. Accepted for v1. Unlinkable submissions (anonymous credentials) are listed for protocol v2 (§12)
A5WireGuard to the VPN, TLS 1.3 to the hub. Requests are padded to a 4 KiB multipleVolume and timing of one daily batch
A6 / attacker receiving a forwarded reportHub reports carry day ranges, services, counts in buckets, number of sensors and countries; never node addresses, ports of a node, pseudonyms or node idsNone new
A2 poisoning§8 (consensus, age, reputation, cohorts, caps, PoW, never-list, human review in the first release)A patient, well-funded Sybil farm with ≥ 5 aged, well-behaved nodes. Hub review mode and dispute handling bound the damage
A7Hub public key pinned in NETWORK_HUB_KEY; every hub answer signed and bound to the request nonce; TLS with normal certificate checksPin rotation needs a node configuration change (by design)
#InvariantEnforced by
N1Nothing is sent unless NETWORK_ENABLED=true and an admin opted in in the UI (password re-entered). Default: off.Worker idle check + tests
N2Every network call goes through suckerpot_egress (gate open, VPN proxy), only to the host of NETWORK_HUB_URL, HTTPS, no redirects.ingest.egress.make_client + static egress test
N3Payloads are built from an allow-list of columns, never from events.data or raw requests. No credential, port, URL path of the node, Host header, SNI or banner is ever read by the builder.Builder reads only listed columns; planted-leak tests (§10)
N4Every item and every whole batch passes the M22 identity guard (suckerpot_reporting.guard, fmt="json"). Item failure drops the item; batch failure sends nothing. No confirmed self address → nothing is sent.Builder + tests
N5Times are UTC days. A day is submitted only after it ended plus a random 2 to 20 h delay.Scheduler + tests
N6URLs and domain names leave the node only as blinded hashes, and in clear only when the hub asks for a reveal of a hash already confirmed by ≥ k nodes, after the guard.Builder + reveal handler
N7The node’s private key is generated in ingest, stored encrypted (SecretBox), and never readable by the API role.Column grants + test
N8Leaving deletes the local identity and every network_* row, and asks the hub to delete the node (retried up to 7 days).Worker + tests
N9Hub answers are accepted only with a valid hub signature. Pulled data is validated (enums, IP and hash formats, sizes); nothing pulled is executed or triggers a connection to a target.netproto.verify_response + strict models
N10Demo data and documentation ranges never leave (demo = true rows skipped; the guard forbids documentation ranges).Builder + guard
#Invariant
H1No client IP address is stored or logged anywhere (Caddy log disabled, uvicorn --no-access-log, app log filter, no IP column in any table). Rate limits use an in-memory HMAC of the address with a random key rotated daily.
H2No analytics, no third-party resource, no cookies.
H3Per-node observations deleted after HUB_OBS_RETENTION_DAYS (14). Nonces after 10 min. Challenges after 10 min. Contributor links when the target is resolved or after 30 days. Inactive nodes (no request for 30 days) deleted with their data.
H4Timestamps about nodes are stored as dates (enrolment day, last active day), never times.
H5Published views (member pulls and any future public page) contain only consensus aggregates and coarse member stats (§7).
H6Every request is signed, size-capped and rate-limited; replays are refused.
H7Reports go out only for confirmed targets that are reportable (§8.4), deduplicated, under caps, and in the first release only after hub-operator approval.
SharedFormWhy
Attacker source IPclear (IPv4/IPv6 global unicast)Needed for consensus; hashing an IPv4 address protects nothing (2^32)
Services seenenum (sensor names: ssh, telnet, web, rdp, redis, docker, mysql, adb, mssql, smb, sip, synology)Not ports: a node’s host ports are a fingerprint
Activityenum connect, scan, login, login_success, command, download, exploit, httpSeverity rules
Tagsallow-list TAGS_V1 in netproto (behaviour tags from ingest/rules: mirai, crypto-miner, malware-drop, credential-stuffing, php2shell, eternalblue, bluekeep, sql-rce, container-escape, db-ransom, sip-fraud, persistence, recon, exec-attempt, synology-exploit, c3rb3r, and cve-* matching ^cve-\d{4}-\d{4,7}$). Any other tag is dropped. No tag may contain a project termContext
Countsbuckets 0: 1, 1: 2–9, 2: 10–99, 3: 100–999, 4: ≥1000 for sessions and login attemptsPrecise counts fingerprint a node
DayYYYY-MM-DD UTC, one day per batchTime bucketing
Payload URLblinded url_b (§5.6); payload host IP in clear when the URL host is an IP literal; blinded host_b when it is a namePer-target URLs
File hashsha256 (only of files the collector fetched, or hashes sensors computed: trusted = true), with family from the YARA allow-list FAMILIES_V1 and coarse file_type enumMalware takedowns
C2IP + port in clear, or blinded domain + port, with family and via_sha256; only confidence = 'high'C2 takedowns
Takedown checksper hub target id: up, down, unknownTakedown tracking

Not shared in v1:

  • credentials (attackers derive usernames and passwords from the target’s names, a leak risk, and of little value for takedowns);
  • commands and HTTP requests;
  • source ports; destination ports; precise times;
  • anything from research-scanner sessions (scanner_name IS NOT NULL) or benign sessions;
  • private, reserved, CGNAT, documentation addresses;
  • anything in the never-block list;
  • any self term.

Credentials may come back in a later protocol version as a separate opt-in. The field name credentials is reserved and the hub refuses it in v1.

5. Protocol spn/1 (normative; copied to docs/network-protocol.md)

Section titled “5. Protocol spn/1 (normative; copied to docs/network-protocol.md)”
  • HTTPS to NETWORK_HUB_URL (for example https://hub.example.org), base path /v1. JSON, UTF-8, Content-Type: application/json. No compression (no decompression bombs).
  • Request bodies ≤ 1 MiB for /v1/observations, ≤ 64 KiB elsewhere. Every JSON object rejects unknown fields; JSON depth ≤ 6.
  • Client User-Agent: spn-node/1. No cookies.
  • The request body is padded: field "pad" with spaces so the body length is a multiple of 4096.
  • Node identity: an Ed25519 key pair made on the node. node_key = base64url (no padding) of the 32-byte public key. Fingerprint = first 8 hex characters of sha256(public key), shown as Node 3f9a1c07.

  • Request headers:

    • X-SPN-Key: node_key;
    • X-SPN-Timestamp: integer Unix seconds;
    • X-SPN-Nonce: base64url of 16 random bytes;
    • X-SPN-Signature: base64url Ed25519 signature of the canonical request string.
  • Canonical request string (lines joined with \n, no trailing newline):

    SPN1-REQ
    <METHOD uppercase>
    <path with query exactly as sent, e.g. /v1/targets?since=c_12&limit=1000>
    <X-SPN-Timestamp>
    <X-SPN-Nonce>
    <lowercase hex sha256 of the raw body bytes (of the empty string for GET/DELETE)>
  • Hub checks, in this order, before parsing the body:

    1. size;
    2. headers present and well-formed;
    3. |now − timestamp| ≤ 300 s (stale_timestamp);
    4. key known, except for enrolment (unknown_node);
    5. signature (bad_signature);
    6. nonce unseen for this key in 10 min (replayed_nonce, stored in nonces);
    7. rate limit.
  • Response signature: every answer to a signed request carries X-SPN-Hub-Signature: Ed25519 by the hub key over

    SPN1-RESP
    <request X-SPN-Nonce>
    <HTTP status code>
    <lowercase hex sha256 of the response body>

    The node verifies it against NETWORK_HUB_KEY (base64url public key) and refuses the answer otherwise (hub_signature).

  • Golden test vectors (fixed keys, timestamp, nonce, body → canonical string and signature) live in netproto/tests/vectors.json and in the spec.

Status 4xx/5xx with body {"error": "<code>"}, codes:

CodeMeaning
too_largeBody over the size cap
invalid_payloadSchema or value validation failed
unsupported_protocolUnknown protocol value
stale_timestamp, replayed_nonce, bad_signature, unknown_node§5.2 checks
pow_invalid, challenge_expiredEnrolment proof
pseudonym_invalid, pseudonym_takenPseudonym rules
enrolment_closedGlobal enrolment cap reached
rate_limitedWith Retry-After seconds
day_not_closedDay not finished yet
day_too_oldDay older than 3 days
server_errorAnything else

A soft-suspended node is never told: its requests succeed and its data is ignored.

MethodPathSignedBody → answer
GET/v1/infono{protocols: ["spn/1"], hub_key, pow: {algorithm: "sha256-leading-zero-bits", difficulty}, limits: {...}, thresholds: {min_nodes, min_age_days}, today}
POST/v1/enrol/challengeno{protocol, node_key} → {challenge (b64url 32 bytes), difficulty, expires_in: 600}
POST/v1/enrolyes, by the new key{protocol, challenge, pow_nonce (b64url ≤ 16 bytes), pseudonym?: str, country?: "CH", show_country: bool} → {node_id, fingerprint, enrolled_day, eligible_from_day}
GET/v1/nodeyes→ {node_id, fingerprint, pseudonym, country, show_country, enrolled_day, eligible_from_day, standing: "new"|"good"|"low", active_days_14} (standing is coarse; the reputation number is never sent)
PATCH/v1/nodeyes{protocol, pseudonym?, country?, show_country?} → node as above
DELETE/v1/nodeyes→ {deleted: true}. Deletes the node, its observations, contributor links, takedown votes, stats and nonces
POST/v1/observationsyesbatch (§5.5) → {accepted: {sources, payloads, c2, files}, rejected: {...counts by reason}, duplicate: bool}
GET/v1/targets?since=<cursor>&limit=<1..1000>yes→ {cursor, targets: [Target], reveal: [{kind, value_b}], recheck: [{id, kind, value}]}
POST/v1/revealsyes{protocol, items: [{kind: "url"|"domain", value_b, value}]} (≤ 200) → {accepted, rejected}
POST/v1/rechecksyes{protocol, day, items: [{id, state: "up"|"down"|"unknown"}]} (≤ 500) → {accepted}
GET/v1/networkyes→ summary (§7.1)

Target:

{"id": "t_8c1d2e", "kind": "ip", "value": "198.51.100.23", "value_b": null,
"state": "confirmed", "nodes": 1, "countries": 4,
"first_day": "2026-10-26", "last_day": "2026-10-30",
"services": ["ssh", "telnet"], "activity": ["login", "command", "download"],
"tags": ["mirai"], "families": ["mirai"],
"reported_day": null, "taken_down_day": null}

Field rules:

  • kind: ip, url, domain, sha256, c2_ip, c2_domain.
  • value is null for a blinded kind not yet revealed.
  • nodes is a bucket: 0: 5–9, 1: 10–24, 2: ≥ 25.
  • countries is null below 3.
  • state: confirmed, reported, taken_down, disputed, expired.
  • The cursor is opaque. recheck lists confirmed or reported targets the node is asked to check (at most 50 per pull).
{
"protocol": "spn/1",
"batch_id": "q8V0b3...", // b64url 16 random bytes; idempotency key per node and day
"day": "2026-10-30",
"sources": [
{"ip": "198.51.100.23", "services": ["ssh"], "activity": ["login", "command", "download"],
"tags": ["mirai"], "sessions": 1, "attempts": 3}
],
"payloads": [
{"url_b": "9f2c...64hex", "host_ip": "198.51.100.9", "host_b": null,
"sha256": "e3b0...64hex", "family": "mirai", "file_type": "elf-arm", "fetched": true}
],
"c2": [
{"ip": "198.51.100.50", "domain_b": null, "port": 23, "family": "mirai", "via_sha256": "e3b0...64hex"}
],
"files": [
{"sha256": "e3b0...64hex", "family": "mirai", "file_type": "elf-arm"}
],
"pad": " "
}
  • Caps: sources ≤ 5000, payloads ≤ 1000, c2 ≤ 1000, files ≤ 1000. day must be closed (hub time) and ≤ 3 days old.
  • Idempotency: a second batch with the same batch_id answers duplicate: true and changes nothing; a different batch_id for an already submitted day is invalid_payload. One batch per node per day.
  • Validation on both sides: IPs global unicast (documentation ranges allowed only with HUB_ALLOW_DOCUMENTATION=1, test overlay only); hex lengths; enums; port 1–65535; every list deduplicated.

value_b = lowercase hex sha256( "spn/1|" + kind + "|" + canonical(value) ), where:

  • url: scheme and host lowercased, IDNA host, default port removed, fragment removed, path and query kept byte for byte;
  • domain: lowercased IDNA, no trailing dot.

The node can recompute value_b for its own recent indicators. The hub counts nodes per value_b. When a blinded value is confirmed (§8), the hub puts it in reveal. Any member node that has the clear value sends it to /v1/reveals after the guard. The hub checks sha256(...) == value_b, then validates: URL parse, host global or a name, not on the never-list.

  • Find pow_nonce such that sha256(challenge || pubkey || pow_nonce) has at least difficulty leading zero bits.
  • Default difficulty 27 (about a minute of one CPU core in Python). The node solves it in a separate, low-priority child process (os.nice), so ingest is not slowed. The hub raises it for the requesting address prefix only: one more bit above HUB_ENROL_SOFT_CAP challenges of that prefix in the current 10-minute window, then one more per HUB_ENROL_STEP (at most +10).
  • It is a speed bump; age and reputation are the real defence (§8).
  • protocol in every body and the /v1 prefix. Additive fields need no new version (receivers that see an unknown field reject it, so a field is added to the hub first, then to nodes).
  • A breaking change is spn/2 under /v2. The hub serves both for at least 6 months, and /v1/info lists them.
hub/
Dockerfile # multi-stage, python:3.13 slim pinned, uid 10001, read-only
docker-compose.yml # hub stack, deployed separately by the project
Caddyfile
.env.example # HUB_* placeholders only
pyproject.toml uv.lock
alembic.ini migrations/ # own history: h0001_initial, ...
hub/
main.py config.py logging.py
auth.py # §5.2 checks, nonce store, response signing
ratelimit.py # in-memory token buckets; the ONLY module that reads the client address
models.py db.py
routes/{info,enrol,node,observations,targets,network}.py
consensus.py reputation.py stats.py retention.py never.py
reports/{select,render,rdap,send}.py
worker.py # python -m hub.worker: hourly consensus, daily stats, retention, reports
admin.py # python -m hub.admin: CLI only (no admin web UI)
healthcheck.py
tests/

The hub is added to the justfile packages list and the CI python job. Its own CI job hub-integration runs the hub tests against a Postgres service and an end-to-end test (§10).

ServiceImageNetworksNotes
caddycaddy:2.x pinned by digesthub-edge, hub-publicPorts 80, 443. TLS via ACME for HUB_DOMAIN. log output discarded globally; request_body max_size 1MB; header limits; only /v1/* proxied; HSTS
hub-apibuilt from hub/Dockerfilehub-edge, hub-internaluvicorn, --no-access-log --no-server-header --limit-concurrency 200, hardened like the main stack
hub-workersame image, python -m hub.workerhub-internal, hub-outOnly service with outbound (RDAP, SMTP)
hub-dbpostgres:17.x pinnedhub-internalExternal volume ${HUB_VOLUME_PREFIX:?}_db-data, log_connections=off, log_statement=none
hub-migratesame image, alembic upgrade head then role passwordshub-internalOne-shot
hub-backuppostgres imagehub-internalDaily dump, 7 kept (same script shape as the main stack)
  • hub-internal is internal: true.
  • DB roles: hub_api (INSERT/SELECT on nodes, nonces, challenges, observations; SELECT on targets, stats) and hub_worker (everything except schema).
  • The hub signing key is a Docker secret file (HUB_SIGNING_KEY_FILE), made by python -m hub.admin gen-signing-key (prints the public key to publish as NETWORK_HUB_KEY).
nodes(id uuid PK DEFAULT gen_random_uuid(), pubkey bytea UNIQUE NOT NULL CHECK (length(pubkey)=32),
pseudonym text NULL, pseudonym_key text UNIQUE NULL, -- lower-cased for uniqueness
country char(2) NULL, show_country bool NOT NULL DEFAULT false,
enrolled_day date NOT NULL, last_active_day date NOT NULL,
reputation real NOT NULL DEFAULT 0.6, standing text NOT NULL DEFAULT 'active', -- active|soft_suspended
protocol text NOT NULL)
enrol_challenges(challenge bytea PK, pubkey bytea NOT NULL, difficulty int NOT NULL, expires_at timestamptz NOT NULL)
nonces(node_id uuid REFERENCES nodes ON DELETE CASCADE, nonce bytea, expires_at timestamptz, PRIMARY KEY(node_id, nonce))
batches(node_id uuid REFERENCES nodes ON DELETE CASCADE, day date, batch_id bytea, PRIMARY KEY(node_id, day))
observations(id bigint identity PK, node_id uuid REFERENCES nodes ON DELETE CASCADE, day date NOT NULL,
kind text NOT NULL, -- ip|url|domain|sha256|c2_ip|c2_domain
value text NOT NULL, -- clear ip, sha256 hex, ip:port, or value_b hex for blinded kinds
services text[], activity text[], tags text[], families text[],
sessions smallint, attempts smallint,
UNIQUE(node_id, day, kind, value))
targets(id text PK, -- 't_' + 6 random bytes hex
kind text, value text NULL, value_b text NULL, UNIQUE(kind, coalesce(value_b, value)),
state text NOT NULL, -- candidate|confirmed|reported|taken_down|disputed|expired
node_count int, weight real, country_count int, first_day date, last_day date,
services text[], activity text[], tags text[], families text[],
confirmed_day date, reported_day date, taken_down_day date, report_id bigint NULL, updated_at timestamptz)
target_contributors(target_id text REFERENCES targets ON DELETE CASCADE, node_id uuid REFERENCES nodes ON DELETE CASCADE,
PRIMARY KEY(target_id, node_id)) -- deleted when resolved or after 30 days
takedown_votes(target_id text REFERENCES targets ON DELETE CASCADE, node_id uuid REFERENCES nodes ON DELETE CASCADE,
day date, state text, PRIMARY KEY(target_id, node_id, day)) -- 30 days
reports(id bigint identity PK, recipient text, kind text, target_ids text[], state text, -- queued|approved|sent|failed|cancelled
body_text text, xarf text, created_day date, approved_by text NULL, sent_at timestamptz NULL, error_code text NULL)
recipient_log(recipient text, day date, count int, PRIMARY KEY(recipient, day))
node_stats_weekly(node_id uuid REFERENCES nodes ON DELETE CASCADE, week date, active_days smallint, tier smallint,
PRIMARY KEY(node_id, week))
network_daily(day date PK, active_nodes int, members int, confirmed int, reported int, taken_down int)
never_list(value text PK, kind text, note text)
hub_audit(id bigint identity PK, at timestamptz, actor text, action text, detail jsonb) -- admin CLI only, no node data

No table has an IP-address column about a client. pubkey is the only node identifier.

LimitDefault
Unsigned endpoints per client address (in-memory HMAC key)10/min, 100/day
Signed requests per node60/hour
Observation batches1 per node per day
Distinct new values per node per day20,000
Reveals per node per day2,000
Enrolment difficulty, per address prefixHUB_ENROL_SOFT_CAP=3 challenges per window (+1 bit above, then +1 per HUB_ENROL_STEP=3)
Enrolments, globalHUB_ENROL_HARD_CAP=5000/day (enrolment_closed), set far above any honest day
Pseudonym changes1 per node per 7 days

Subcommands:

  • gen-signing-key;
  • reports list|show|approve <id>|cancel <id>;
  • target dispute <id> --note;
  • never add|remove <value>;
  • node suspend|unsuspend <fingerprint>;
  • stats.

Each writes hub_audit. No web admin: smaller attack surface.

{"generated_day": "2026-11-01",
"totals": {"members": 37, "active_7d": 29, "countries": 9,
"confirmed_30d": 412, "reported_30d": 318, "taken_down_30d": 141, "median_days_to_takedown": 4},
"weekly": [{"week": "2026-10-19", "confirmed": 96, "reported": 81, "taken_down": 33}],
"countries": [{"cc": "CH", "members": 4}],
"members": [{"fingerprint": "3f9a1c07", "pseudonym": "night-owl", "country": "CH",
"member_since": "2026-09", "active_days": 6, "tier": 3}]}

Rules:

  • countries only lists countries with ≥ 3 members who set them.
  • A member’s country is non-null only if they opted to show it and their country has ≥ 3 showing members.
  • member_since is a month.
  • active_days and tier refer to the last full ISO week. tier is 0 to 4 by distinct values submitted: 0: 0, 1: 1–99, 2: 100–999, 3: 1k–9.9k, 4: ≥ 10k.
  • Members are listed in random order per day (no ranking by activity).
  • Computed once a day by the worker.

A reported target becomes taken_down when, after reported_day:

  • at least 2 eligible nodes vote down for it on 3 different days;
  • and no eligible node votes up on the last of those days.

It goes back to reported if up votes come back within 14 days. Node votes come from M27’s local takedown re-checks (§9.5). The hub itself never connects to targets in v1.

8. Trust: consensus, reputation and reports

Section titled “8. Trust: consensus, reputation and reports”

A node’s observations count toward consensus only when all of these hold:

  • standing = active;
  • enrolled_day ≤ today − HUB_NODE_MIN_AGE_DAYS (14);
  • it submitted batches on ≥ HUB_NODE_MIN_ACTIVE_DAYS (7) of the last 14 days;
  • reputation ≥ HUB_MIN_REPUTATION (0.3).

For each (kind, value) with observations in the last HUB_WINDOW_DAYS (7) days, let E be the set of eligible contributing nodes. The target is confirmed when all of these hold:

  1. |E| ≥ HUB_MIN_NODES (5) and Σ min(reputation, 1) over E ≥ HUB_MIN_WEIGHT (3.0);
  2. observed on ≥ 2 distinct days, or |E| ≥ 2 × HUB_MIN_NODES;
  3. cohort cap: no single enrolment ISO week supplies more than HUB_COHORT_MAX_SHARE (0.5) of E (a Sybil farm usually enrols at once);
  4. not on the never-list (public resolvers, large CDNs and SaaS, research scanners by ASN from ingest/rules/scanners.yaml copied into the image, reserved ranges, entries added by the admin);
  5. corroboration: for ip, if no external source (the hub’s own optional AbuseIPDB, GreyNoise or abuse.ch keys) knows it, condition 1 needs 2 × HUB_MIN_NODES nodes.

Otherwise the target stays candidate (never published). Contributor links are kept in target_contributors for reputation.

EventChange
Start0.6 (5 eligible nodes at start weigh exactly 3.0, the confirmation threshold; reputation otherwise rises only through confirmed targets, so a lower start would deadlock a new network)
Each confirmed target the node contributed to+0.01 (max +0.05/day), cap 1.0
A target it contributed to is disputed (admin, abuse desk proved false, or later hits the never-list)−0.1
Over 14 days > 500 distinct values and > 95 % never corroborated by anyone−0.05/day
Contributed to a taken_down target+0.02
  • Below 0.2 the node is soft_suspended: requests succeed, data is ignored, standing shows low.
  • The exact number is never sent to anyone; the node sees new, good or low.
Section titled “8.4 Who sends the report: the hub (recommended)”
Hub sendsEach node sends
StrengthOne report: “observed by 15 independent sensors in 8 countries between 26 and 30 October”15 separate weak reports
Abuse-desk loadOne mail per desk per week, deduplicatedSpam; desks blacklist the sender
Member anonymityMembers send nothing, store no mail credentials, own no mailboxEach member needs an anonymous mailbox and the M22/M25 workflow
Evidence detailDay ranges and buckets only (no per-event lines)Exact times and ports, but each one leaks more about the node
Central riskThe hub’s mailbox is a target and a single point of failure; the project needs an anonymous identityNone central
Legal / abuse handlingReplies go to one project mailboxScattered

Recommendation: the hub sends. Members keep M22 for their own local reports. When a node is a member, its M22 worker skips subjects that the network has confirmed or reported in the last 30 days (§9.4), so desks never get both.

  • Reportable:
    • ip with activity in {login_success, command, download, exploit} from ≥ min nodes, or attempts bucket ≥ 3 from ≥ min nodes;
    • url / sha256 fetched (fetched: true) by ≥ 2 nodes;
    • c2_ip / c2_domain confirmed.
    • Scans and connects alone are never reportable.
  • Contact: RDAP (ported from ingest/ingest/abuse/rdap.py with the same size, time and depth limits); domains go to the registrar.
  • Grouping: one report per (recipient, kind) per 7 days; a target is never reported twice within 30 days unless it came back up after taken_down.
  • Caps: HUB_REPORT_DAILY_CAP (100) mails per day globally, 1 per recipient per day.
  • Modes (HUB_REPORT_MODE):
    • review (default for the first release): reports wait for hub.admin reports approve;
    • auto: sent after HUB_REPORT_HOLD_HOURS (24) unless cancelled;
    • off.
  • Rendering:
    • plain text and X-ARF v0.2 (category abuse, report type per kind), using suckerpot_reporting helpers where they fit (the X-ARF YAML writer);
    • evidence = day range, services, activity, tags, families, bucketed counts, number of sensors (bucket) and countries (only ≥ 3), sha256 and revealed URLs;
    • never node ids, pseudonyms or fingerprints.
  • Sending: SMTP over TLS from the project mailbox (HUB_SMTP_*), neutral headers as in M25. reports.state and targets.reported_day are updated.

Additions to sections 8.1 to 8.5, implemented in hub/:

  • Active means data. A node counts as active on a day only if that day’s batch held at least one observation (batches.items > 0); empty batches keep nobody eligible.
  • Twice the nodes for more kinds. The “2 × HUB_MIN_NODES unless an external source knows it” rule applies to ip, payload_ip (payload hosts given as an address; M30), c2_ip, url, domain and c2_domain.
  • Payload hosts need a fetched file (M30). A payload_ip target is reportable (as a malware download location) only when ≥ 2 eligible nodes fetched a file from it and ≥ k eligible nodes saw it, the same fetched rule as url and domain. Typing wget http://<victim>/x into honeypots never mails the victim’s abuse contact. Only addresses can be looked up; a blinded value always needs twice the nodes.
  • C2 needs a dropper. A c2_* target is confirmed only with a via_sha256 that is itself confirmed and was fetched by at least two eligible nodes.
  • Reportability uses eligible nodes only and the target’s k-supported fields; fresh keys cannot make a scan reportable as an exploit or fake a fetched payload.
  • k-supported publication. last_day, first_day, services, activity, tags, families follow what at least k eligible nodes support. nodes and countries are frozen when the target is confirmed. A single sighting on a listed target changes nothing a member sees.
  • Reputation bonus for a confirmation only if the contributors span two enrolment cohorts.
  • Auto mode is closed until HUB_AUTO_MIN_COHORTS (5) independent cohorts exist among eligible nodes, and never approves C2 reports (an operator does).
  • Member tier counts contributions to values that reached consensus in the week, capped per kind and with a stable jitter of up to 25 %. What a node merely sent never moves it.
  • Enrolment cost. One more proof-of-work bit per HUB_ENROL_STEP challenges of the same address prefix above the soft cap (at most +10), so a flood prices out the flooder only; pending challenges are capped per address prefix (IPv6 by /64).
  • Research-scanner ASNs (section 8.2, rule 4) cannot be applied: the hub has no ASN database and the protocol carries no ASN. Scanner address ranges go on the never-list by the operator.
  • Responses are bound to the whole request (see docs/network-protocol.md).
VariableDefaultMeaning
NETWORK_ENABLEDfalseMaster switch (env). Without it the UI explains and shows just set NETWORK_ENABLED=true
NETWORK_HUB_URLemptyhttps://hub.example.org placeholder. Empty = “no hub configured”
NETWORK_HUB_KEYemptyHub public key (base64url). Required with the URL
NETWORK_INTERVAL_S900Worker cycle
NETWORK_SUBMIT_DELAY_MIN_H / _MAX_H2 / 20Random delay after the day ends
NETWORK_MAX_SOURCES5000Batch cap (top by severity, then by sessions)

Passed to ingest (all) and api (NETWORK_ENABLED, and whether the URL and key are set, not their values).

9.2 Migration 0028_network (node database)

Section titled “9.2 Migration 0028_network (node database)”
network_membership(id smallint PK CHECK (id=1),
desired text NOT NULL DEFAULT 'off', -- off|member|leave
state text NOT NULL DEFAULT 'off', -- off|enrolling|member|leaving|error
pseudonym text, country char(2), show_country bool NOT NULL DEFAULT false,
node_id uuid, fingerprint text, pubkey bytea, privkey_enc bytea,
enrolled_day date, eligible_from_day date, standing text,
next_submit_at timestamptz, last_submit_day date, last_pull_at timestamptz, pull_cursor text,
leave_requested_at timestamptz, last_error_code text,
requested_by text, requested_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now())
network_submissions(id bigint identity PK, day date UNIQUE, batch_id text, sent_at timestamptz,
status text, -- sent|failed|skipped
counts jsonb, dropped jsonb, -- {"guard": 3, "cap": 120, "self": 1}
error_code text, payload jsonb) -- exact JSON sent, ≤ 256 KiB, kept 7 days (transparency)
network_targets(kind text, value text, value_b text, hub_id text UNIQUE, state text, nodes smallint,
countries smallint, first_day date, last_day date, services text[], activity text[], tags text[],
families text[], reported_day date, taken_down_day date, mine bool NOT NULL DEFAULT false,
updated_at timestamptz, PRIMARY KEY(kind, coalesce(value, value_b)))
network_summary(id smallint PK CHECK (id=1), data jsonb, fetched_at timestamptz)

Grants:

  • suckerpot_ingest: all on network_*.
  • suckerpot_api:
    • SELECT on network_submissions, network_targets, network_summary;
    • column-level SELECT on network_membership excluding privkey_enc (and pubkey is fine);
    • UPDATE only (desired, pseudonym, country, show_country, requested_by, requested_at).
  • Retention: network_submissions.payload nulled after 7 days, rows after 90; network_targets expired after 180 days.

Modules:

  • config.py;
  • identity.py (key generation, SecretBox, sign);
  • client.py (signed requests via make_client, response verification);
  • builder.py (§9.4);
  • scheduler.py (day selection and delay);
  • sync.py (targets, reveals, rechecks, summary);
  • takedown.py (M27 adapter, §9.5);
  • worker.py (IntelWorker shape: run(publish), status(), aclose()).

Wired in ingest/ingest/main.py like the other *_ENABLED workers. Status goes to enrichment_status row network (state and reason codes only).

Each cycle:

  1. If NETWORK_ENABLED is false, or desired = off and state = off: idle.
  2. Gate closed (gate_open() false): wait, state unchanged (egress_closed).
  3. desired = leave:
    • DELETE /v1/node;
    • on success or unknown_node: wipe network_membership (back to defaults), delete all network_targets, network_submissions and network_summary rows;
    • on a network error: retry each cycle for 7 days, then wipe locally anyway and record leave_unconfirmed. The hub deletes inactive nodes after 30 days.
  4. desired = member, state off or error:
    • generate an Ed25519 key (cryptography), store privkey_enc with the SecretBox (refuse with no_settings_key if SETTINGS_ENCRYPTION_KEY is unset);
    • GET /v1/info;
    • challenge; PoW in asyncio.to_thread with a 15-minute cap;
    • POST /v1/enrol;
    • store node_id, fingerprint, days. State member.
  5. Pseudonym or country changed in the row since the last sync → PATCH /v1/node.
  6. Submit: if now ≥ next_submit_at and the previous UTC day is not in network_submissions:
    • require a confirmed self address (terms.has_self_address), else waiting_self_address;
    • build (§9.4), POST /v1/observations, record;
    • set next_submit_at = next day 00:00 UTC + uniform(min, max) hours.
    • At most the last 2 closed days are ever submitted (older days are skipped, never back-filled).
  7. Pull: every 6 h, GET /v1/targets until the cursor is current, upsert network_targets (mine = value or value_b is in this node’s submitted values of the last 14 days, computed from the DB). Then:
    • answer reveal items the node has (guarded per item);
    • answer recheck items through takedown.local_states.
  8. Summary: daily GET /v1/network → network_summary; GET /v1/node → standing.
  • Inputs: only these columns, for rows with demo = false.

    • sessions: src_ip, sensor, tags, benign, scanner_name, counters (login attempts, commands, downloads), first_seen, last_seen within the UTC day.
    • indicators (type, value, role, host, trusted) seen that day.
    • samples, sample_links (kind IN ('c2_ip','c2_domain'), confidence = 'high').
    • fetch_targets (status = 'fetched').
  • Never read: events.data, credentials, commands, HTTP fields.

  • Activity mapping:

    Session factActivity
    connect onlyconnect
    probe or scan tagscan
    login attemptslogin
    accepted loginlogin_success
    commandscommand
    download attemptdownload
    exploit or CVE tagsexploit
    web requestshttp
  • Exclusions:

    • benign and scanner_name IS NOT NULL sessions;
    • non-global addresses;
    • the never-block list (suckerpot_db.blocklist_values);
    • self terms (SelfTerms);
    • tags not in TAGS_V1, families not in FAMILIES_V1.
  • Blinding: URLs and domain hosts are blinded (§5.6).

  • Guard: every item is serialised and checked with guard(item, terms, fmt="json", ctx=GuardContext()). A failing item is dropped (dropped.guard += 1). Then the full batch body is guarded once more; failure means status = failed, error_code = guard_failed, nothing sent.

  • Caps: sources sorted by severity (exploit > download > command > login_success > login > others), then sessions bucket; cut at NETWORK_MAX_SOURCES (dropped.cap).

  • The exact JSON sent is stored in network_submissions.payload (shown in the UI, §9.6).

  • M22 dedupe (ingest/ingest/abuse/candidates.py): when membership state = member, skip subjects whose value is in network_targets with state IN ('confirmed','reported', 'taken_down') and last_day in the last 30 days. The Abuse reports UI shows a chip “Reported by the network” on such subjects in the candidate list (API adds network_state to the subject JSON).
  • Blocklists (ingest/ingest/blocklist/): a network-confirmed ip, url, domain or sha256 gets a fixed +2 score with the reason “Confirmed by the SuckerPot network (5 or more sensors)”. Overrides still win.
  • M27 takedown tracking:
    • Adapter takedown.local_states(session, items: list[tuple[str, str]]) -> dict[str, Literal["up","down","unknown"]], keyed by hub target id. It reads M27’s local re-check results. Until M27 is merged it answers unknown for everything, and nothing is sent.
    • The node’s Impact page (M27) gets network figures from GET /api/v1/network/impact.
MethodPathWhoAnswer
GET/api/v1/network/statusany user{enabled_env, hub_configured, desired, state, pseudonym, country, show_country, fingerprint, enrolled_day, eligible_from_day, standing, last_submit_day, next_submit_at, last_error_code, waiting: null|"self_address"|"egress"|"settings_key"}. 404 never (M28’s setup step hides itself when enabled_env is false and no hub is configured)
PUT/api/v1/network/membershipadmin, CSRF, password again{join: true, pseudonym?, country?, show_country?, accept: ["sources","payloads","c2","files","rechecks"]}. All five must be listed (the explicit consent list). Pseudonym validated locally (§9.7) and through the guard. Audit network.join (no pseudonym in the audit detail, only pseudonym_set: bool)
PATCH/api/v1/network/membershipadmin, CSRF{pseudonym?, country?, show_country?} → audit network.update
DELETE/api/v1/network/membershipadmin, CSRF, password againSets desired = leave. Audit network.leave
GET/api/v1/network/previewadminThe batch that would be sent for yesterday, built by the same builder code? No: the API has no builder. Instead the API returns the last stored network_submissions.payload plus counts, and a “preview” requested through network_preview_requests? Keep it simple: v1 shows the last sent batch, and before joining, a static example built from the user’s own data is not offered. The explanation lists every field
GET/api/v1/network/submissions?limit=30adminRows of network_submissions (payload only on GET /api/v1/network/submissions/{day})
GET/api/v1/network/targets?state=&kind=&mine=&cursor=any userPaginated network_targets
GET/api/v1/network/summaryany usernetwork_summary.data plus fetched_at
GET/api/v1/network/impactany user{network: {confirmed_30d, reported_30d, taken_down_30d, median_days_to_takedown}, mine: {helped_confirm_30d, helped_takedown_30d}} computed from network_targets (mine) and the summary

setup_status.py: new item network: off when not a member, ok when member and last submission ≤ 2 days ago, warning with the waiting reason otherwise.

  • 3 to 24 characters, ^[A-Za-z0-9][A-Za-z0-9 _.-]{1,22}[A-Za-z0-9]$, no ://, no @, no sequence of 4+ digits (avoids postcodes, years and addresses).
  • Passes the identity guard (owner names and domains refused) and a reserved list (admin, hub, official, moderator, and the project terms).
  • The UI says: “Do not use a name you use anywhere else.” Empty = shown as “Node 3f9a1c07”.

enrolment_closed and pow_timeout are temporary. The node keeps desired = member, stays in enrolling, reports last_error_code = waiting_enrolment (the API shows waiting: "enrolment") and retries with exponential backoff (15 minutes, doubling, at most 6 hours). Only pseudonym_taken and pseudonym_invalid end the attempt.

Navigation. New entry in the Defend space: “Network” (lucide Share2), hub with tabs “Overview” (/network) and “Members” (/network/members). Settings gets a “Network” tab (/settings?tab=network) for joining and leaving. M28’s setup step network reuses the join card.

Not a member. The Overview page explains in three short paragraphs:

  • what the network does;
  • exactly what is shared (the §4 table in plain words, with “never shared” listed as well);
  • the residual risk sentence (“an attacker who probes many addresses could learn that your address runs a sensor, never who you are”).

It shows network totals from the hub only if the node was once a member (otherwise no hub contact at all; non-members never call the hub). Admin sees “Join the network” opening the join dialog:

  • five consent checkboxes;
  • the pseudonym field with the warning;
  • optional country select (prefilled empty, never auto-detected), “Show my country next to my pseudonym” off by default;
  • the password field.

Viewers see the explanation only.

Member.

  • Overview opens with a sentence: “The network took down 141 malware hosts and attack sources in the last 30 days. Your sensor helped confirm 23 of them.”
  • KPI row (StatTile): members active this week, confirmed (30 d), reported (30 d), taken down (30 d), median days to takedown.
  • Charts:
    • weekly bars confirmed / reported / taken down (Recharts, existing chart tokens; red is not used: takedowns are success);
    • a country choropleth on the existing flat map component, only countries with ≥ 3.
  • Recently taken down table: target (IPChip opens the local attacker page when the address was seen locally, else a filtered /network?target=), kind, families (TagBadge), days to takedown, sensors bucket (“5–9 sensors”), “You helped” badge when mine.
  • Your node card:
    • fingerprint, pseudonym (edit), country, member since, eligibility (“Counts toward consensus from 15 Nov”);
    • standing (new / good / low with a sentence);
    • last batch day and counts;
    • “See exactly what was sent” (a drawer with the stored JSON, copy button);
    • “Leave the network” (danger button, confirmation dialog stating what is deleted where, then password).
  • Members tab: a virtualised list showing pseudonym or “Node 3f9a1c07”, CountryFlag or ”—”, member since, seven dots for active days last week, tier as a small meter (0–4) with a text label (“1,000 to 9,999 items”). No ranking; random order from the hub, sorted by pseudonym in the UI. Header sentence: “37 members in 9 countries; 29 active this week.”

Rules (CLAUDE.md §4).

  • A sentence before every chart.
  • Filters in the URL (state, kind, mine).
  • Tabular numbers; times shown in the browser time zone (days only here).
  • No new hue.
  • WCAG AA; keyboard; screen-reader text for the dot rows and the tier meter.
  • Mock data in web/src/mocks/network.ts with documentation IPs and invented pseudonyms.
  • help.ts entries for both pages; glossary terms “consensus”, “blinded indicator”, “eligible node”.
  • Test traffic you send yourself from an outside address that is not in your own address terms (Settings, Reporting) is submitted like any attacker’s. Test from an address you have listed, or expect your test host to appear in a batch.

netproto/tests:

  • golden vectors (canonical strings, signatures, response signatures, blinding of URLs and domains with IDNA and default ports);
  • PoW verify (valid, one bit short, wrong challenge);
  • bucket functions;
  • models reject unknown fields, private and documentation IPs (unless allowed), bad enums, over-cap lists, the credentials field;
  • padding.

hub/tests (unit):

  • auth order and every error code;
  • nonce replay; timestamp skew; enrol with a bad PoW, an expired challenge, a taken pseudonym;
  • idempotent batch; day_not_closed, day_too_old; caps; rate limits;
  • privacy:
    • an AST test that only ratelimit.py reads request.client or X-Forwarded-For;
    • a log-capture test that no record contains the test client address or a full pubkey;
    • the Caddyfile contains output discard;
    • no model has an inet column;
  • k-rules: countries < 3 hidden, member country hidden below 3, no per-node data in /v1/targets or /v1/network (scan the JSON for every node id and fingerprint except the member list’s fingerprints);
  • retention: observations at 15 days deleted, nonces expired, inactive nodes deleted after 30 days, DELETE /v1/node leaves no row with the node id in any table (query every table).

hub/tests (consensus and reputation):

CaseExpected
5 aged nodes on 2 daysconfirmed
4 nodescandidate
5 nodes, all enrolled the same weekcandidate (cohort)
5 new nodes (< 14 days)candidate
Uncorroborated IP with 5 nodescandidate, with 10 nodes confirmed
Never-list valuenever confirmed
Disputelowers contributors’ reputation; below 0.2 is soft-suspended and the node is not told

Takedown voting state changes are tested too.

hub/tests (end-to-end, -m integration): hub/docker-compose.test.yml (local volumes, HUB_ALLOW_DOCUMENTATION=1, no Caddy TLS) and a driver that enrols 7 synthetic nodes with low PoW difficulty, ages them by moving enrolled_day back, submits batches with documentation IPs, runs the worker once, and checks targets, reveal flow, rechecks, a report rendered in review mode (sent to a local SMTP sink container), then leaves one node and checks deletion.

ingest/tests/network:

  • N1: worker idle without env or opt-in (a transport that fails the test on any call).
  • N2: gate closed → no call; host must equal the configured hub host.
  • N3/N4 planted leaks:
    • session data and events carrying the self address in Host, SNI, URL query and username are present in the DB, and the batch never contains them;
    • a payload URL with the self address in its query is dropped with dropped.guard;
    • a pseudonym equal to an owner domain is refused;
    • no confirmed self address → waiting_self_address, nothing sent.
  • N5: schedule within bounds; never submits the current day; skips days older than 2.
  • N6: URLs only blinded in batches; reveal only for requested hashes and guarded.
  • N7: the API role cannot select privkey_enc (integration test).
  • N8: the leave flow wipes every network_* row; the 7-day retry ends with leave_unconfirmed.
  • N9: a bad hub signature is refused; unknown fields refused.
  • N10: demo rows never included.
  • Static: ingest/ingest/network uses only make_client; no events.data reference in builder.py (AST test).

api/tests/test_network.py: roles (viewer read-only), CSRF, re-authentication on join and leave, the consent list required, pseudonym validation, audit details without values, impact figures.

Web: vitest for join dialog validation, leave dialog, overview sentence and empty states, members list; Playwright screenshots of /network (non-member, member), /network/members, Settings > Network, desktop and mobile, dark and light.

Wave 0 publishes the contract. Wave 1 tasks run in parallel against it (worktrees or the strictly separate files listed). Every task uses documentation IPs, documentation ASNs and example.org only.

TaskAgent / modelOwnsDepends onWave
T0. Protocol library and specbackend-engineer, sonnet (opus review of the crypto)netproto/** (suckerpot_netproto: models.py, sign.py, blind.py, pow.py, buckets.py, enums.py with TAGS_V1, FAMILIES_V1, tests/vectors.json), docs/network-protocol.md, justfile packages list, .github/workflows/ci.yml python job list—0
T1a. Hub API and storagebackend-engineer, sonnethub/Dockerfile, hub/docker-compose.yml, hub/docker-compose.test.yml, hub/Caddyfile, hub/.env.example, hub/pyproject.toml, hub/migrations/**, hub/hub/{main,config,logging,auth,ratelimit,models,db,healthcheck}.py, hub/hub/routes/**, hub/tests/test_api_*.py, test_privacy.pyT01
T1b. Hub workerbackend-engineer, sonnethub/hub/{consensus,reputation,stats,retention,never,worker,admin}.py, hub/hub/reports/**, hub/hub/data/**, hub/tests/test_consensus*.py, test_reports*.py, test_retention.py, hub/tests/e2e/**, .github/workflows/ci.yml (hub-integration job only)T0; codes against the §6.3 DDL; merges after T1a1
T2. Node migration and rolesbackend-engineer, sonnetdb/migrations/versions/0028_network.py, db/suckerpot_db/models.py (four models), db/suckerpot_db/roles.py, db/suckerpot_db/retention.py (network retention)—1
T3. Node workerbackend-engineer, sonnetingest/ingest/network/**, ingest/ingest/main.py (wiring), ingest/ingest/features.py (settings), ingest/tests/network/**, docker-compose.yml (ingest and api env only), .env.example (network section)T0, T2 (can stub models)2
T4. Node APIbackend-engineer, sonnetapi/api/network.py, api/api/routes.py, api/api/setup_status.py (network item), api/tests/test_network.pyT22
T5. Webfrontend-engineer, sonnetweb/src/pages/network.tsx, web/src/pages/network-members.tsx, web/src/components/network/**, web/src/lib/network.ts, web/src/mocks/network.ts, web/src/app/nav.ts, web/src/app/router.tsx, web/src/pages/settings.tsx (Network tab), web/src/components/setup/network-step.tsx, web/src/lib/help.ts, web/src/lib/glossary.ts§9.6 JSON (mocks first)1 (mocks), 2 (wire)
T6. Dedupe and blocklist sourcebackend-engineer, sonnetingest/ingest/abuse/candidates.py, ingest/ingest/blocklist/**, api/api/abuse.py (network_state on subjects), testsT22
T7. Docsdocs-writer, haikudocs/network.md (user guide: what is shared, joining, leaving, risks), docs/ARCHITECTURE.md (pieces, outbound hosts row: the hub host, network worker row), docs/SECURITY.md (threat model summary, §3), docs/configuration.md, docs/api.md, docs/index.md, CHANGELOG.md, hub/README.md (operator guide: deploy, keys, admin CLI, retention)all3

Contracts that must not drift:

  • §5 (wire format, owned by T0; any change goes through T0 and the spec first);
  • §6.3 DDL (T1a owns the migration; T1b codes against it);
  • §9.2 DDL (T2);
  • §9.6 JSON (T4 owns; T5 mocks match it exactly; a vitest checks the mock against a shared TypeScript type in lib/network.ts).

Reviews:

  • security-reviewer (opus) on T0, T1a, T1b, T3, T4: threat model §3, invariants N1–N10 and H1–H7, signatures, replay, poisoning.
  • ui-designer on T5.
  • qa-runner: just check, just guardrails, just test-integration, the hub integration job.
  1. Project identity. The hub needs a domain, a VPS paid for anonymously, a mailbox and a signing key. Until then NETWORK_HUB_URL and NETWORK_HUB_KEY ship empty and the feature cannot be joined. Owner action.
  2. Hub as a trusted party for linkage. The hub knows, for 14 days and at day granularity, which pubkey saw which attacker IPs. A seizure plus an attacker’s own target logs could narrow a node’s address. Protocol v2 candidate: anonymous credentials (blind-signed per-day tokens) so submissions are unlinkable to the node key, with consensus counting tokens issued one per eligible node per day.
  3. Probe-response attacks cannot be eliminated; documented.
  4. Bootstrap. With fewer than 5 eligible members nothing is ever confirmed. The page says “The network needs 5 active members before it confirms anything” with the current count.
  5. Hub report wording lives in the hub configuration, not in this public repo (same reason as M22: searchable wording). The hub ships no default text: an empty template sends the facts only.
  6. Legal. The hub operator receives replies and disputes; the dispute path (§8.3) is manual in v1.
  7. M27 interface. The takedown.local_states adapter assumes M27 keeps per-target re-check results locally. Confirm with the M27 design before T3 wires it.
  8. Migration numbers. M28 uses 0025, M26 and M27 use 0026 and 0027 (deployed first), so M29 is 0028, chained on 0027.