# Telemetry collectors

Every collector the sensor ships, what each watches, the kernel mechanism it
rides, and the blind spot it declares. The default build is pure Go and
GPL-free; the heavier kernel taps are opt-in. Collectors are visibility. The
verdict layer that reads them lives in [Detection engines](/docs/detections).

## What runs by default

Two steps, not one, and deliberately so. The plain one-liner installs the signed
binary and starts nothing: a server that only wants a resolvable identity is never
surprised by a new collector appearing on it.

```
curl -fsSL https://get.whisper.online | sh
```

Ask for the sensor and it starts with every collector on:

```
curl -fsSL https://get.whisper.online | WHISPER_SENSOR=1 sh
```

That installs the binary and a dedicated `whisper-sensor` unit, running
immediately. From there nothing needs enabling one by one: every collector in the
table below, and every detection engine that reads them, ships on by default.
The default build carries no cgo and no GPL object. Its core collectors are the
exec collector on the kernel NETLINK process-connector, the file collector on
fanotify, the outbound and inbound socket collector on NETLINK_SOCK_DIAG, and
the DNS attribution tap on AF_PACKET. A critical-config integrity watch and a
vulnerability-posture collector ship in the same pure-Go build. A lower-overhead
eBPF exec and per-connect tap is a separate opt-in build behind `-tags ebpf`,
which links a GPL-2.0 object and is never in the public MIT tree.

Kernel-graceful, fail-open: a collector that cannot attach on a given kernel is
dropped, and the sensor keeps running on the rest. It never fails the host. The
default exec path needs no eBPF, so it runs on kernels well below 5.3.

## One stream, four vocabularies, forward-first

Everything the collectors see rides one telemetry stream in four vocabularies:
exec, file, conn and dns, plus a fifth `detection` kind that engine findings
ride back on. The rule that shapes the whole sensor is forward-first: the hot
path always forwards an event downstream BEFORE any detector looks at it.
Detectors attach either as taps spliced inline that forward first and fold a
judgement on the side, or as observers that side-consume copies and by
construction cannot delay, change or lose a single event.

```
  KERNEL TAPS
  exec · NETLINK      \
  file · fanotify      \___ ONE STREAM · forwards first, always ___ spool -> backend
  conn · SOCK_DIAG     /        |            |            |          (never blocked)
  dns  · AF_PACKET    /         :            :            :
                            taps: Sigma · YARA-X · chains · ransomware · injection
                            a tap folds its judgement ON THE SIDE;
                            detection can never delay, drop or alter telemetry
```

The stream is also defended at the source. Both netlink collectors accept only
datagrams from the kernel itself (source port-id 0) and bind each dump to a
fresh sequence number, so a local attacker cannot fabricate exec or connection
records to bury the real ones. The exec collector carries a PID-recycle guard:
if /proc turns out to be newer than the exec event it is enriching, it drops the
stale identity rather than attribute the wrong binary. The collector set behind
this page carries 2,112 test functions and 93 fuzz targets across 216 test files.

## The collectors, at a glance

| Collector | Watches | Mechanism | Platform | Status |
|---|---|---|---|---|
| Process exec | every execve, /proc-enriched | NETLINK proc-connector | Linux | Live, default |
| Process exec (deep) | same, lower overhead + earlier | eBPF | Linux >= 5.3 | Opt-in, `-tags ebpf` |
| File changes | create / modify / delete | fanotify | Linux | Live, default |
| Settled-hash lane | SHA-256 of settled new executables | fanotify CLOSE_WRITE + bounded drain | Linux | Live, default |
| Critical-config watch | writes to sensitive paths | fanotify + closed path allowlist | Linux | Live, default |
| Sockets, both directions | outbound connect + inbound accept | NETLINK_SOCK_DIAG | Linux | Live, default |
| Per-connect net-tap | each new connection at SYN_SENT, source port | eBPF | Linux >= 5.3 | Opt-in, `-tags ebpf` |
| DNS attribution | qname to originating process | AF_PACKET + classic BPF filter | Linux | Live, default |
| Inventory + posture | packages, open CVEs, SBOM-lite | distro metadata | Linux | Live, default |
| Windows ETW | exec / file / network telemetry | ETW | Windows | Live, telemetry |
| macOS ES bridge | EndpointSecurity + NetworkExtension events | ES daemon over NDJSON | macOS | Live, entitlement-gated |

## Process exec

The exec collector is the spine of the sensor. By default it reads the kernel
NETLINK process-connector, a pure-Go path with no cgo and no GPL, and enriches
each execve from /proc (argv, exe path, uid, ppid) with the PID-recycle guard
described above. The image's SHA-256 and its known-good verdict are computed
asynchronously, off the hot path, so enrichment never slows the stream. The
opt-in eBPF build sees execs earlier and at lower overhead, and links a GPL-2.0
object, so it is kept out of the public MIT tree and turned on deliberately with
`-tags ebpf`. Either way the event shape is the same. This is the raw stream
behind the measured 100% telemetry run:

