-
Notifications
You must be signed in to change notification settings - Fork 1
log
Append-only record of wiki operations. Newest entries at the bottom.
Source: docs/views/*.md (24 per-view docs) + docs/views/README.md + repo-root CLAUDE.md.
Created pages:
- sloth.md — top-level project overview.
- architecture.md — code-tree layout and seams.
- views-catalog.md — keybinding map for all 24 views.
- dashboard.md — seven-band composite layout.
- alerts.md — alert engine + the six rules.
- ja3-fingerprinting.md — TLS ClientHello fingerprinting primitive.
- threat-intel.md — embedded IOC matcher.
- beacon-detection.md — periodicity detector for C2.
- wifi-sigint.md — overview of the v1.1 SIGINT view set.
- mac-randomisation.md — 802.11 seqnum deanonymisation.
- ip-palette.md — colour conventions and TUI rules.
- platform-vtable.md — the kernel seam.
- pcap-export.md — three pcap-export paths.
- attack-map.md — threat-to-view map.
- index.md — table of contents.
Notes:
- Source view docs in
docs/views/left untouched (treated as immutable perdocs/CLAUDE.md). - Wiki pages cross-link concept-to-concept with
[[wiki-link]]and point back to source docs with normal relative links (../views/foo.md). - No per-view 1:1 mirror page —
views-catalog.mdplus the concept pages cover the synthesis-level material; the per-view docs remain the source of truth for protocol-level detail.
Source: MISSION.md §4 ("out of scope" list).
Change: replaced the blanket "no REST API or remote-control
surface" bullet with a stricter two-paragraph rule. The hard ban now
targets control surfaces specifically (no command channel, no RPC,
no remote configuration, no plugin loader, no shell-out). A
read-only local data socket (UNIX domain or 127.0.0.1) that
mirrors the JSONL stream is now explicitly in scope as a
tail -f-style consumer hook for local SIEM forwarders.
Why: JSONL files are clunky for in-process tooling and force filesystem polling. A read-only socket gives downstream tools a clean hookup without ever giving sloth the ability to be told what to do. The mission's passive-only spirit is preserved — there are no verbs on the socket; you can only read.
Not implemented yet: this amendment opens the door. The socket
itself is future work — needs a --data-socket PATH flag, a writer
loop in src/jsonl.c (or a new src/data_socket.c), and a connection
test in the test suite.
Source: GitHub issue #4 ("Add a mutation-testing harness to
validate the test oracle"), .github/scripts/mutate.py,
agents/dark-factory.md §3.3.
Created pages:
- mutation-testing.md — operator reference, how to read the report, what to do with each surviving mutant (real gap / equivalent mutant / dead code), implementation notes (force-rebuild, sandbox isolation), target priorities.
Index updates: added a "Factory infrastructure" section under index holding the new page.
Why: closes the loop the dark-factory pattern doc opens — §3.3
states "if the test suite is 'yeah it mostly catches things,' you
are still at Level 3 — the human is the actual oracle." make mutate
is the mechanical check that the suite is stronger than that.
Baseline kill-rate for src/alerts.c lives in PROGRESS.md.
Source: issue #18 request for an automated version check-in and update
plan; include/sloth.h, src/main.c, src/event_wake.c,
src/data_socket.c, README.md, RELEASE_v1.4.0.md, SECURITY.md.
Created pages:
- version-checkin.md — architecture recommendation for periodic release checks, a separate staged self-update path, safety boundaries, implementation phases, and open questions.
Index updates: added version-checkin under "UI and infrastructure" in index.
Why: the issue explicitly asked for plan and architecture rather than product code. The new page records a concrete design that fits sloth's existing non-blocking main loop and passive-tool constraints, while still laying out how a future source-download/build/install workflow could be added with minimal interruption.
Source: version-checkin recommendation; commits landing
src/version.{c,h}, src/updater.{c,h}, examples/updater/,
SECURITY.md rewrite.
Created pages:
-
manifest-format.md — JSON schema for the
manifest file that
--check-manifestconsumes.
Index updates: added manifest-format under "UI and infrastructure" in index.
Doc updates: version-checkin rewrote its "Recommended
implementation phases" section to mark phases 1–3 as landed and
phases 4–5 as deferred. SECURITY.md replaced its GitHub template with
a real supported-version policy, vulnerability reporting instructions,
and a pointer to MISSION.md for the passive-only guarantee.
Why: the design was already logged as an architecture proposal; this entry records the first-code landing so a future reader can see when the policy → module → checker sequence actually shipped and where the follow-up phases are still open.
Source: issue #40; commit adding ALERT_TYPE_ICMP_TUNNEL and the
icmp_log_entry_t.payload_len field.
Doc updates: jsonl-schema icmp record gained the additive
plen integer (payload bytes past the ICMP header) — the signal the new
ICMP_TUNNEL rule keys on. Additive per the schema-stability contract;
older records simply omit it.
Index updates: none (no new page).
Why: the JSONL schema is a downstream contract (MISSION §3), so a new emitted field is logged even though it rode in on a detector change rather than a dedicated schema revision.
Source: issue #42 (log volume); commit adding a change cache to
src/jsonl.c that suppresses re-emission of unchanged snapshot rows.
Doc updates: jsonl-schema "State snapshot record types" gained a
Change-only emission paragraph. Rows are now written only when new,
changed, or JSONL_HEARTBEAT_SECS (300 s) stale — no schema/field change,
purely an emission-cadence change. pnl_client and seqnum_client are
converted first (≈45 % of production volume was unchanged rows); other
snapshot types follow the same pattern later.
Index updates: none (no new page).
Why: the emission cadence is part of the downstream JSONL contract — consumers that assumed one row per entity per tick need to know the stream is now change-driven with a 5-minute heartbeat floor, even though the per-row fields are unchanged.
Source: issue #47 (field report from a production appliance);
commit resetting the #42 change cache when data_socket_tick() accepts
a client.
Doc updates: jsonl-schema "Change-only emission" gained a paragraph covering the data-socket case. Before this, the cache reset on file-sink (re)open only, so a socket client connecting mid-run saw only entities that changed after it connected — steady-state rows stayed invisible to it until their next 5-minute heartbeat. The reset now also fires per accept.
Index updates: none (no new page).
Why: same reason as the #42 entry — emission cadence is part of the downstream contract. A consumer that reconnects needs to know it will be re-synced with a full baseline rather than having to wait out a heartbeat, since that determines whether reconnect is a viable re-sync strategy at all.
Source: a request for a UX test persona simulating a WiFi security
survey; commit adding docs/personas/, and #51 which the suite's own
scoring turned up.
Doc updates: new docs/personas/ tree — a README defining the
verdict scheme and wifi-surveyor.md, an eleven-scenario suite scored
against the tree. index gained a Reference entry pointing at it.
No wiki page was modified; the suite cites docs/views/* and src/*
as sources the same way a wiki page does, but it carries results, so
it is a fixture rather than a concept page.
Index updates: index § Reference.
Why: make test proves the parsers do what they say; it cannot say
whether what they say is what the operator needed. The suite is the
inspection step for that second question, and it earned its keep
immediately — scoring S2.1 surfaced that the evil-twin rule reports a
cross-vendor range extender as a CRIT rogue AP, which shipped as #51.
S2.1 is rescored WRONG → PARTIAL; the residual case (APs that emit
no 802.11k Neighbor Reports) is documented in the scenario rather than
closed.
Source: issue #52, itself raised by scoring the wifi-surveyor
persona suite scenarios S4.1 / S4.2;
commit adding src/ownership.c and the MY_NET_RECON rule.
Doc updates: docs/views/alerts.md gained a MY_NET_RECON row and
severity-scoping notes on DEAUTH_FLOOD / AUTH_FLOOD. README gained a
"Designating your own network" section. The persona suite rescored S4.1
FAIL → PASS and S4.2 PARTIAL → PASS, closing Q4.
Index updates: none (no new page).
Why: this is sloth's first operator-supplied context input —
previously the tool took observations and display preferences only,
never an assertion about the world. Worth recording as an architectural
first: the known-device roster (G4 in the persona doc) is the same shape
and should extend ownership.c rather than introduce a second
mechanism. The passive guarantee is untouched — a designation is a
label, and nothing is transmitted.
Source: issue #42; commits 61700b5 (schema v1 + state bucket), 01dc317 (protocol flows), ec4a9b7 (event bucket), 78305dc (retention + size ceiling), 376ab88 (KARMA / rogue-RADIUS evidence).
Doc updates: new sqlite-schema page — why the sink exists
against the measured 38 GB/day, the 38-table layout by tier, the upsert
semantics that make the file trustworthy, retention tiers, the MISSION
§2 guardrails and what "identifier versus secret" means in practice,
plus query recipes. README gained --db sections covering the flags.
Index updates: index § Reference, next to jsonl-schema.
Why: the sink is a second durable contract alongside the JSONL
schema, and the parts a reader most needs are the ones not visible in
the DDL — that assocs.source is strongest-first so a naive MAX
silently downgrades confirmed handshakes; that detector evidence sits in
the finding tier so it cannot expire before the alert it justifies; that
SNMP community strings are deliberately absent even though the wire
format emits them; and the auto_vacuum caveat for files created before
that pragma landed.
Source: issue #56 (persona S5.2); commit adding the sessions
table, db_new_since(), and a New since last survey report section.
Doc updates: sqlite-schema gained a Survey sessions section
and a rewritten schema-version note; the page is retitled schema v2 /
40 tables. README gained the repeat-survey paragraph. The persona suite
rescored S5.2 PARTIAL → PASS.
Index updates: none.
Why: the version note is the part worth recording. v2 exists
because probe_clients.presence (#53) added a column to an existing
table without a bump — and CREATE TABLE IF NOT EXISTS cannot apply
that, so v1 files failed on a confusing SQL error instead of the clear
version message. The rule is now written down: new tables are safe
without a bump, new columns on existing tables are not. The version
check also moved ahead of schema application so the clear message wins.
Source: issue #50; commit adding --headless, --no-color /
NO_COLOR, and the tui_poll_key tty guard.
Doc updates: ip-palette gained a Colour policy and headless operation section covering the split between "no colour" and "no drawing". README gained a Headless operation section including the spin-fix note. The persona suite's S1.3 caveat is removed.
Index updates: none.
Why: the issue as filed was about escape sequences in the journal,
and the fix for that is --headless. But measuring it turned up a
larger defect behind the same symptom: with stdin at EOF (systemd,
< /dev/null) select() reported stdin readable every time, so the
refresh interval never applied and the loop spun — ~76 000 redraws in
two seconds against an intended ~10, burning a core as well as flooding
the journal. Recorded here because the fix (isatty guard in
tui_poll_key) benefits interactive and --no-color runs too, not
only --headless, and a future reader tracing the flag will otherwise
miss why the guard exists.
Source: issue #75; commit adding VIEW_FRAGATTACK / [c],
src/views/fragattack.c, frag_snapshot().
Doc updates: fragattacks gained a The view section and its
intro/CVE-count corrected from "six" to "eight of twelve, seven
detectors" — stale since slice 4 landed and only now noticed while
touching the page. New per-view doc
docs/views/fragattack.md.
Index updates: fragattacks bullet updated to mention the view and the corrected count.
Why: seven detectors had been shipping since slice 1 with no
operator-visible surface short of grepping alert history. The view adds
no new data source — it mirrors src/fragattack.c's existing per-BSS
counters — and deliberately adds no SQLite table or evidence blob,
since src/alert_pcap.c already carries better forensic evidence than
a truncated blob would. [c] is the key because [f] went to #73's
Research view first and was, by the time slice 5 landed, the only
letter still free in the global switch.
Source: issue #77 ("Attack-hardware fingerprint layer"); the
buildable-without-hardware slice of it. src/beacon_snoop.c,
src/beacon_snoop.h, include/sloth.h, src/jsonl.c,
src/views/beacon.c.
Doc updates: docs/views/beacons.md gained a
"WPS vendor-string leakage (#77)" section. jsonl-schema's beacon
row gained the four new additive fields.
Why: #77 asked for a curated attack-hardware fingerprint corpus
(specific Cisco/Aruba/Ubiquiti/MikroTik stacks, Pineapple/Marauder/
eaphammer captures) and a pcap-fixture test plan — both blocked, the
first on "empirical fact about a binary" grounds tool_fingerprint.c
already documents, the second on agents/AGENTS.md's no-pcap-fixtures
rule. The one bullet in the issue that needed neither — WPS Manufacturer/
Model Name/Model Number/Serial Number attribute parsing — is spec-defined
(WFA WPS 2.0 §12) and hand-testable from TLV bytes like every other
parser in this file. Shipped that slice only; see the issue-77 triage
comment for the rest.
Source: issue #77, bullet 1 ("Beacon IE ordering fingerprint") — the
observable half. src/beacon_snoop.c, include/sloth.h, src/jsonl.c.
Doc updates: docs/views/beacons.md gained an
"IE-ordering fingerprint (#77)" section. jsonl-schema's beacon row
gained ie_order_hash / ie_order_count. tool-fingerprints'
"Adding a tool" asks captures to record the new hash.
Why: the bullet asks for an IE-order hash per BSSID matched against a
curated table of vendor stacks. The hash is spec-shaped (IEEE 802.11-2020
§9.3.3.2 fixes element order; Vanhoef et al., AsiaCCS 2016, established
presence/order as a device fingerprint) and hand-testable; the table is
not — it needs captures from each stack, the same wall
tool_fingerprint.c documents. Shipped the hash as a plain observable;
no signature field, no verdict. It also gives the "vendor-IE consistency
drift" follow-up something to compare.
Source: issue #77, bullet 2 ("Beacon interval jitter") — the
observable half. src/beacon_snoop.{c,h}, include/sloth.h,
src/jsonl.c.
Doc updates: docs/views/beacons.md gained a
"Beacon TBTT jitter (#77)" section. jsonl-schema's beacon row
gained tbtt_jitter_us / tbtt_jitter_samples / tbtt_jitter_resets.
Why: the bullet asks for a rolling stddev of beacon inter-arrival
per BSSID. Measured from the receiver it would be worthless here — no
radiotap TSFT is guaranteed, the radio hops, and frames are lost. The
beacon body already carries the answer: IEEE 802.11-2020 §11.1.3
schedules TBTTs at whole multiples of the Beacon Interval and
§9.3.3.2 order 1 / §9.4.1.10 puts the transmitter's own TSF in every
beacon, so the residual against the nearest multiple is the AP's own
medium-access deferral on the AP's clock. The timestamp field was
previously not parsed at all — beacon_parse skipped bytes 24-31.
Shipped as measurement only. The issue's "8-40 TU = Marauder" figure
has no source that could be verified here, and agents/AGENTS.md
requires detectors to cite theirs; the measurement is spec-grounded,
the attribution is not. No alert rule, no TUI row, no signature row in
tool_fingerprint.c, and no --db column (a schema bump invalidates
every prior database file — the phy_confirmed/#60f precedent).
Source: issue #93 (external CISO/GRC review, finding F11).
src/data_socket.{c,h}, tests/test_data_socket.c.
Doc updates: jsonl-schema "Backpressure" became "Delivery:
whole records or none", with the overflow / stall / error table, the
loss-detection rule, and the EOF-fragment rule under "Framing"; new
socket_gap (socket-only) record section. README --data-socket
bullet, docs/streaming.html, examples/consumer/README.md and the
sloth-stream.py docstring said a slow client "loses lines"; they now
describe the queue.
Index updates: none (no new page).
Why: the writer sent the payload and its \n as two send()
calls and kept no state between them. An EAGAIN on the delimiter
left the client connected with no record of the missing byte, so the
next record was glued on — {"a":1}{"b":2}\n, which no JSONL parser
accepts. It also read errno after a positive short write, where
errno is stale, and could keep a client with half a record on the wire.
Each client now owns a queue of complete payload\n spans written from
a byte offset; short writes and EINTR retry, EAGAIN waits for the
next emit or tick, anything else closes. Overflow (512 KiB) drops only
whole records not yet started, and a client that accepts no bytes for
30 s is closed — a reader that stops can hold a TCP window shut without
ever producing EPIPE, so waiting for one is not a policy.
The loss signal is a new record type rather than a field on every
record: a per-connection seq on each line would give each client a
different byte stream and add a field that has no meaning in -o FILE.
socket_gap appears only after an actual drop, only on that
connection, so the schema change is additive and a healthy consumer
never sees it.
Source: issue #87 (external CISO/GRC review, finding F05).
src/secure_file.{c,h} (new), src/eapol_log.c, src/alert_pcap.c,
src/jsonl.c, src/db.c, src/pcap_write.c, src/main.c,
src/views/eapol.c; tests in test_eapol_log.c, test_alert_pcap.c,
test_jsonl.c, test_db.c, test_pcap_write.c.
Doc updates: pcap-export gained "Permissions and failures" and
lost two stale claims (per-alert hits never appended to one file, and
pcap_write.c never handled all three paths). jsonl-schema,
sqlite-schema and posture-report state the 0600 rule and the
refusal behaviour. docs/views/eapol.md gained "Export handling"; the
README Output section a file-permissions paragraph, and the --db
example now reads the 0600 file with sudo.
Index updates: none (no new page).
Why: the EAPOL directory was mkdir(…, 0755) and every file a plain
fopen(), so under the usual 022 umask a fresh PMKID export was
world-readable; SQLite likewise creates 0644 & ~umask and copies that
to the WAL. Directory creation and most write errors were ignored. Now
dirs are 0700 and files 0600 by descriptor (O_NOFOLLOW|O_CLOEXEC,
relative to a directory fd validated once at startup), and an existing
path is validated and refused — never chmoded — when it is a symlink,
foreign-owned, multiply linked, or group/other accessible. Each artifact
has a deliberate create mode: eapol.22000 and -o append; handshake
pcaps are atomically replaced; alert and Packets-view pcaps are
exclusive with a numeric suffix; reports are validated then truncated.
Failures print once to stderr and are counted (EAPOL view shows it).
Not done here (owner decision): a separate opt-in for collecting crackable material with a short default retention, and an explicit group-sharing policy. Today a group bit is simply refused.
Source: issue #97 (external CISO/GRC review, technical appendix T12
and T13). include/eapol_log.h, include/sloth.h,
include/assoc_track.h, src/eapol_log.c, src/jsonl.c; tests in
test_eapol_log.c.
Doc updates: jsonl-schema eapol record gained three additive
fields — handshake_progress, replay_counter_ok, assoc_evidence —
and the row now says what handshake_complete does and does not mean.
docs/views/eapol.md gained "Attempts, not flags" and "Message-pair
byte"; docs/views/assoc.md's evidence table now names M3 rather than
"M2 with prior M1" as the EAPOL source.
Index updates: none (no new page).
Why: the pending record was keyed (BSSID, STA) with m1_seen /
m2_seen, no replay counter and no age bound, so a cached M1 paired
with any later M2 — across associations and across rekeys — and a new
M1 left the previous attempt's M2 and PMKID underneath it. That pair
was then handed to assoc_observe(ASSOC_SRC_EAPOL, …), calling a
half-exchange an association. The record now holds one bounded
attempt: M1 and M2 pair only on equal Key Replay Counters
(802.11-2020 §12.7.2) within EAPOL_PAIR_WINDOW_S, a new M1 discards
everything that depended on the old ANonce, message role must agree
with frame direction, and association promotion moved to M3 — the AP
installing a key, which is the first point the authenticator commits.
The export's trailing message-pair byte was the literal 02, which is
hashcat's M2+M3 category; sloth builds M1+M2 with the EAPOL blob from
M2, which is 00. It is now derived by eapol_message_pair() from the
pair kind plus whether the replay counters were actually compared, so
bit 7 ("not replaycount checked") reflects what the tool did.
Not done here: validating the export against hcxpcapngtool and
confirming a known-key fixture cracks. There is no owned capture on the
build host and MISSION §2.2 forbids sloth running a cracker, so the
bit values are asserted against hashcat's published table and the
offline lab validation stays open on #97.
Source: issue #98 (external CISO/GRC review, technical appendix
T14). src/alerts.c, src/jsonl.{c,h}, include/sloth.h; tests in
test_alerts.c and test_data_socket.c.
Doc updates: jsonl-schema gained the alert.create /
alert.update / alert.escalate / alert.resolve section (field
table, the material-change predicate, the resolve rule and its
reasons), incident_id on the alert record, and a plain statement of
what count measures; the common-envelope type row and the
versioning section were updated. docs/views/alerts.md gained an
"Incident lifecycle" section and a counter/timestamp table, and the
lines describing the engine, the n column and the c key were
corrected — they described emit-on-new-key-only behaviour.
docs/streaming.html gained an alert.escalate card and the "the
alert record is written only when the key is new" note.
examples/consumer/README.md and sloth-stream.py gained a lifecycle
section, a formatter for the four new types, and the warning that
--type alert alone never sees an escalation.
Index updates: none (no new page).
Why: fire() bumped count, refreshed the timestamp, replaced
detail and severity, then returned early when the key already existed —
and jsonl_emit_alert() ran only for new keys. A WARN→CRIT escalation
therefore changed sloth's engine and emitted nothing, so a consumer
paging on CRIT only ever held the WARN record written when the key was
created. count had the matching problem in the other direction: it
incremented on every evaluation tick of a retained condition, measuring
rule polls while being presented as an occurrence count.
An incident — one continuous run of a dedup key — now opens with
alert.create, carries one incident_id through every escalate and
update, and closes with exactly one alert.resolve. What is emitted is
a material change: any severity move (never throttled, in either
direction), or a changed detail at most once per 60 s. An evaluation
that re-renders identical evidence emits nothing, so a condition held
across 100 polls is one create and silence — and observations counts
only the evaluations whose evidence moved, beside evaluations (and
the unchanged count) for the rule ticks.
Resolve keys on last_evaluated, not last_observed, after 300 s: a
rule that stops firing is one whose evidence aged out of the source
ring, whereas a standing condition re-renders the same detail forever
and keying on observation would resolve and immediately re-create it
every five minutes. 300 s matches JSONL_HEARTBEAT_SECS. Durations run
on CLOCK_MONOTONIC through the #88 seam in src/flood_window.c;
every exported timestamp stays wall clock, because those are evidence.
A resolved incident stays in the TUI — the operator's history is not
the stream's business — but is evicted first under MAX_ALERTS
pressure, and an evicted live incident is resolved on the way out.
Everything is additive: four new record types, one new field on
alert, no field removed or repurposed. The --db alerts table is
untouched and DB_SCHEMA_VERSION stays 4.
Not done here: the issue's fourth Fix bullet ("revisit dedup keys
per F06/F07") and its third regression ("two distinct twin pairs under
one SSID: two incidents"). F06 was already done in #88; F07 is issue
#89 and still open, and rule_evil_twin still keys on twin:<ssid>,
so the two pairs merge before the lifecycle layer sees them. The layer
never merges across keys and will report two incidents the moment the
key distinguishes them; test_lifecycle_two_twin_pairs_one_ssid_still_merge_see_89
pins the current behaviour so #89 flips it deliberately.
Source: issue #91 (external CISO/GRC review, F09), slices 2 and 3 of
a 4-slice plan; slice 1 (requested-vs-confirmed channel) shipped in
509fa71. src/capture/capture.{c,h}, src/capture/probe.{c,h},
src/sensor_health.{c,h} (new), src/jsonl.{c,h}, src/views/iface.c,
src/main.c, include/sloth.h; tests in test_capture.c,
test_sensor_health.c (new), test_state.c and test_jsonl.c.
Doc updates: jsonl-schema gained the sensor_health record —
table row, full field list, the per-stream block, the monotonic-total
rule, the change-only signature and an explicit statement of which
tables the eviction tally does and does not cover.
docs/views/interfaces.md gained a "Sensor health strip" section with
the three liveness words, the exit-reason table, and the same coverage
caveat; the slice-1 section's forward reference to slices 2-4 was
replaced by it. examples/consumer/README.md and sloth-stream.py
gained a sensor_health section, a formatter, and the note that it is
the one record type a silent sensor still emits.
Index updates: none (no new page).
Why: "healthy with no detections" and "not observing" were the same
observation. Three separate paths produced it. Both capture workers loop
on pcap_dispatch() and break on a negative return, then simply
returned — the pcap handle stayed open, capture_is_open() kept saying
yes, and the tables stopped growing. On a channel-hopping radio an empty
dwell is normal, so a dead monitor thread was indistinguishable from a
quiet channel indefinitely. pcap_stats() was never called at all, so
NIC and libpcap buffer drops were invisible. And every bounded table
discards observations when full, silently, so a sensor at max occupancy
and a sensor on an empty segment produced the same empty delta.
Each worker now classifies why it stopped — stopped (requested),
iface_gone, perm_lost, not_activated, error — and publishes the
verdict with libpcap's raw text beside it. The classification is pure
(capture_classify_exit(), above the WITH_PCAP guard like
capture_activate_failed() before it), so it is unit-tested from
hand-written return codes and error strings with no handle and no radio.
A requested stop wins over any error text, because pcap_breakloop()
makes the pending dispatch fail and reporting that as a fault would cry
wolf on every clean shutdown. PCAP_ERROR is split by case-insensitive
substring on libpcap's wording, which is not a kernel contract — an
unrecognised message degrades to error rather than being guessed into
a bucket, and _exit_detail carries the raw string so a consumer is
never left with only sloth's classification.
Liveness is run_flag && exit_reason == none, deliberately a
conjunction: a dead worker leaves the run flag set, because it broke
out of its loop rather than being asked to stop. _open 1 with
_running 0 is exactly the failure that used to be invisible.
pcap_stats() is polled once per tick. Sloth accumulates its own
lifetime totals from the per-tick deltas rather than echoing libpcap's
32-bit counters: a sample below the previous one is read as a counter
reset (the new value becomes the delta), so the exported total is
monotonic and a consumer differencing two samples never sees a negative
rate. A true 2^32 wrap would under-count by one wrap period — the
deliberate trade for keeping the common case exact. The first sample is
taken as both total and first delta rather than discarded as a baseline,
because the startup window is where an undersized buffer drops hardest.
sensor_health is the stream's only singleton record: one line per tick
about the collector, not one per table row. It has to be, since the
whole point is to say something when no other record exists. It is
change-only on the shared snapshot cache, but the signature covers
liveness, exit reasons, the channel pair, retune failures, the
cumulative drop counters and the eviction total — and deliberately not
recv or any delta. recv climbs every tick on a working sensor, so
signing over it would mean a line per second forever and the suppression
would be decorative. A drop counter moving is news; a packet counter
moving is not. Healthy sensors emit one line per 300 s heartbeat.
The TUI strip in the interface view follows the same rule in the other
direction: everything after the two liveness words is a fault and
appears only when non-zero, so a healthy sensor renders exactly
health: cap up mon up. A strip that printed drop 0 evict 0 every
second would train the operator to stop reading it.
Everything is additive — one new record type, no existing record, field
or name touched, DB_SCHEMA_VERSION unchanged (MISSION §4.3).
Scope decision, flagged: the eviction tally covers alerts, top
hosts, PNL clients, per-client PNL SSIDs, DHCP events, 802.1X EAP
sessions and the device table (which refuses a new entry rather than
evicting an old one — different mechanism, same meaning). It does
not cover the probe-client, beacon, seqnum, assoc or per-protocol
flow rings. The counted set is the one whose modules are in TEST_SRCS,
so every wired call site has a test that drives the real table past its
cap; the probe-client ring's eviction site lives in src/capture/probe.c,
which is compiled only under WITH_PCAP and is not in the test build, so
instrumenting it would have shipped untested wiring. Which tables are
covered is stated in both docs rather than left implicit, because a
tally that silently omits a table reads as "no loss" when it means "not
measured".
Not done here: slice 4 (benchmarking sequence correlation and
PNL-union at max occupancy) is out of scope for this change. The
issue's remaining hardware-dependent items stay open and are not
faked — the measured adapter/driver/kernel/band/width support matrix,
and comparing hopping against an independent reference receiver, both
need a radio this build host does not have. The dwell-servicing gap
(configured dwell != measured dwell, since dwell is only serviced when
the poll loop runs) and the monitor-specific frame counter tied to the
confirmed frequency are also untouched; the RF dwell heuristic still
attributes activity via s->pkt_total, the general capture counter.
Source: issue #89 (external CISO/GRC review, F07), slice 1 of 3 per
the Captain's answers recorded on the issue 2026-09-24. Code:
src/alerts.{c,h}, src/twins.c, src/views/{twins,beacon}.c,
src/jsonl.c, include/sloth.h.
Updated pages:
- alerts.md — new "Severity vs confidence (#89)" section: the two axes, why most rules omit confidence, the two removed trust anchors, and the canonical pair-key shape.
-
jsonl-schema.md — additive
confidenceonalertand thealert.*lifecycle records; additiveattributed+confidenceontwin_episode; thereal_bssid/twin_bssidmeanings now depend onattributed; canonical dedup-key note. -
../views/alerts.md —
EVIL_TWINrule-table row. -
../views/twins.md — "Which half is the impostor"
ranking,
?glyph, Conf column, A/B headings.
What changed in the detector:
Three things, all removals of something the code treated as proof:
- An 802.11k neighbour report is no longer a suppressor. It was an unauthenticated frame with veto power: an attacker advertising the AP it impersonated erased the finding. Now a confidence deduction that can demote a soft-signal severity and nothing more — a hard signal (attacker-tool OUI, BTM steer) is immune, so the attacker gets no severity lever.
- A matching vendor OUI is no longer a suppressor. Three bytes the attacker writes. A same-OUI pair whose vendor-IE fingerprints contradict each other now fires; a same-OUI pair with no other signal stays quiet because it carries no evidence, which is a different fact from being trusted.
-
RSSI no longer names the impostor. It is a fact about distance,
and in the commonest case it is backwards — the operator's own AP is
the closest radio in the room. Unattributed pairs are ordered
canonically and flagged
?.
Plus: severity split from confidence (5..95 %, never 100), and the
canonical pair key so two candidate pairs under one SSID both survive.
The key shares #98's dedup/incident machinery rather than forking a
second scheme — it flips #98's own
test_lifecycle_two_twin_pairs_one_ssid_* assertion, which that issue
left pinned to the broken count with a note naming #89.
Out of scope, deliberately: the JSON inventory file, the
--my-ssid/--my-bssid flag merge and the inventory content hash are
slice 2; the UI separation of over-the-air impersonator vs neighbouring
AP vs wired-attached rogue is slice 3. site is left empty rather than
derived from an observed SSID or BSSID — the Captain's constraint on the
issue, and the exact defect class this slice removes.
Not done here: attributed and confidence are not persisted to the
--db twin_episodes table. That needs a DB_SCHEMA_VERSION bump and
a migration, which is more than this slice warrants; the canonical
BSSID ordering does however fix a real defect in that table's primary
key (ssid, real_bssid, twin_bssid), which previously swapped — and so
inserted a duplicate row — whenever the pair's two RSSIs crossed.
Updated pages: alerts.md, tool-fingerprints.md.
KARMA_AP fired CRIT unconditionally once one BSSID beaconed ≥3
distinct SSIDs — a long-lived AP that legitimately renamed itself a
few times over a session scored the same as an active PineAP lure.
Applied the same severity/confidence split #89 gave EVIL_TWIN:
- Bare SSID count is now a WARN candidate (
- candidate, uncorroboratedin the detail); CRIT requires PNL overlap (the actual PineAP Beacon-Response mechanism), a shared-victim deauth-then-lure chain, or a verified tool signature match. -
Deauth-then-lure now requires a shared victim. The prior check
(
karma_deauth_active(), both insrc/alerts.cand duplicated insrc/karma_detect.c) fired on any recent flood anywhere in range, crediting every KARMA candidate with an unrelated victim's bad luck.karma_deauth_lure_victim()(src/karma_detect.c, shared by both callers) now requires a station deauthed off a different BSSID whose PNL asks for one of this candidate's SSIDs, or who has since associated with it. -
tool_fingerprint_match()gained anunverifiedout-param so a caller can tell a genuinely thin match from one the existing confidence cap was silently capping. The alert detail marks an UNVERIFIED match with a trailing?, matching the[11?]unconfirmed-retune convention #91 introduced in the interface view. - PMKID observation stays informational only — it never escalated severity, and the fix makes that explicit in code comments: a legitimate 802.11r/PMK-caching exchange also produces one.
Additive-only: confidence reuses #89's fire_conf() engine plumbing
and JSONL field; no alert type id renamed, no DB_SCHEMA_VERSION bump
(karma_candidates.score keeps its column, just a corrected input to
the same formula).
Created page: inventory.md. Updated pages: index.md, alerts.md, jsonl-schema.md, ../views/alerts.md, ../views/twins.md.
Slice 1 removed three false trust anchors from the evil-twin rules and
left the detector with none at all: it could say "these two radios
disagree" and never "that one is not mine". This slice adds the anchor —
the operator's own --inventory JSON, the only trust input in the
family that does not arrive over the air.
-
Loader (
src/inventory.c): hand-rolled recursive descent, no new dependency. The tree had no JSON parser —jsonl.c/formatter.conly write andupdater.c'sscan_str_fieldis a flat key scanner that cannot expressnetworks[].bssids[]and would match"ssid"inside a string value. All-or-nothing: malformed JSON, wrong types, over-size, duplicate SSIDs/BSSIDs, bad MACs, control bytes, a NUL, deep nesting and\ueach fail with a reason and a byte offset, loading nothing; a failed load leaves a previously valid inventory in force.--inventoryexits non-zero rather than starting without the anchor the operator asked for. -
Detection:
INV_MISMATCHis+50positive evidence and hard, so a spoofed 802.11k neighbour claim cannot erase it and a same-OUI clone becomes visible.INV_APPROVEDon both halves makes the pair a non-candidate — a sole suppressor, deliberately: what #89 removed were suppressors sourced from frames the attacker writes, and the test is whether the adversary can reach the input. The weak/strong branch demotes an approved pair to WARN instead of silencing it, because a downgrade lane under one SSID is a finding whoever owns the radios. -
Unconfigured is unchanged. Pinned by
test_no_inventory_keeps_slice1_behaviour— an operator who never writes a file must not silently lose detection. -
Flag merge:
--my-ssid/--my-bssid(#52) are unioned with the file, never intersected. Adding a flag must not be able to manufacture a rogue out of the operator's own AP. -
siteis configuration only (owner decision, 2026-09-25):--siteor the file'ssitefield, nothing else, flag wins, order-independent. Nosite_sourcefield — there is no second source. A site derived from the uplink would re-key the canonical pair key on every roam and fragment one impersonator into several incidents. -
Identity is the content hash: 16 hex chars of SHA-256 over the
file's bytes, stamped into every alert and export that consulted it as
the additive
inventoryJSONL field. Emitted only by rules that actually read it. Theversionstring is a label — two files may both claim one. -
security_profileis parsed, validated and displayed but not matched against the observed cipher: that needs a normalisation table between operator vocabulary and beacon-derivedencstrings, and a wrong row in it alerts on the operator's own APs.
Additive JSONL only; no DB_SCHEMA_VERSION bump. Slice 3 (UI separation
of impersonator / neighbour / wired-attached, plus the controller
correlation hook) remains open.
Updated pages: dashboard.md, plus
docs/views/dns.md,
docs/views/dashboard.md and the README.
Slice 1 built the seam — dns_lookup_cached() for names sloth already
observed, dns_resolve() as the single choke point for active reverse
resolution — and deliberately changed nothing that flowed through it.
This slice decides the policy, on the Captain's written instruction
recorded on the issue: strict observation is the default.
-
dns_resolver_set_enabled()defaults off (DNS_RESOLVER_DEFAULT_ENABLED).test_resolver_enabled_by_defaultinverted totest_resolver_disabled_by_default; that assertion existed precisely to make this flip arrive as a deliberate edit rather than as drift, and this is that edit. -
The worker thread is gated.
dns_init()no longer creates the resolver thread unconditionally. Since the worker owns the onlygetnameinfo(3)call in the tree, a strict run cannot resolve even by accident — "the DNS worker is never started" is now a property of the process, not a branch taken per lookup. It also no longer starts a second worker when called twice, which two test suites do. -
The two remaining ungated callers are routed. The UDP/443 QUIC
decoder (v4 and v6) called
dns_resolve()on the capture thread for every packet, consulting no toggle — a cold cache turned capture itself into a reverse-DNS generator. It now callscapture_quic_hostname(), which is passive unconditionally.top_hosts_update()resolved on every poll regardless of the[n]names/numeric toggle; it now follows it, so the two gates compose. -
Flags.
--allow-activeopts in and prints exactly one stderr line naming what it enabled.--strictis an accepted no-op that locks the guarantee for the run; the lock lives insrc/dns.c, not in the argv parser, so it is a cross-module invariant. A command line carrying both is refused in either order rather than silently resolved in favour of one.
Not done here: the nl80211 scan-trigger limiter and the telemetry/Avahi profile are slice 3, and the README positioning rewrite is sequenced after the profile is verified on real hardware. Both halves of the DNS lookup API still return a shared static buffer documented main-thread-only while the capture thread reaches the decoder path — that race predates #84 and was not touched inside a behaviour flip.
Created page: data-socket-exposure.md. Updated pages: index.md, ../streaming.html, ../../README.md, ../../examples/README.md.
The Captain's decision on #86 (2026-09-25) was no crypto in sloth — in-process TLS/mTLS and bearer tokens are rejected, the design is parked in #100, and the transport stays local. This slice makes "no remote exposure by default" technically true rather than merely conventional, and writes down the boundary that replaces the crypto.
-
Remote-bind guard. A
tcp:spec outside127.0.0.0/8— including the0.0.0.0wildcard — is refused unless--data-socket-allow-remoteis passed; the refusal names the flag and offers thessh -Lone-liner. With the flag, the bind proceeds and warns, naming address, port, and the fact that the stream is unauthenticated and unencrypted. The check runs beforesocket(), so a bind nobody opted into never reaches the kernel. -
Loopback is the whole
/8, not127.0.0.1.127.0.0.2is equally unreachable off-host and an operator using one must not start needing a flag. Classification is done on the addressinet_pton()parsed, not on the string, andunix:is unaffected in both directions. No working configuration changed. -
unix:sockets are now created 0600. They were created at the process umask —0755on a default0022host, i.e. world-connectable. The page states that 0600 plus the uid-ownership check fromf2bf0b5is kernel-enforced peer authentication, which was not true until this commit: the mode is now forced withumaskacross thebind()(notchmod()after it, which leaves the socket listening at the looser mode first). This is the recommended deployment and the page says so plainly rather than burying it. -
The three supported remote paths, with commands that run:
ssh -N -L 8765:127.0.0.1:8765 user@sensor; the new mutual-TLS stunnel templates; andexamples/forwarder/sloth-forward.py, which connects as a local client and pushes outbound to HEC / syslog / Elasticsearch / Loki / Datadog / webhook — so nothing listens remotely on the sensor at all. -
examples/stunnel/ships sensor- and reader-side configs withverify = 2on both ends, because one-sided TLS protects this stream from a passive listener and not at all from an active one. The README leads with theaccept/connectswap, the one error that fails open. No systemd unit ships —FACTORY.md§7 keeps deployment under operator control, so the unit is an inline example on the wiki page instead, matching the forwarder README's precedent.
The page is explicit about the one thing sloth cannot assert about itself: whether this closes an external reviewer's "remotely exposed" finding is the reviewer's sign-off. The auth/TLS half of #86 stays open.
Source: external CISO/GRC review (Todd Luther, 2026-09), issue #94. The finding is not a missing feature — it is that two outputs stated conclusions their evidence does not support, and both produce records that could be quoted in a personnel investigation or used to point at a person in a room.
Doc updates: mac-randomisation.md rewritten
around the limits on use, the seven acceptance requirements, the measured
false-pair rate, and the configuration; alerts.md gained
Corroboration before intent and Records are not conclusions about
people; jsonl-schema.md documents the seven additive
seqnum_correlation fields; retention.md gained §3b for the two
in-memory correlation horizons; docs/views/seqnum.md and the
MY_NET_RECON row in docs/views/alerts.md rewritten.
Sequence correlation. The pre-#94 rule minimised absolute modular
distance over the cross product of two 8-entry trails and accepted any
pair inside a flat 64-seqnum / 30-second window, with nothing expiring
anywhere, then labelled the result LIKELY SAME DEVICE. Five checks now
gate a pair, and each removes a class of false pair rather than tuning a
threshold:
-
freshness — both addresses heard within
--correlate-retain(default 300 s) of now. Without it two trails that were once close kept producing current suggestions for the life of the process. - ordering + exclusivity — the earlier address must be finished before the later one starts. One radio holds one address at a time, so interleaved transmissions are two radios however close their counters sit. This is the check that does most of the work in a dense room.
- direction — the counter advanced forward by 1…64. Absolute distance cannot separate +5 from −5, and only one of those is a rotation. Zero is excluded: a repeated value is a duplicate, and duplicates are the commonest coincidence there is.
- continuity — each trail runs forward on its own before it is extrapolated across the seam.
- a score above the floor, replacing the categorical verdict.
Measured, not estimated. The issue offered scale intuition —
129/4096 differences inside modular distance 64, ≈ 3.15 % per comparison
for two independent uniform 12-bit values. That is an estimate about a
model; real trails are dependent. test_dense_environment_false_pair_rate
builds 64 independent radios over only 512 counter positions, each in its
own non-overlapping slot, and prints the rate on every make test:
472/2016 = 23.413 % before, 59/2016 = 2.927 % after, top score 80 %.
The test asserts a bound set from the measurement (< 4 %), so it fails
if the rule is ever loosened, and does not encode the estimate.
The score is a ranking, not a probability. Stated plainly in the docs because it is the one thing a reader will get wrong: how often a reported pair is really one radio depends on the base rate of rotations, which sloth cannot observe. In a room of sixty randomising handsets and no rotations, every accepted pair is wrong whatever it scores. This is also why the heat-red "possible rotation" row is structural as well as numeric — it requires exactly one randomised address, the shape rotation actually produces, because coincidences reach the same numeric band and no number derived from the same evidence can separate them.
MY_NET_RECON. Uncorroborated it is now LOW at 25 % confidence and
says probed for designated network … uncorroborated; the word
reconnaissance requires positive corroboration (sustained probing, or a
PNL naming two or more designated networks). A randomised MAC does not
corroborate — it describes the handset population — and neither does the
absence of an observed association, because an incomplete capture is
the benign case the rule exists to respect. Three exonerations:
designated association, the --known-mac roster, and association by a
seqnum-correlated sibling address — which closes the randomised-probe /
real-association case where exact-MAC matching accused a device that was
on the network the whole time.
Deliberate test change. test_my_net_recon_fires_for_unassociated_client
pinned ALERT_SEV_WARN for the bare observation while the rule's own
comment conceded "a former guest's phone produces it honestly". That
assertion was pinning the over-claim, not guarding a regression, so it
was rewritten as
test_my_net_recon_uncorroborated_is_low_and_unqualified rather than
worked around.
Configurable, with a stated purpose. --no-correlate and
--correlate-retain SECS, announced at startup so an operator does not
have to read the source to learn the capability is on. Purpose: keep
device counts, alert dedup and transit passes from being inflated by MAC
rotation — not to build a movement history.
Flagged decision (taken on a default, not an answer). Triage asked
whether "rename outputs" meant display text only or also the JSONL alert
type ids, and the question was never answered. Proceeded on the stated
default: human-readable text changed, type ids kept. MY_NET_RECON
and every other id is untouched, because renaming one is a non-additive
schema break under MISSION §4.3 and would reach the sloth-ios consumer.
Everything new is an added field. No DB_SCHEMA_VERSION bump.
Source: issue #84 (external CISO/GRC review, Todd Luther, 2026-09), slice 3 of 3. Slices 1–2 built the passive/active split in the resolver and made strict observation the default.
What changed.
-
src/observe.{c,h}is new and small: one owner for the run's observation policy. The strict lock moved out ofsrc/dns.c, which held it in slice 2 when the resolver was the only active path worth gating. The nl80211 scan trigger and the Avahi carve-out both have to honour the same lock and neither can depend on the DNS module, so the policy is now its own object every subsystem asks.dns.ckeeps only the resolver's own enable bit and delegates the lock. -
The nl80211 scan trigger is gated and instrumented.
trigger_scan_async()used to fireNL80211_CMD_TRIGGER_SCANfrom the ordinary poll loop, unconditionally. The decision, the rate limiter and the message construction are now split intolinux_wifi_prepare_scan_trigger(), which returns 0 when policy or the limiter refuses. Under the default profile — and, locked, under--strict— zero requests are built, and no socket is opened either. Counting at the builder rather than atsendto(2)is the point: a request that was built and then dropped is still a request the code was willing to make. -
The scan-trigger rate limiter is genuinely per-interface. It was a
single function-static
time_tshared by every interface behind a comment claiming per-interface limiting, so the first radiofind_wlan_ifaces()enumerated consumed the whole budget and the rest were triggered only when it happened to be quiet. Fixed rather than documented as global: the enumeration order is fixed, so "global" does not mean fair, it means one radio is preferred and the others starve — and a monitor radio alongside an uplink is the deployment sloth targets.find_wlan_ifaces()caps at 8, so an 8-slot table gives every enumerated radio its own slot with no allocation. -
--strictsuppresses mDNS discovery, enforced insidediscovery_publish()rather than at the call site. sloth still transmits nothing itself, but avahi-daemon announcing on its behalf is the host's presence on the wire. - README positioning rewritten, which the issue sequences last. The old headline claim — "never injects packets, never scans, never modifies kernel state" — was the thing under review and is gone. What replaces it says only what the code enforces, names the two active behaviours in a table, and states plainly what has not been proven (over-the-air validation with an independent receiver).
Deliberate asymmetry, flagged. The scan trigger is off by default;
discovery is suppressed only under the explicit --strict lock. The
Captain's written decision of 2026-09-25 names the resolver and the scan
trigger, not discovery, and the carve-out already requires a routable
--data-socket bind plus --data-socket-allow-remote — two explicit
operator acts — so it cannot fire on a default run at all. Silently
dropping it would break sloth-ios discovery for a deployment that asked
for it.
Not touched. MISSION.md §2/§3 and agents/AGENTS.md still carry
the older "never scans, never modifies kernel state" phrasing. The
charter is the Captain's to author (§4.3) and agents/ is a high-signal
surface, so the inconsistency is surfaced on the issue rather than
edited here.
Source: issue #89 fix list, final bullet — "Distinguish in the UI: over-the-air impersonator vs. neighboring AP vs. unauthorized AP attached to the wired network. RF alone cannot establish wired attachment; leave a hook for controller/switch/DHCP correlation."
Updated pages:
-
jsonl-schema.md — additive
ap_classandwired_attachmenton thealert,alert.*andtwin_episoderecords, plus a note on why they are two axes and why a missingwired_attachmentmust be rendered as unknown rather than as "not attached". -
../views/twins.md — the category table, the new
Class/Wiredcolumns and legend, and the reasoning for keeping wired attachment off the class enum entirely.
Notes:
-
Two of the three categories are decidable, one is not, and that
asymmetry is the whole slice.
ap_class(impostor/neighbor/declared/ unknown) comes from RF plus the approved inventory, because impersonation is an over-the-air behaviour. Wired attachment is not observable from any frame — a Pineapple on an LTE uplink and a rogue bridged onto the access VLAN beacon identically — so it is a separate field that no rule insrc/alerts.ccan set, defaulting to unknown and rendering as?. -
The hook is
src/wired_attach.h: one in-process registration slot for a future switch CAM / controller / DHCP correlator. Not a plugin loader (no dlopen, no path), not a control surface (nothing reaches it from the network or the CLI), and registering a correlator grants it no permission to transmit — MISSION §2 applies to it like anything else. One slot, no stacking: two correlators disagreeing is an unresolved question, not a vote. -
Classification is a label, never a gate. No class suppresses an
episode, moves a severity or changes a confidence; with no inventory
configured an operator sees exactly what they saw before, plus two
columns reading
?. A classifier that could silence a finding would be a new sole suppressor — the bug class #89 exists to remove. -
ALERT_DETAIL_LEN256 → 320. The same-security twin detail was calibrated to land at exactly 255 bytes worst case, so appending[class=… wired=…]pushed it over and-Wformat-truncationcaught it. Grown rather than trading a field away, because the field that would have been truncated iswired=?— the one statement that stops a reader assuming sloth checked the wire.
Source: issue #86 — the documented unix: socket examples should
model the deployment data-socket-exposure.md recommends, not a path
another local user controls.
Updated pages:
-
../quickstart.html,
../index.html and
../views/interfaces.md —
unix:examples now spell/run/sloth.sock(was/tmp/sloth.sock, and/var/runin the interfaces doc).
Notes:
-
Why
/run./tmpis world-writable, so anyone can create the path before sloth starts.init_unix()already refuses a non-socket or foreign-owned path (f2bf0b5), which turns a plant into a denial of service rather than a hijack, but examples are what people copy./runis root-owned and is what the refusal hint names. -
Not done yet.
README.md,docs/streaming.html, jsonl-schema.md and theexamples/consumerandexamples/forwarderREADMEs still show/tmp/sloth.sock. They are the next sweep. TheRELEASE_v1.*.mdnotes are historical and stay. -
Known rough edge. The forwarder examples connect without
sudo, and a 0600 socket that root creates refuses any other uid. This was true before with/tmptoo, anddata-socket-exposure.md§5 has the same shape. It needs a sudo or group note in a later pass.
Source: issue #96 — README, SECURITY and help text state the version, view count and alert-rule count, and each has drifted from the build before.
Updated pages:
-
../../SECURITY.md— the archived row of the supported-versions table reads "every tag before the newest" (was "v1.8.0 and below", which left v1.8.1 in no row once it was tagged). -
tests/test_docs_consistency.c(new) — compares claims inREADME.md,SECURITY.md, the help card andprint_usage()withSLOTH_VERSION,VIEW_COUNTandALERT_TYPE_COUNT.
Notes:
-
Fixed grammar, no model. Counts come from a closed word pattern:
<N> [live|passive|ncurses|alert]* views|rules. README must yield its known claims, so a reword that slips past the grammar fails instead of passing silently. -
Coupling. SECURITY.md may name only
SLOTH_VERSION. The commit that bumps the version must edit SECURITY.md in the same commit. -
Limits. Counts written as words ("six alert rules") are not
read. The help-card check only catches a count or version later
hard-coded into
help.c. -
Out of scope.
MISSION.mdandagents/are not checked. The "9304 assertions" figure inagents/AGENTS.mdand README is stale (10712 now) and is left for the owner.
Source: issue #95 — parsers read bytes off the air and the wire; a unit test that passes while reading past its buffer is a false green. #83's EAPOL-Key over-read was only confirmed by a manual ASan run.
Updated pages:
-
.github/workflows/ci.yml— new blockingsanitizejob runsmake testwith-fsanitize=address,undefined -fno-sanitize-recover=alland leak detection on. -
tests/test_tls_log.c— the JA4 extension-reorder test swapped two extensions with an overlappingmemcpy; both copies now read from the pristine fixture. Same bytes, same assertions.
Notes:
-
One finding, in a test. The overlapping
memcpywas the only sanitizer report across 10712 assertions; nothing insrc/. -
Fix, don't suppress. Zero leaks, so no
tests/lsan.supp. A future third-party entry needs a reason per line and never covers sloth's own allocations. -
Coverage limit. Only
sloth_testis instrumented, and it is built withoutWITH_PCAP/WITH_NCURSES: pcap capture paths, ncurses rendering and theslothbinary are not sanitized. -
Out of scope.
agents/AGENTS.mdstill describes CI as the six-variant-Werrorbuild only; left for the owner.
Source: issue #86 — discovery_routable_tcp_port() kept its own
copy of "is this spec routable" and had drifted from the bind guard in
src/data_socket.c.
Updated pages:
-
src/discovery.c— routable now meansdata_socket_spec_is_remote() == 1; the port comes from the newdata_socket_spec_tcp_port(). -
src/data_socket.{c,h}—data_socket_spec_tcp_port()exported, built on the binder's strictparse_host_port(). -
src/discovery.h— comment no longer describes a 127.0.0.1-only rule. -
tests/test_discovery.c— 5 tests, 20 assertions.
Notes:
-
The bug. Only 127.0.0.1 counted as loopback, so
tcp:127.0.0.2:8765bound without--data-socket-allow-remoteand was then advertised over mDNS as a LAN service.atoi()read8765xas 8765 where the binder rejects the spec. -
One classifier. The advertisement can now only describe what the
exposure guard allowed. #84's
--strictgate indiscovery_publish()is unchanged. -
Still advertised.
0.0.0.0binds every interface and needs--data-socket-allow-remote; now pinned by a test. -
Limits.
data_socket_spec_tcp_port()has no direct tests intests/test_data_socket.c, and duplicates the parse sequence indata_socket_spec_is_remote(). Both are follow-ups.
Source: issue #95 — g_taint in src/alerts.c was written on the
main loop and read from the probe thread (via eapol_log.c writing a
.22000 line) with nothing ordering the accesses.
Updated pages:
-
src/alerts.c—g_taint_muguards mark, query and clear; eviction body istaint_mark_locked(). -
src/alerts.h— thread-safety contract documented;EVIL_TWIN_TAINT_MAXexported. -
tests/test_alerts.c— concurrent mark/query test, 16 assertions.
Notes:
-
Leaf lock. Nothing but memcmp/memcpy runs under
g_taint_muandtime()is read before locking, so taking it undereapol_log.c'sg_mucannot deadlock. - Red signal is TSan. With the locks stubbed a TSan run reports 2 races; without TSan the new test only catches the race by chance. CI gains a reliable signal when the #95 TSan slice lands.
- Limits. The post-join capacity check runs on a cleared table, and the leaf-lock invariant is documented, not mechanically enforced.
Source: issue #84 — README and the data-socket exposure page
claimed --strict blocks --hop and closes every on-wire path. The
code gates only --allow-active and the mDNS advertisement.
Updated pages:
-
README.md— WiFi SIGINT usage:--strictrefuses--allow-activeonly;--hopis the separate MISSION §2 carve-out.--strictand--data-socketbullets name a routable data socket as a remaining path. -
docs/wiki/data-socket-exposure.md—--strictsuppresses the advertisement, not an opted-in routable listener.
Notes:
-
Owner decision open. Whether
--strictshould also refuse--hopor a routable socket is left to the owner; the docs describe today's behaviour. -
Limits.
sloth --helpinsrc/main.cstill over-claims and needs a code slice. The README "exactly two behaviours" paragraph predates this change and omits--hopand the routable socket.
Source: issue #87 — the permission hardening shipped a fixed on-disk contract, but only the EAPOL page described it and its closing line read like guidance on group access while triage Q3 is still open.
Updated pages:
-
docs/wiki/retention.md— new §4.1: per-artifact creation mode (0600 files, 0700 directories, independent of umask), write strategy and refusal behaviour.--snapshot-outadded to §4 and to §6 known gaps as not covered (plain fopen). -
docs/views/eapol.md— sudo/group line replaced with current behaviour: no group mode, gid not checked, a group-accessible existing path is refused and left unchanged.
Notes:
- Behaviour, not policy. Both pages say what the code does and point to #87 Q3; SECURITY.md is untouched.
-
Scoped claims. SQLite side files are lstat-checked by path, not
pinned to a descriptor. "Refused, not repaired" covers append and
truncate targets and directories only; EXCL artifacts step to
_Nand the per-handshake pcap is replaced by rename. -
Limits. The main
--dbis reopened by path after its check.--snapshot-outignores write errors after open. §6 still says a failed write disables the sink, which #87/#92 changed to count-and-retry.
Source: issue #95 — g_ch/g_n in src/rf_quality.c were written
by rf_quality_observe() on the probe thread for every monitor-mode
frame and read by the poll loop's snapshot with nothing ordering the
accesses.
Updated pages:
-
src/rf_quality.c—g_muguards observe, retry/badFCS pct, is_degraded, snapshot and clear;roll_if_stale()runs only under it; pct body ispct_locked(). -
src/rf_quality.h— thread-safety contract documented. -
tests/test_rf_quality.c— concurrent observe/snapshot test, 13 assertions.
Notes:
- Leaf lock, table-wide. Eviction rewrites a slot's identity, so per-slot locks would still need a table lock; critical sections are at most a 64-entry scan. Snapshot holds the lock for the whole pass so each row is self-consistent.
- Red signal is TSan. With the locks stubbed TSan reports races (exit 66); a plain build caught the tear 1 of 3 runs. CI gains a reliable signal when the #95 TSan slice lands.
-
Limits. The reader checks
is_degradedonly for false positives on clean channels.rf_quality_snapshot()still writess->channels[]unlocked, so callers must ownsloth_state_t(true today: poll loop only).
Source: issue #82. Its PIN-brute and Pixie-Dust asks were declined in triage because sloth had no EAP-WSC decode path. This slice adds that path as a stateless parser.
Updated pages:
-
src/eap_parse.c,src/eap_parse.h—eap_wsc_parse()andwsc_msg_name(): RFC 3748 §5.7 Expanded Type 254 gated on WFA SMI 0x00372A / vendor-type 1 (WSC 2.0 §7.7); Op-Code, MF/LF flags, Message Length; §12 TLV walk for Message Type 0x1022, UUID-E 0x1047, MAC 0x1020, bounded by EAP Length.eap_type_name(254)now returns "Expanded". -
tests/test_eap_parse.c— 9 hand-built-frame tests (+155 assertions).
Notes:
- Fragments. Middle fragments are skipped because their body starts mid-value. A final fragment (MF and LF clear) looks the same as an unfragmented message, so it is walked. Its msg_type is untrusted until the session layer reassembles.
- Tolerance. Fixed-size attributes with the wrong length are ignored. Duplicate TLVs resolve last-wins. Op-Code and Message Type are not cross-checked. Each is a job for the session layer.
-
Naming. 0x1020 is the generic MAC Address attribute and is the
Enrollee MAC only in M1, so the field is
mac. - Not yet wired. Nothing calls it at runtime. The wave-7 WPS session table is the first caller, and wave-11 fuzzing targets it.
Source: issue #92. The per-handshake pcap stamped every record with
the processing time (time(NULL)) and a zero ts_usec, so a capture
backlog shifted it and M1..M4 of one exchange collapsed into the same
second.
Updated pages:
-
include/eapol_log.h,src/eapol_log.c—eapol_observe_dot11()takes(ts_sec, ts_usec)from the pcap header;pending_tstores per-message usec;write_handshake_pcap()writes both. Theeapol_observe_dot11_at()seam from #97 is folded in and removed. -
src/capture/probe.c—on_probe_framepasseshdr->tsthrough. -
tests/test_eapol_log.c— 3 hand-built-frame tests that read back the written record headers (+19 assertions).tests/test_alerts.candtests/test_fragattack.cget one-line signature updates.
Notes:
-
One clock for EAPOL. The pairing window, event ts, LRU
last_seenand theeap_trackrogue-RADIUS session stamps now use capture time. Live capture is the same wall-clock domain, so no comparison crosses domains. -
Clamp. A
ts_usecoutside 0..999999 is stored as 0. Probe never enables nanosecond precision; if it ever does, the pcap magic must change to 0xa1b23c4d and the clamp revisited. -
Open.
frag_observe()still takestime(NULL)for the same frame. That is left for a later #92 slice or the wave-4 stop-flags edit of probe.c.
Source: issue #95. dns_lookup_cached() and dns_resolve()
returned a pointer into one static g_result. The capture thread
(capture_quic_hostname()) read it while the main thread
(dns_resolve() for top_hosts and the packet detail pane) could be
overwriting it, so a QUIC log record could carry a torn hostname. This
closes the race the #84 entry above left under "Not done here".
Updated pages:
-
src/dns.c,src/dns.h— all three lookups take(ip, buf, sz); the name, or a copy of ip on a miss, is copied underg_mu, truncated and NUL-terminated.g_resultremoved.DNS_NAME_MAX(256) added and used for the cache host field. -
src/top_hosts.c,src/views/packets.c,src/capture/capture.[ch]— callers pass their own buffer;capture_quic_hostname()gains(buf, sz). -
tests/test_dns.c— held-at-once, miss, truncation, NULL/0 buffer and a two-thread lookup-vs-resolve test (+35 assertions withtests/test_top_hosts.c). Snoop and capture tests moved to the new signature.
Notes:
-
Contract. NULL buf or
sz == 0returns ip and writes nothing; every other path returns buf.dns_lookup()is kept on the same signature so the compatibility spelling cannot bring back a static buffer. -
top_hosts resolves into a
hostname[64]-sized temporary, keeping the old 63-character truncation. - Race gate. The two-thread test is probabilistic; the held-at-once test is the deterministic proof. Reliable detection of a regression depends on the TSan CI slice.
-
Open.
dns_fmt_addris not covered by the "any thread" note indns.h; it is main-thread only today.
Source: commit d3170da, issue #86 (last item on the fix list).
sloth keeps the data socket open for its whole life. Without
FD_CLOEXEC, any child it exec()s inherits the listener and every live
client, so that child can accept() on, or read, the unauthenticated
JSONL stream.
Updated pages:
-
src/data_socket.c: on Linux the listener comes fromds_socket()withSOCK_CLOEXEC|SOCK_NONBLOCK, and clients come fromaccept_cloexec()(accept4()with the same flags), so the flag is set atomically._GNU_SOURCEis defined for this file only, and only under__linux__. Other targets setfcntl(F_SETFD, FD_CLOEXEC)after the call, check it, and close the fd if it fails. -
src/data_socket.h: one test-hook comment. Passing NULL todata_socket_test_set_accept_fn()now restores the close-on-exec wrapper, not plainaccept. -
tests/test_data_socket.c: three tests (+12 assertions) checkfcntl(F_GETFD) & FD_CLOEXECon the UNIX listener, the loopback TCP listener and two accepted clients.
Notes:
-
Coverage gap. Reverting only the static
g_accept_fninitializer toacceptstays green, because earlier tests pass NULL first. The initial default has no coverage that is independent of test order. -
Open. The liveness-probe socket in
unix_path_removable()is still not close-on-exec. It is short-lived and closed before init returns. The "every fd this module owns" comment therefore over-claims slightly. -
Open. A docs sweep for
unix:/tmp/sloth.sockis outstanding in README.md, docs/streaming.html, docs/wiki/jsonl-schema.md and examples/{consumer,forwarder}/README.md. Authenticated remote delivery is tracked in #100.
Source: commit 9bfb808, issue #95.
The capture, probe and DNS workers were stopped through a plain int
flag. It was written on one thread and read on another with no
synchronisation, which is a data race in C99. Stop paths also checked
the flag and cleared it in two separate steps, so two stoppers running
at once could both pthread_join the same thread, which is undefined
behaviour.
Changes:
-
src/capture/capture.{c,h}: newcapture_run_flag_t(a mutex plus an int) withget,setandtake.take()clears the flag and returns its old value atomically. It is defined outsideWITH_PCAPso the tests can run it under TSan without libpcap. -
src/capture/probe.c: uses the same helper.probe_set_iface()now clears the flag whenpthread_createfails. -
src/dns.c: the flag stays underg_mu, because the condvar needs it. The worker reads it only while holding the lock, anddns_cleanupchecks and clears it in one step. -
src/main.c:g_quitisvolatile sig_atomic_t. - Tests (+16 assertions): the flag contract, a polling worker stopped
mid-loop, concurrent
take(), and concurrentdns_cleanup().
Notes:
-
Open. SIGINT and SIGTERM can be delivered to worker threads, so
the C99 guarantee for
g_quitformally covers only a handler on the same thread. Blocking them withpthread_sigmaskis a follow-up. -
Open. Start and stop are not safe to run concurrently, because
the flag is set before
pthread_create. Only the main thread starts and stops workers today. -
Coverage gap. The exclusivity test catches a
take()written as a get followed by a set only by chance. The new spin loops have no timeout. The pcap worker loops themselves are not tested.
Source: commit ef7db0d, issue #90.
A KARMA_AP alert that named a tool (ESP32 Marauder, WiFi Pineapple) said so only in free-text detail. A JSONL consumer could not tell which signature row matched, which revision of it, or that every shipped row is research-derived and has never been validated against a capture.
Changes:
-
src/tool_fingerprint.{c,h}: each row has a stableidand aversion. The shipped rows areesp32-marauder-01andpineapple-mk7-01, versionresearch-2026-09-04. Ids are never reused or renamed; a capture that replaces a row keeps its id and moves its version.tool_fingerprint_match_row()returns the winning row, and the older matchers wrap it. -
include/sloth.h,src/alerts.c:alert_tcarriessig_id,sig_version,sig_evidenceandsig_validated. They are stamped infire_invand refreshed on every evaluation, so a match that disappears mid-incident stops being exported. -
src/jsonl.c: alert and lifecycle records emitsignature_id,signature_version,validated(JSON boolean) andsignature_evidence, and omit all four when nothing matched. Additive: no schema-version bump, no CLI change. -
src/karma_detect.{c,h},src/views/karma.c:karma_tool_match()builds the observation for both the rule and the [y] view, andtool_attribution_format()renders it. An unverified match reads[ESP32 Marauder/med? provisional]in both places. - Docs:
jsonl-schema.md(new fields;confidenceis also emitted by KARMA_AP and MY_NET_RECON),alerts.md,karma.md,tool-fingerprints.md. - Tests (+95 assertions), including a data-socket test that pushes every unverified row through the real rule, engine and socket.
Notes:
-
Owner questions.
research-2026-09-04or the literalunversionedfor unverified rows? Is a JSON boolean right forvalidated, given the rest of the schema uses 0/1 ints? -
Open. A matched [y] row runs to about 122 columns and wraps on
the 100-column minimum terminal; the attribution should be truncated
to the remaining width. The second legend line is not counted in the
page budget (
LINES - 5), so a full list clips it. - Open. The explicit PMKID_OBSERVED / BEHAVIOR_CONSISTENT_WITH_ACTIVE_COLLECTION / CONFIRMED_ATTACK states.
Source: commit 7da4b15, issue #85.
--iface and --monitor-only were enforced by matching interface
names through a cache. A rename, delete or ifindex reuse could make a
stale name authorise traffic from the wrong interface, and nothing told
the operator when the requested scope was no longer being observed.
Changes:
-
src/capture/capture.{c,h}:capture_policy_pin()pins the allow-list to (ifindex, name) pairs before the worker starts. A frame is admitted only when its SLL2 ingress index matches a valid pin.capture_scope_poll()revalidates each pin every tick. On delete, rename, reuse or lookup failure it clears the pin (sticky) and bumps a generation counter underg_mu.capture_scope_state()returns none, enforced, no_capture or degraded. -
src/jsonl.c: sensor_health appendsscope,scope_not_enforced,scope_requested,scope_enforcedandscope_generation, all in the change-only signature. Additive. -
src/views/iface.c: the health strip showsscope degraded N/Morscope no-captureonly when something is wrong. - Docs:
jsonl-schema.md,docs/views/interfaces.md;examples/consumer/sloth-stream.py. - Tests (+101 assertions), using a seeded interface table that changes between ticks and hand-built SLL2 headers.
Notes:
- Owner question. When an adapter is unplugged and returns under the same name with a new ifindex, should it stay failed closed until restart (what ships), or be re-pinned by name mid-run?
-
Open. A transient
if_indextoname()failure also fails a pin closed for the rest of the run. It shows asscope degraded. -
Open. An ifindex reused between two ticks is admitted until the
next revalidation (documented). A dead worker with a live handle
reads
enforced. [m] can still retarget the probe radio under--monitor-only. -
Deferred. The
out_of_scope_droppedcounter (wave 8).
Source: commit 154079c, issue #95.
The sanitizer jobs only see code the suite executes. A static pass over
all of src/ and research/ catches defects on paths no test drives.
Changes:
-
.github/workflows/ci.yml: newcppcheckjob on ubuntu-24.04. A guard step fails unless the binary is exactly 2.13.0. Default Linux configuration only (the -D set mirrorsmake); warning, portability, performance and information classes plus errors; any unlisted finding fails the run. -
tests/cppcheck.supp: 23id:fileentries, each with its reason. An entry that stops matching an analysed file fails the job (unmatchedSuppression). -
src/views/alerts.c: the alert detail panel printed last_seen as "First seen": twolocaltime()results shared one static struct tm. Nowlocaltime_rinto stack storage. New test intests/test_alerts.c(+6 assertions), red before the fix. -
src/capture/capture.c:%u→%dfor an int protocol number. -
src/http_log.c: dropped a redundante &&guard that the next statement contradicted.
Notes:
-
Owner question. Convert the 10 main-thread
localtime()/gmtime()sites tolocaltime_r()and drop their suppressions? - Owner question. Add a second cppcheck pass for the embedded configuration, so WITH_*=0 paths are analysed?
-
Open. An
id:fileentry hides a new same-id finding in the same file; an entry for a renamed or deleted file is not reported stale. Both are stated in the list header. -
Open.
src/pcap_write.c:30dereferences alocaltime()result without a NULL check (pre-existing; cppcheck does not flag it). - Not done. clang-tidy.
Source: issue #85, deferred from the wave-5 slice above.
The scope boundary could fail an individual frame silently: a pin
rejection or an unattributable frame under an active allow-list left no
trace beyond the drop itself. scope/scope_enforced/scope_generation
say whether the boundary is intact, not whether it is being tested.
Changes:
-
src/capture/capture.{c,h}:capture_frame_in_scope()now bumps a module-local lifetime counter whenever an active allow-list refuses a frame — an unattributable ifindex, a non-SLL2/short frame that can't carry one, or an ifindex with no valid pin. The unrelated runtime[y]deselect (unrestricted stream) does not touch it — that's an operator toggle, not an authorization failure. New accessorcapture_out_of_scope_dropped()plus a test-only reset. -
src/jsonl.c: sensor_health appendsscope_dropped, in the change-only signature alongside the otherscope*fields — a scope boundary starting to refuse frames is exactly the kind of news that heartbeat already treats as urgent. Additive. - Docs:
jsonl-schema.md(field table, sample record, cadence note);examples/consumer/sloth-stream.pysurfaces it as a fault-only line. - Tests (+9 assertions): counts distinct rejection reasons, stays at
zero for admitted frames, for unrestricted "can't-tell" frames, and
for the runtime deselect; reset zeroes it; the JSONL field reflects
the accessor. Three mutations (drop the
restrictedguard on each of the two capture.c increment sites; hardcode the emitted field to 0) each turned one test red, reverted.
Notes:
- Open, same as the parent slice: the residual window where an ifindex reused between ticks is admitted until the next revalidation — those frames are not scope-boundary refusals and correctly don't count here.
Seven new concept pages turn the wiki into the complete information source for sloth:
-
what-sloth-does.md— one-screen map: surfaces, 35 views, 61 alerts, outputs, the passive line. -
how-wifi-works.md— 802.11 reference: bands/channels, the three frame classes, discover→auth→assoc→key, security generations, roaming, MAC randomisation, each tied to the sloth view that sees it. -
monitor-mode.md— the four NIC modes, what monitor mode gathers that managed/promiscuous cannot, how to enable it (external), the limits. -
where-exploits-happen.md— attack surface mapped to the join sequence and to eachALERT_TYPE_*detector. -
wifi-sigint-techniques.md— six passive collection techniques, each mapped to a view, with the tradecraft/policy limits (incl. #94). -
cli-reference.md— authoritative flags/args/keybindings/env/exit codes fromsrc/main.candsrc/view_labels.c. -
wifi-state-of-the-art.md— living Wi-Fi radar (Wi-Fi 7 MLO, Wi-Fi 8 / 802.11bn schedule, WPA3/Dragonblood, FragAttacks, SSID confusion), web-sourced items dated and linked. -
wiki-maintenance.md— source-of-truth + sync mechanism + the standing agent duty.
Sync infrastructure so agents keep the wiki updated automatically:
.github/scripts/wiki_sync.sh renders docs/wiki/ into sloth.wiki.git
(index→Home, strips front matter, rewrites relative links, generates
_Sidebar/_Footer, deletes removed pages); .github/workflows/ wiki-sync.yml runs it on every push to main touching docs/wiki/**.
Repo instructions updated: docs/CLAUDE.md, agents/AGENTS.md (new
"Wiki" duty section), README.md (wiki = complete reference). index.md
gained a "Read this first" section linking all seven.
Source: the owner's written decision on issue #87 (2026-09-30): crackable material is gated behind an opt-in, off by default, with a 7-day default retention sweep at startup and daily.
Updated pages:
-
cli-reference.md— the two new flags in the output group,--eapol-dirmarked as requiring the opt-in, the synopsis group list, and exit code2extended with both new refusals. -
retention.md— new §2c "Handshake exports: the opt-in and its sweep": what is swept, when, the whole-file-by-mtime granularity limit oneapol.22000, the never-follow-a-symlink rule, and the limit that retention needs a live opted-in collection. §4's "no retention" table rows for the EAPOL artifacts corrected to 7 days, §4.1's directory row noting the gate is checked before the directory is created, and two new entries in §6 Known gaps. -
what-sloth-does.md,pcap-export.md,monitor-mode.md,evil-twin-reproducer.md—--eapol-dirnow names the opt-in, and every runnable command line that would have exited2was corrected.
Also outside docs/wiki/: README.md (output section, DB retention
paragraph, the quickstart command, the EAPOL view row) and
docs/views/eapol.md (§ Export handling).
Not changed: MISSION.md §2.2 already frames the 22000 export as
material the operator cracks, never sloth; the gate narrows what
reaches disk and needed no mission change.
Mirrored from docs/wiki/ on main by .github/scripts/wiki_sync.sh. Edit there, not here — hand edits to this wiki are overwritten on the next push.
Read this first — the complete reference
- what-sloth-does
- how-wifi-works
- monitor-mode
- where-exploits-happen
- wifi-sigint-techniques
- cli-reference
- wifi-state-of-the-art
Start here
Engines
WiFi SIGINT
- wifi-sigint
- non-ip-sensors
- mac-randomisation
- evil-twin-reproducer
- btm-abuse
- action-frames
- research-corpus
- captive-portal
- fragattacks
- tool-fingerprints
- enterprise-rogue
- ipv6-ndp
- smb-snoop
- kerberos-snoop
- ldap-snoop
- bgp-snoop
- ssh-snoop
- rdp-snoop
- snmp-snoop
- mqtt-snoop
UI and infrastructure
- ip-palette
- platform-vtable
- version-checkin
- manifest-format
- pcap-export
- jsonl-schema
- data-socket-exposure
- sqlite-schema
- ring-buffers
Factory infrastructure
Reference
Source material
Maintenance