Detect a Runtime Compromise with Falco: 5 Useful Rules and Zero Alert Noise
Your cluster has image scanning, admission control and NetworkPolicies. All of that is prevention. None of it tells you what a process actually did at 03:14 last night inside a running pod. That is runtime detection, and
Your cluster has image scanning, admission control and NetworkPolicies. All of that is prevention. None of it tells you what a process actually did at 03:14 last night inside a running pod. That is runtime detection, and on Kubernetes the default answer is Falco.
The problem: a fresh Falco install ships ~100 default rules tuned for "demo on a laptop", not for a 60-node OpenShift cluster running CI jobs, Istio sidecars and log shippers. Within an hour you get thousands of Read sensitive file untrusted and Contact K8S API Server From Container events. Two weeks later nobody reads the channel, and your detection capability is worth exactly zero.
This article takes the opposite approach: install Falco with the modern eBPF driver, disable most of the default ruleset, keep five rules that map to real attacker behaviour, tune them with explicit allowlists, and route the result to Alertmanager through Falcosidekick. The goal is a handful of alerts per week that a human will actually open.
Context: what Falco sees, and why it's loud
Falco is a userspace process (deployed as a DaemonSet, one pod per node) that consumes a stream of syscalls from the kernel — execve, openat, connect, ptrace and friends — enriches them with container and Kubernetes metadata, and evaluates them against rules written in a small boolean DSL.
Three driver options exist:
- modern eBPF (CO-RE) — recommended. Needs a kernel ≥ 5.8 with BTF. RHCOS on OpenShift 4.12+ qualifies. No kernel module, no compilation, no DKMS.
- legacy eBPF probe — downloaded/compiled per kernel version.
- kernel module — avoid on immutable OSes.
The noise comes from the fact that generic rules cannot know your workload. "A shell was opened in a container" is suspicious — unless it's your CI runner building images, in which case it fires 4000 times a day. Falco tuning is therefore not about writing clever rules; it's about writing precise exceptions for your own platform.
Installing Falco with the modern eBPF driver
Prerequisites
# Check kernel version and BTF availability on a node
oc debug node/worker-0 -- chroot /host uname -r
oc debug node/worker-0 -- chroot /host ls -l /sys/kernel/btf/vmlinux
If /sys/kernel/btf/vmlinux exists, modern_ebpf will work.
Helm values
# falco-values.yaml
driver:
enabled: true
kind: modern_ebpf
collectors:
crio:
enabled: true
socket: /var/run/crio/crio.sock
containerd:
enabled: false
docker:
enabled: false
# Content of falco.yaml
falco:
rules_files:
- /etc/falco/falco_rules.yaml
- /etc/falco/rules.d
json_output: true
json_include_output_property: true
buffered_outputs: true
# Global floor: anything below NOTICE is dropped before it reaches an output
priority: notice
# Token bucket: 0.1 alert/s sustained, burst of 10. Stops alert storms.
outputs:
rate: 0.1
max_burst: 10
syscall_event_drops:
actions:
- log
rate: 0.03333
max_burst: 10
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1
memory: 1Gi
tolerations:
- operator: Exists
Install it:
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
oc new-project falco
helm upgrade --install falco falcosecurity/falco \
--namespace falco \
--values falco-values.yaml \
--wait
OpenShift: grant the SCC
Falco needs privileged access to load the eBPF program and read /proc:
oc adm policy add-scc-to-user privileged -z falco -n falco
oc rollout restart daemonset/falco -n falco
Verify the driver is actually loaded
oc logs -n falco ds/falco -c falco | head -30
# Expect: "Falco initialized with configuration file ..."
# "Loading rules from file /etc/falco/falco_rules.yaml"
# "Starting health webserver with threadiness ..."
# "Enabled event sources: syscall"
# "Opening 'syscall' source with modern BPF probe."
oc get pods -n falco -o wide
If you see Opening 'syscall' source with Kernel module, your values were not applied.
Step 1: silence the default ruleset
Before adding anything, remove what you will never act on. Falco lets you disable rules by name or by tag at startup. Keep the default file loaded (it provides the shared macros and lists that your own rules reuse), but mute the chatty families:
# falco-values.yaml (append)
falco:
rules_files:
- /etc/falco/falco_rules.yaml
- /etc/falco/rules.d
# Disable entire noisy families by tag
disabled_tags:
- network
- users
# Re-enable nothing by default; our local file re-declares what we want
This is deliberately brutal. The five rules below are declared in your own file, so they survive. Everything else falls back to "logged at DEBUG, never emitted".
Step 2: five rules that are worth waking up for
Put them in falco_rules.local.yaml, the conventional override file. Two patterns are used:
-
override: condition: append— extend an existing default rule with exceptions (requires Falco ≥ 0.36). - a brand-new rule — when the default is too broad to salvage.
# falco_rules.local.yaml
- required_engine_version: 0x000028
#############################################
# Shared allowlists — the heart of the tuning
#############################################
- list: shell_allowed_images
items:
- registry.internal.example.com/ops/debug-toolbox
- quay.io/openshift/origin-cli
- list: build_namespaces
items: ["openshift-builds", "ci-runners", "tekton-pipelines"]
- list: etc_write_allowed_procs
items: [nginx, envoy, pilot-agent, update-ca-trust, dpkg, rpm]
- list: sa_token_readers
items: [kubectl, oc, kubelet, pilot-agent, envoy, fluent-bit, vector, otelcol, prometheus]
- list: egress_allowed_namespaces
items: ["openshift-monitoring", "openshift-logging", "egress-proxy"]
#############################################
# RULE 1 — Interactive shell in a container
# MITRE T1059 — Command and Scripting Interpreter
#############################################
- rule: Terminal shell in container
condition: >
and not container.image.repository in (shell_allowed_images)
and not k8s.ns.name in (build_namespaces)
override:
condition: append
#############################################
# RULE 2 — Write below /etc inside a container
# MITRE T1222 / T1098 — persistence & config tampering
#############################################
- rule: Write below etc in container
desc: A process inside a container opened a file under /etc for writing
condition: >
container
and evt.type in (open, openat, openat2, creat)
and evt.is_open_write = true
and fd.name startswith /etc
and not fd.name startswith /etc/nginx/conf.d
and not fd.name startswith /etc/pki/ca-trust
and not proc.name in (etc_write_allowed_procs)
and not k8s.ns.name in (build_namespaces)
output: >
Write below /etc in container
(file=%fd.name proc=%proc.cmdline ppid=%proc.pname uid=%user.uid
container=%container.name image=%container.image.repository
ns=%k8s.ns.name pod=%k8s.pod.name)
priority: ERROR
tags: [container, filesystem, persistence, T1222]
#############################################
# RULE 3 — ServiceAccount token read by an unexpected process
# MITRE T1552.001 — Credentials in files
#############################################
- rule: Service account token read by unexpected process
desc: Something other than the known K8s clients opened the projected SA token
condition: >
open_read
and container
and fd.name contains "secrets/kubernetes.io/serviceaccount"
and fd.name endswith "token"
and not proc.name in (sa_token_readers)
output: >
SA token read by unexpected process
(proc=%proc.cmdline exe=%proc.exepath uid=%user.uid
container=%container.name image=%container.image.repository
ns=%k8s.ns.name pod=%k8s.pod.name)
priority: WARNING
tags: [container, k8s, credentials, T1552]
#############################################
# RULE 4 — Egress to the public internet (exfiltration)
# MITRE T1041 — Exfiltration over C2 channel
#############################################
- macro: private_destination
condition: >
(fd.snet = "10.0.0.0/8" or fd.snet = "172.16.0.0/12"
or fd.snet = "192.168.0.0/16" or fd.snet = "169.254.0.0/16"
or fd.snet = "127.0.0.0/8")
- rule: Unexpected outbound connection from container
desc: A container connected to a public IP from a namespace that should only talk internally
condition: >
outbound
and container
and not private_destination
and not k8s.ns.name in (egress_allowed_namespaces)
and not container.image.repository in (shell_allowed_images)
output: >
Outbound connection to public IP from restricted namespace
(dest=%fd.sip:%fd.sport proto=%fd.l4proto proc=%proc.cmdline
container=%container.name image=%container.image.repository
ns=%k8s.ns.name pod=%k8s.pod.name)
priority: WARNING
tags: [container, network, exfiltration, T1041]
#############################################
# RULE 5 — A binary written at runtime is executed
# MITRE T1105 — Ingress tool transfer
#############################################
- rule: Drop and execute new binary in container
condition: >
and not k8s.ns.name in (build_namespaces)
override:
condition: append
Why these five
-
Interactive shell — nearly every hands-on-keyboard intrusion starts with
/bin/sh. Almost no production container legitimately spawns one. -
Write below /etc — covers cron persistence,
/etc/ld.so.preloadhijacking, CA trust tampering. A container filesystem should be immutable at runtime. - SA token read — the token is the pivot from "code execution in a pod" to "cluster API access". Reading it from a process that is not a Kubernetes client is a strong signal.
- Unexpected egress — exfiltration and C2 beaconing. This is the rule most likely to be noisy, which is exactly why it is scoped by namespace allowlist rather than applied cluster-wide.
-
Drop and execute —
proc.is_exe_upper_layer=truemeans the executed binary was written into the container's writable layer after the image was built. Crypto miners, reverse shells and kernel exploits all land here.
Note what is not in the list: "Contact K8S API Server From Container", "Read sensitive file untrusted", "Launch Privileged Container". They are useful on paper and unusable in practice without weeks of tuning.
Step 3: ship the rules and validate them
The Helm chart renders customRules into a ConfigMap mounted at /etc/falco/rules.d/:
# falco-values.yaml (append)
customRules:
falco_rules.local.yaml: |-
- required_engine_version: 0x000028
# ... paste the content above ...
helm upgrade --install falco falcosecurity/falco \
-n falco -f falco-values.yaml
# Syntax check from inside a running pod
oc exec -n falco ds/falco -c falco -- \
falco --validate /etc/falco/rules.d/falco_rules.local.yaml
# List the rules Falco actually loaded
oc exec -n falco ds/falco -c falco -- falco -L | grep -A1 "Terminal shell"
A malformed rule makes the pod crash-loop. Always validate before rolling the DaemonSet.
Step 4: route to Alertmanager with Falcosidekick
Falcosidekick is a small forwarder that receives Falco's JSON output over HTTP and fans it out to ~50 backends. Alertmanager is the right target when you already have an on-call rotation wired to it: you inherit grouping, inhibition, silences and escalation for free.
# falco-values.yaml (append)
falcosidekick:
enabled: true
replicaCount: 2
webui:
enabled: false
config:
debug: false
customfields: "cluster:prod-eu-west-1,env:production"
alertmanager:
hostport: "http://alertmanager-operated.monitoring.svc.cluster.local:9093"
endpoint: "/api/v2/alerts" # v1 was removed in Alertmanager 0.27
minimumpriority: "warning" # second filter, after falco.priority
checkcert: false
expireafter: "10m"
extralabels: "source:falco,team:security"
customseveritymap: "critical:critical,error:critical,warning:warning,notice:info"
Two filters now stack: Falco drops anything below notice at the source, Falcosidekick drops anything below warning before it reaches Alertmanager. Debug-level events remain in the pod logs for forensics without paging anyone.
On the Alertmanager side, Falcosidekick sends alertname=<rule name>, source=falco, severity=<mapped>, plus every Falco output field as a label (dots become underscores: k8s.ns.name → k8s_ns_name).
# alertmanager.yaml (excerpt)
route:
receiver: default
group_by: [alertname, cluster]
routes:
- matchers:
- source = "falco"
- severity = "critical"
receiver: security-oncall
group_by: [alertname, k8s_ns_name]
group_wait: 10s
group_interval: 5m
repeat_interval: 4h
- matchers:
- source = "falco"
receiver: security-slack
group_by: [alertname, k8s_ns_name]
group_wait: 60s
repeat_interval: 24h
inhibit_rules:
- source_matchers: [alertname = "Drop and execute new binary in container"]
target_matchers: [source = "falco"]
equal: [k8s_pod_name]
The inhibit rule matters: once a binary was dropped and executed in a pod, the follow-up shell/egress alerts for that same pod are noise. One incident, one page.
# Check your config before applying
amtool check-config alertmanager.yaml
# Watch what actually arrives
oc port-forward -n monitoring svc/alertmanager-operated 9093:9093
amtool alert query --alertmanager.url=http://localhost:9093 'source="falco"'
Step 5: prove it works
Never trust an untested detection pipeline.
oc new-project falco-test
oc run canary --image=registry.access.redhat.com/ubi9/ubi:latest -- sleep 3600
oc wait --for=condition=Ready pod/canary -n falco-test
# Rule 1: interactive shell
oc exec -it -n falco-test canary -- /bin/bash -c "echo hello"
# Rule 2: write below /etc
oc exec -n falco-test canary -- bash -c 'echo "* * * * * root id" >> /etc/crontab'
# Rule 3: SA token read
oc exec -n falco-test canary -- cat /var/run/secrets/kubernetes.io/serviceaccount/token
# Rule 5: drop and execute
oc exec -n falco-test canary -- bash -c 'cp /bin/echo /tmp/notecho && /tmp/notecho pwned'
# Observe
oc logs -n falco ds/falco -c falco --tail=50 | jq -r '.rule + " | " + .output'
oc logs -n falco deploy/falco-falcosidekick --tail=20
For a broader smoke test, the upstream event-generator replays known attack patterns:
oc run event-generator -n falco-test --rm -it \
--image=falcosecurity/event-generator:latest \
--restart=Never -- run syscall --loop
Clean up afterwards: oc delete project falco-test.
Security best practices
-
Protect the pipeline itself. Falco runs privileged. Restrict the
falconamespace with a dedicated SCC, forbidoc execinto it via RBAC, and ship Falco's own logs off-cluster — an attacker with node access will try to kill the DaemonSet first. Alert onfalcopod count dropping below node count with a Prometheus rule. -
Version the rules in Git, not in
helm --set. Every change tofalco_rules.local.yamlis a change to your detection coverage. It deserves a PR, a reviewer, and thefalco --validatestep in CI. -
Allowlist by image digest or namespace, never by process name alone.
proc.name in (nginx)is trivially bypassed by renaming a binary. Combine withcontainer.image.repositoryorproc.exepath. -
Watch for dropped events.
syscall_event_dropsin the logs means Falco could not keep up and you have blind spots. Scrape the Falco metrics endpoint and alert onfalco_n_drops_totalincreasing. -
Make containers immutable. Set
readOnlyRootFilesystem: truewherever possible — rules 2 and 5 then become near-zero-false-positive, because any write is already abnormal. - Review exceptions quarterly. An allowlist entry added for a debug session in March is an attacker's open door in November.
- Keep raw events longer than alerts. Alertmanager is for paging; send the full JSON stream to Loki/Elasticsearch for incident response and threat hunting.
Conclusion
Runtime detection fails for organisational reasons far more often than technical ones. A Falco install that emits 3000 events a day is not "more secure" than no Falco at all — it is strictly worse, because it creates the illusion of coverage.
Key takeaways:
- Deploy with
driver.kind: modern_ebpfon any kernel ≥ 5.8 with BTF: no module, no compilation, no breakage on node upgrades. - Start by disabling the default ruleset, then add back only rules you have a response procedure for. Five well-tuned rules beat a hundred ignored ones.
- Tune with explicit lists and macros in
falco_rules.local.yaml, usingoverride: condition: appendfor built-in rules and custom rules when the default is unsalvageable. - Stack filters:
falco.priority,outputs.rate, andminimumpriorityin Falcosidekick — then let Alertmanager handle grouping, inhibition and escalation. -
Test your detections with a canary pod and
event-generatoron every rule change, exactly as you would test application code.
Once the five rules are quiet in steady state, add a sixth. Then a seventh. Growing a ruleset from a silent baseline is sustainable; pruning one from a screaming baseline never happens.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.