```
[T1003.008 read /etc/shadow]  {"comm":"cat","filename":"/usr/bin/cat","pid":1658,"ppid":1656,"uid":0}
[T1547.006 kernel module]     {"comm":"insmod","filename":"/usr/bin/kmod","pid":1875,"uid":0}
[T1027 obfuscation]           {"comm":"base64","filename":"/usr/bin/base64","pid":1986,"uid":0}
[T1071.001 C2 user-agent]     {"comm":"curl","filename":"/usr/bin/curl","pid":2018,"uid":0}
[T1105 ingress transfer]      {"comm":"rsync","filename":"/usr/bin/rsync","pid":7495,"uid":0}
```

On that run the exec collector captured 17,092 exec events with zero drops,
including one scan loop that alone spawned 10,974 processes. The full matrix is
in [ATT&CK coverage](/docs/attack-coverage).

## File changes, the settled-hash lane and the critical-config watch

The file collector rides fanotify for create, modify and delete, resolving paths
via file handles rather than racy name lookups. Two lanes sit on top of it.

The settled-hash lane: when a new ELF or script closes for write, its settled
content is digested on a bounded side lane and a SHA-256 hash annotation joins
the stream. That annotation is what feeds the YARA scanner and the known-good
hash verdicts downstream. The lane is bounded by design: a hashing storm sheds
digests, never the drain, so a mass file-drop can cost you some hashes but never
your file telemetry.

The critical-config integrity watch carries a closed, hard-coded allowlist of 16
classic Linux persistence and privilege-escalation paths, each pre-tagged with
its ATT&CK technique: the dynamic-linker preload files, the cron directories,
systemd and init service paths, the sudoers files and the SSH daemon config. It
marks parent directories so a file that does not exist yet (a fresh
/etc/ld.so.preload) is caught at creation, filters every non-allowlisted
sibling, and refuses to follow symlinks so a link-swap race cannot redirect the
watch. The write is captured the moment it happens, not inferred after the fact:

```
# the config-watch collector, on a critical file write:
[T1574.006 ld.so.preload]  {"kind":"file","event":"create","path":"/etc/ld.so.preload","critical":true}
```

## Sockets, the net-tap and DNS attribution

Network visibility is three collectors, each answering a different question, and
each declaring what it cannot see.

```
  SOCK_DIAG                 SYN_SENT (eBPF, opt-in)     AF_PACKET udp/53
  standing sockets v4+v6    the instant a dial starts   the DNS question, per PID
  dialed vs accepted        carries the srcPort key     kernel-filtered before bind
  blind: sub-tick conns     blind: nothing; it closes   blind: DoT/DoH bypass 53,
                            the sub-tick gap            simply not observed
        \___________________________|___________________________/
              one tee -> the per-endpoint graph: process -> name -> peer
```

- Sockets, both directions on NETLINK_SOCK_DIAG: a calm periodic dump of
  established TCP and connected UDP, v4 and v6, classifying direction against a
  per-tick listener snapshot so a dialed-out connection and an accepted-inbound
  one are labelled distinctly. This is what makes east-west and inbound exposure
  visible at all, and it runs unprivileged.
- Per-connect net-tap (eBPF, opt-in): one event the instant a TCP dial reaches
  SYN_SENT, carrying the same source-port flow key as the DNS tap, the
  short-lived-connection fidelity a periodic dump structurally cannot have.
  Nothing in it can influence whether a connect succeeds.
- DNS attribution on AF_PACKET: the resolved qname joined to the process that
  asked, so a lookup of a bad domain is attributable to a binary, not just to
  the box.

The DNS tap deserves its mechanics spelled out, because it is the rarest edge
any EDR draws and it uses no resolver plugin and no eBPF. A classic BPF filter
is attached BEFORE the socket binds, so the kernel only ever delivers outgoing
UDP datagrams to destination port 53; the sensor never sees unrelated traffic.
The DNS question is decoded by a bounded, total parser (RFC-1035 255-octet name
cap, at most 8 compression jumps), and the querying process is recovered
post-hoc: the datagram names the querying socket by source port, the source
port maps to a socket inode via one bounded /proc/net/udp scan, and the inode
maps to a PID through a one-second-TTL reverse index. Attribution is budgeted
at 64 lookups per second; past budget, or when the socket is already gone, the
event degrades to PID 0, a held-but-unattributed node, never a lost edge and
never a /proc churn storm. The tap watches exactly the hop that carries the
real querying process (the stub-resolver hop to systemd-resolved or dnsmasq),
and it says plainly what it cannot see: DoT and DoH bypass port 53 and are
simply not observed.

