Skip to content
Sensor

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.

ProbeFeedsNotes
sched_process_exec1007 LaunchSource of memfd, tmpfs and deleted-binary provenance
sched_process_exit1007 TerminateExit code and signal
sys_enter_connect4001Outbound; sets syscall: connect
sys_enter_accept44001Inbound; distinguishes listeners from egress
file_open1001LSM where available, kprobe fallback; resolves path_class
bpf1003The probe that protects the detection
ptrace1003Process injection and memory access
module_load1005LKM loads, rootkit signal
dns4003Parsed 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" } }
ProbeParameterDefaultMeaning
file_openmax_path_len512Bytes of path resolved in-kernel
file_opendedupe_window_ms2000Suppress identical (pid, path, op) within the window
file_openinclude_onlyfalseEmit only paths under include_path_prefixes
dnsparse_answerstrueParse answer records, not just queries
dnsmax_answers8Cap answers per event
sys_enter_connectskip_loopbacktrueSuppress 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.01.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_prefixes covers 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_idBehaviour
1Alert — emit with elevated severity
2Kill — SIGKILL the process group, then emit
3Block — deny the operation (requires BPF LSM)
4Log — 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

  1. An administrator publishes a policy in the console.
  2. Core compiles it, increments generation, and signs the serialized config.
  3. Sensors on the WatchConfig stream receive it.
  4. 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.
  5. The config is applied atomically — probes are re-attached as a set, never partially.
  6. The sensor reports applied_config_generation on 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