Sensor configuration
Probes, filters, sampling, batching and fast-path rules — how a SensorConfig is built, signed, distributed and verified.
A sensor's behaviour is entirely determined by the SensorConfig it has applied. Configuration is authored in the console as a policy, compiled, signed by core, and delivered over the WatchConfig stream. The sensor verifies the signature before applying it, and reports the applied generation on its next heartbeat.
That last part is what makes fleet convergence measurable rather than assumed.
Anatomy
{
"generation": 41,
"tenant_id": "tnt_northwind",
"probes": [ { "name": "sched_process_exec", "enabled": true, "params": {} } ],
"filters": { },
"batching": { },
"sampling": { "1001": 0.6 },
"local_rules": [ ],
"log_level": 2,
"enable_self_telemetry": true,
"signature": "<detached ed25519 over the serialized config>"
}Probes
Nine probes, each feeding specific OCSF classes.
| Probe | Feeds | Notes |
|---|---|---|
sched_process_exec | 1007 Launch | Source of memfd, tmpfs and deleted-binary provenance |
sched_process_exit | 1007 Terminate | Exit code and signal |
sys_enter_connect | 4001 | Outbound; sets syscall: connect |
sys_enter_accept4 | 4001 | Inbound; distinguishes listeners from egress |
file_open | 1001 | LSM where available, kprobe fallback; resolves path_class |
bpf | 1003 | The probe that protects the detection |
ptrace | 1003 | Process injection and memory access |
module_load | 1005 | LKM loads, rootkit signal |
dns | 4003 | Parsed on the socket path — no resolver hook |
Disabling a probe is a coverage decision
Turning off a probe removes an entire OCSF class from that fleet segment. Every rule that reads it stops firing — silently, because there is no error, just an absence. The console's coverage matrix reads probe state, not only rule state, precisely so this is visible.
Probe parameters
{ "name": "file_open",
"enabled": true,
"params": { "max_path_len": "512", "dedupe_window_ms": "2000" } }| Probe | Parameter | Default | Meaning |
|---|---|---|---|
file_open | max_path_len | 512 | Bytes of path resolved in-kernel |
file_open | dedupe_window_ms | 2000 | Suppress identical (pid, path, op) within the window |
file_open | include_only | false | Emit only paths under include_path_prefixes |
dns | parse_answers | true | Parse answer records, not just queries |
dns | max_answers | 8 | Cap answers per event |
sys_enter_connect | skip_loopback | true | Suppress 127.0.0.0/8 |
Filters
Filters run in-kernel, before an event is serialised. This is the difference between a sensor that costs 0.5% CPU and one that costs 5%.
{
"always_include_path_prefixes": [
"/etc/shadow", "/etc/passwd", "/etc/sudoers",
"/root/.ssh/", "/home/*/.ssh/authorized_keys",
"/var/run/docker.sock",
"/var/run/secrets/kubernetes.io/serviceaccount/"
],
"exclude_path_prefixes": ["/proc/", "/sys/kernel/debug/"],
"exclude_comms": ["prometheus", "node_exporter", "fluent-bit"],
"exclude_dst_cidrs": ["10.96.0.0/12", "169.254.169.254/32"],
"exclude_dst_ports": [9100, 9090, 10250],
"exclude_k8s_namespaces": ["kube-system", "monitoring"]
}always_include_path_prefixes wins over every exclusion. Put credential and persistence paths there and they cannot be accidentally filtered away by a later broad exclusion.
Excluding 169.254.169.254
Excluding the IMDS address from network events is a common performance tweak and a bad idea — it is one of the highest-signal destinations in a cloud estate. Exclude the chatty parts of your service CIDR instead.
Sampling
Per-class rates, 0.0 – 1.0. An absent class means 1.0.
{ "1001": 0.6, "4001": 0.85, "4003": 1.0, "1007": 1.0, "1003": 1.0 }Rules of thumb:
- Never sample 1007, 1003 or 1005. Execution, kernel activity and module loads are low-volume and high-value. Sampling them means correlations silently miss their other half.
- 1001 tolerates sampling if
always_include_path_prefixescovers what matters — the sample only applies to the residue. - 4001 tolerates sampling for volume-based detections, but statistical analytics (beaconing, egress volume) degrade in accuracy proportionally.
Batching and spool
{
"max_events": 2048,
"max_age_ms": 750,
"max_bytes": 4194304,
"spool_max_bytes": 536870912,
"spool_dir": "/var/lib/falak/spool",
"compression": "zstd"
}A batch closes on whichever limit trips first. max_age_ms bounds detection latency; max_events and max_bytes bound memory.
The spool is a disk-backed queue used when core is unreachable. The sensor's durable cursor advances only on BatchAck, so a core outage costs latency, not events — until the spool fills, at which point the oldest are dropped and counted.
Sizing: at 5,000 events/sec and roughly 500 bytes per event, 512 MiB buys about 3.5 minutes. For a fleet where a core restart takes ten minutes, size for fifteen.
Fast-path (local) rules
Compiled expressions evaluated in the sensor's userspace so a kill decision does not wait for a round trip.
{
"uid": "falak.local.memfd-exec",
"name": "Fileless execution from memfd",
"expression": "class_uid == 1007 && activity_id == 1 && exec_from_memfd == true",
"action_id": 2,
"severity_id": 5,
"attack_technique_uid": "T1620"
}action_id | Behaviour |
|---|---|
| 1 | Alert — emit with elevated severity |
| 2 | Kill — SIGKILL the process group, then emit |
| 3 | Block — deny the operation (requires BPF LSM) |
| 4 | Log — emit at informational severity |
attack_technique_uid is required. A local rule without one is rejected at compile time, same as any other detection.
Start in alert mode
Deploy a new fast-path rule with action_id: 1 for a week and read what it would have killed. Promoting to 2 afterwards is a five-second policy change; explaining why a rule killed a payment service is not.
Distribution and convergence
- An administrator publishes a policy in the console.
- Core compiles it, increments
generation, and signs the serialized config. - Sensors on the
WatchConfigstream receive it. - Each sensor verifies the signature against the core CA. An unsigned or badly-signed config is refused and the sensor keeps running its current generation.
- The config is applied atomically — probes are re-attached as a set, never partially.
- The sensor reports
applied_config_generationon its next heartbeat.
Core compares reported generations against the published one to compute drift. The console shows the percentage converged per policy, so "we changed the policy" and "the fleet is running the policy" are visibly different statements.
Something wrong or missing? Edit this page