Why carry a source port on a DNS event at all? Because real tooling splits the
resolve and the connect across different processes (a shared system resolver
asks, the payload process dials). The shared ephemeral port is the flow key
that lets the backend fuse the resolve and the connect back together across
that split. Together the three taps power the write-exec-connect event chain
and the exfil-fusion join, folded into the per-endpoint graph; the three-graph
story they enable is in [How the sensor works](/docs/architecture), and what
the verdict layer makes of them is in [Detection engines](/docs/detections).

## Inventory and vulnerability posture

The posture collector enumerates the host's own packages, its open CVEs and an
SBOM-lite from distro metadata. Collection is self-contained: no heavy
client-side SBOM upload. The graph then ranks the findings by KEV, EPSS, CVSS
and known-ransomware use, so a fleet of thousands of CVEs distills to the
handful worth fixing first. On a real box: 1,285 packages, 4,002 open CVEs, 12
in the Known-Exploited catalog to fix first, including Log4Shell
(CVE-2021-44228). The collector is read via `op:list{kind:'posture'}`.

The gate: the posture collector is live. The graph-side CVE severity scoring
(the KEV and EPSS ranking) runs against a pre-production corpus today and
promotes to the production graph on a dated gate, so treat the ranking as beta
while the collection itself is solid.

## What heavy work costs, and when it runs

Collection itself is cheap by construction; everything that is not cheap
(full-file hashing, deep scans, inventory sweeps) is deferrable work handed to
the calm scheduler, which admits it only when the host is idle, on workers at
nice 19 with idle I/O priority, under the sensor's own cgroup ceiling.
Starvation floors guarantee that a fresh unknown executable is still examined
within about a minute even on a busy host. The measured whole-sensor footprint
is roughly 2.6% of one core and 16 MB; the scheduler, the floors and the ceilings
are in [Calm footprint & SLA](/docs/footprint).

## Windows and macOS

The deep on-host sensor runs on Linux and Windows today, and macOS already emits
telemetry through the same wire contract.

Windows ETW is live: one real-time session over four manifest providers carries
exec, file, connection, DNS and image-load telemetry, a pure-Go user-mode collector
with no kernel driver, and the ransomware, dropper-chain and process-injection
engines run over those streams on-host, proven on a real Windows Server 2022 host.
The ETW-TI tier is built but dark, gated on Microsoft's anti-malware attestation,
not on engineering; the Vulnerable-Driver blocklist matcher is wired
unconditionally and live, registered alongside the rest with no config knob of
its own.

macOS ships an EndpointSecurity and NetworkExtension bridge that ingests the
native ES daemon's events over an authenticated local socket, sharing the same
Go core and a byte-identical event contract proven against the Linux golden
corpus. Customer ship is gated on the Apple EndpointSecurity entitlement, so it
is internal and dev today, not a shippable tier. The full matrix is in
[Platform support](/docs/platforms).

Whatever the OS, identity, resolution and the off-host kill-switch already cover
any host or agent on the network. A device that will never run a kernel sensor
(a PLC, a camera, a contractor laptop, an ephemeral AI agent) still gets a
verifiable /128 and can be contained from the network with no agent installed.

## Where the telemetry surfaces

Collector output becomes a queryable per-endpoint census, enriched with a trust
tier and an affirmative known-good verdict so the analyst sees the calm
majority, not just red. The API key stays server-side; the read is
`op:list{kind:'proc'}`:

```
curl -s https://graph.whisper.online/api/query \
    -H 'X-API-Key: whisper_live_...redacted' \
    -H 'content-type: application/json' \
    -d '{"query":"CALL whisper.agents({op:\"list\", kind:\"proc\",
             address:\"2a04:2a01:8dae:4521:45b8:b241:4f91:56e2\", limit:500})"}'

envelope: op=list  ok=true  status=200  elapsed_ms=66
processes returned : 500
hash-verdict known-good (fleet allowlist) : 52
known-good on THIS host                   : 54
carrying a signal                         : 62

# sample rows (host web-edge-01; known-good rides the signal list)
exe=/usr/bin/wg    trust=T3  knownGood=true  onHost=true  signals=[hash-verdict:known-good,hash-verdict:known-good-onhost]
exe=/usr/bin/ip    trust=T3  knownGood=true  onHost=true  signals=[hash-verdict:known-good,hash-verdict:known-good-onhost]
exe=/usr/bin/bash  trust=T2  knownGood=true  onHost=true  signals=[fan-out:8,sensor-rep:32,hash-verdict:known-good]
```

Telemetry is not a verdict. Everything on this page is visibility: the sensor
sees the event. Turning that stream into an analytic verdict that alerts is a
separate layer, documented in [Detection engines](/docs/detections), and we keep
the two strictly apart.
