Skip to content
Detection

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.008

The 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 300s

Correlation 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 10m

Baseline 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_name

baseline() 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:

PathClassNotes
process.cmd_line1007Full argv, space-joined
process.file.hashes.sha2561007Hash of the executed image
process.is_setuid1007Executable carries the setuid bit
exec_from_memfd1007Falak addition — fileless execution
actor.process.nameallThe parent — this is what most filters key on
actor.user.uid_numallNumeric Linux uid; 0 is root
file.path1001Full resolved path
path_class1001Sensor-resolved sensitivity class
dst_endpoint.port4001Destination port
egress_to_internet4001Falak addition
crossed_pod_boundary4001Falak addition
query.hostname4003Queried name
syscall1003, 4001bpf, ptrace, connect, accept4
security_context.privileged990001Container started privileged
mounts.is_sensitive990001A mount matched the sensitive list
device.k8s.namespaceallNamespace of the workload

The full catalog is in the console under Hunt, filterable, with a one-line description per field.

Modifiers

ModifierMeaning
|containsSubstring match
|startswith, |endswithPrefix / suffix
|reRegular expression (RE2)
|gte, |lte, |gt, |ltNumeric comparison
|existsField present and non-empty
|cidrIP within a CIDR
|base64offset|containsMatch 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-playbook

Backtesting 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 == true

Constraints: single-event only (no correlation), fields limited to what the sensor has locally, and block requires BPF LSM. Deploy with action: alert first.

Lifecycle

StatusMeaning
experimentalUnder evaluation; counts toward coverage but not toward SLAs
testingEnabled, findings suppressed from paging integrations
stableEnabled and paging
deprecatedSuperseded; 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: production

Three 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