Detection engineering
Writing, testing and shipping detection content against OCSF fields, with ATT&CK mapping enforced by the pipeline.
Detection content in Falak is written against OCSF attribute paths — the same paths the sensor emits and the same paths the hunt query language addresses. A hunt that finds something becomes a rule without re-expressing it.
The rule shape
Falak reads sigma-family YAML with a product: falak logsource:
title: Credential File Access by Unexpected Process
id: c04a9f18-3b7e-4e26-9f00-a1d2c5b7e903
status: stable
description: >
A process outside the authentication stack opened /etc/shadow for reading.
author: Falak Neo Detection Engineering
date: 2025/03/11
logsource:
product: falak
category: file_activity
detection:
selection:
class_uid: 1001
activity_id: 2
file.path:
- '/etc/shadow'
- '/etc/gshadow'
filter_auth:
actor.process.name:
- sshd
- systemd-logind
- unix_chkpwd
- passwd
condition: selection and not filter_auth
falsepositives:
- Configuration management reading shadow during convergence
- Backup agents with full-filesystem scope
level: high
tags:
- attack.credential_access
- attack.t1003.008The ATT&CK tag is not optional
A rule with no attack.tXXXX tag is rejected at install time. This is Law 5, and it is enforced by the loader rather than by review. The reason is measurement: a coverage matrix is only meaningful if every piece of content contributes to it.
Correlation
Single-event rules catch the obvious. Most real detection is a relationship between two events.
Sequence within a window
detection:
chmod_setuid:
class_uid: 1001
activity_id: 6
file.mode|startswith: '04'
exec_it:
class_uid: 1007
activity_id: 1
process.is_setuid: true
condition: chmod_setuid and exec_it within 300sCorrelation is keyed on device.uid by default. Add by actor.process.uid to require the same process, or by device.k8s.pod_name to require the same pod.
Threshold
detection:
failures:
class_uid: 3002
activity_id: 1
status_id: 2
condition: failures | count() by src_endpoint.ip > 20 within 10mBaseline deviation
detection:
egress:
class_uid: 4001
network_activity.egress_to_internet: true
condition: >
egress and dst_endpoint.autonomous_system.number
not in baseline("asn", 30d) by device.k8s.workload_namebaseline() is computed per tenant, per grouping key, over a rolling window. A workload that has spoken to an ASN before does not fire; a workload that has not, does.
Field paths
The fields available are exactly the OCSF attributes for the classes in the selection. A few worth knowing:
| Path | Class | Notes |
|---|---|---|
process.cmd_line | 1007 | Full argv, space-joined |
process.file.hashes.sha256 | 1007 | Hash of the executed image |
process.is_setuid | 1007 | Executable carries the setuid bit |
exec_from_memfd | 1007 | Falak addition — fileless execution |
actor.process.name | all | The parent — this is what most filters key on |
actor.user.uid_num | all | Numeric Linux uid; 0 is root |
file.path | 1001 | Full resolved path |
path_class | 1001 | Sensor-resolved sensitivity class |
dst_endpoint.port | 4001 | Destination port |
egress_to_internet | 4001 | Falak addition |
crossed_pod_boundary | 4001 | Falak addition |
query.hostname | 4003 | Queried name |
syscall | 1003, 4001 | bpf, ptrace, connect, accept4 |
security_context.privileged | 990001 | Container started privileged |
mounts.is_sensitive | 990001 | A mount matched the sensitive list |
device.k8s.namespace | all | Namespace of the workload |
The full catalog is in the console under Hunt, filterable, with a one-line description per field.
Modifiers
| Modifier | Meaning |
|---|---|
|contains | Substring match |
|startswith, |endswith | Prefix / suffix |
|re | Regular expression (RE2) |
|gte, |lte, |gt, |lt | Numeric comparison |
|exists | Field present and non-empty |
|cidr | IP within a CIDR |
|base64offset|contains | Match inside a base64-encoded value |
Testing before you ship
# Replay 7 days of tenant events against a rule without emitting findings.
falakctl rules test ./credential-file-access.yml --window 7d
# Rule: Credential File Access by Unexpected Process
# Matches: 14 over 7d (avg 2.0/day)
# ip-10-40-118-14 ansible-playbook /etc/shadow ×11
# vault-primary cat /etc/shadow ×3
#
# Suggested filter (would remove 11 matches):
# actor.process.name: ansible-playbookBacktesting is the single highest-value habit in detection engineering. Two matches a day across a 48-node fleet is shippable. Two hundred is a rule that will be muted within a week and then forgotten while still counting toward your coverage number.
Fast-path rules
A rule that must act in milliseconds becomes a LocalRule in the sensor's config:
local:
enabled: true
action: kill # alert | kill | block | log
expression: >
class_uid == 1007 && activity_id == 1 && exec_from_memfd == trueConstraints: single-event only (no correlation), fields limited to what the sensor has locally, and block requires BPF LSM. Deploy with action: alert first.
Lifecycle
| Status | Meaning |
|---|---|
experimental | Under evaluation; counts toward coverage but not toward SLAs |
testing | Enabled, findings suppressed from paging integrations |
stable | Enabled and paging |
deprecated | Superseded; kept for historical query compatibility |
Promote deliberately. A rule that goes straight from written to paging is how alert fatigue starts.
Tuning without disabling
When a rule fires on legitimate behaviour, the instinct is to disable it. Resist that — a disabled rule is a coverage hole that nobody remembers creating.
Instead, add a scoped filter that names the legitimate behaviour:
filter_cm:
actor.process.name: ansible-playbook
actor.user.name: svc-ansible
device.labels.env: productionThree conjoined conditions, not one. actor.process.name: ansible-playbook alone would let anything named ansible-playbook through — which is a rename away from being a bypass.
Something wrong or missing? Edit this page