Ik scande mijn images met Trivy. Ik dwong policies af met Kyverno. Mijn workloads kregen cryptografische identiteit via SPIFFE. Drie lagen preventie, allemaal groen, en een tijd lang voelde dat als genoeg.
Toen begon ik de ongemakkelijke vraag te stellen. Wat gebeurt er nadat een pod draait? Mijn scanners controleerden de image die erin ging. Mijn admission controller controleerde de spec bij deployment. Geen van beide kijkt nog mee zodra het proces daadwerkelijk draait. Als een container om 3 uur ’s nachts wordt gepakt door een zero-day, hebben al die controls hun werk al gedaan en zijn ze naar huis.
Dat gat zat me dwars. Ik kan niet beweren dat ik begrijp wat mijn cluster doet als ik geen idee heb wat de processen erin daadwerkelijk uitvoeren. Preventie vertelt me wat waar zou moeten zijn. Ik wilde iets dat me vertelt wat waar is, nu, syscall voor syscall.
Dat iets is Falco. Het kijkt naar de system calls die elke container maakt - bestandstoegang, netwerkverbindingen, processen die starten - en slaat alarm wanneer het gedrag niet matcht met de rules die ik gaf. Deze post bouwt het op zoals ik het zelf leerde: eerst de kleinste bruikbare versie, dan echte rules, dan de productie-config die ik daadwerkelijk draai.
Het gat dat preventie laat liggen
Ik denk over clusterbeveiliging als drie momenten in de tijd:
- Build time - scan images op bekende kwetsbaarheden (Trivy)
- Deploy time - dwing policies af op de spec (Kyverno, admission controllers)
- Runtime - kijk wat de workload daadwerkelijk doet (Falco)
De meeste setups, de mijne lang ook, stoppen na de eerste twee. Ze zijn goedkoper, passen netjes in de pipeline, en zetten een dashboard op groen. Het probleem is dat een aanvaller die al binnen een draaiende container zit jouw pipeline niet opnieuw afspeelt. Die werkt met wat draait.
Dit is het soort gedrag dat de eerste twee lagen nooit zien:
- Een container die ineens
/bin/bashuitvoert in productie, waar niets ooit een shell zou moeten starten - Een proces dat
/etc/shadowleest - Een uitgaande verbinding naar een crypto mining pool
- Iets dat in
/etc/schrijft - Kubernetes service account tokens die gelezen worden vanuit een pod die er niets te zoeken heeft
Niets daarvan duikt op in een scan of een admission check, want niets ervan gaat over configuratie. Het gaat over gedrag. Falco vangt het omdat gedrag het enige is waar het naar kijkt.
De simpelste versie: syscalls bekijken
Voordat er ook maar één rule is, begrijp het ene dat Falco doet. Het zit onder je containers en leest hun system calls, en matcht elke call tegen een set condities. eBPF (of een kernel module) is hoe het die plek bemachtigt:
flowchart TD
subgraph userspace["User Space"]
A["Container A"]
B["Container B"]
C["Container C"]
end
subgraph kernel["Kernel Space"]
EBPF["eBPF / Kernel Module<br/>(intercepts syscalls)"]
end
A --> EBPF
B --> EBPF
C --> EBPF
EBPF --> FALCO["Falco<br/>(rules engine)"]
FALCO --> ALERTS["Alerts<br/>(stdout, webhook, Kafka...)"]
Elke syscall stroomt door de rules engine. Een match wordt een alert. Dat is het hele model, en zodra dat klikt is de rest van Falco alleen maar detail dat er bovenop ligt.
Falco installeren
Ik begin met de Helm chart en de eBPF driver. eBPF voorkomt dat je een kernel module in elke node moet laden, wat uitmaakt op Talos en andere dichtgetimmerde hosts:
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
helm install falco falcosecurity/falco \
--namespace falco \
--create-namespace \
--set driver.kind=ebpf \
--set falcosidekick.enabled=true
Ik draai helm install alleen niet met de hand. Alles in mijn cluster gaat via Git, dus de echte versie is een ArgoCD Application die naar dezelfde chart wijst:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: falco
namespace: argocd
spec:
project: default
source:
repoURL: https://falcosecurity.github.io/charts
chart: falco
targetRevision: 4.0.0
helm:
values: |
driver:
kind: ebpf
falcosidekick:
enabled: true
config:
slack:
webhookurl: "https://hooks.slack.com/services/xxx"
tty: true
destination:
server: https://kubernetes.default.svc
namespace: falco
syncPolicy:
automated:
prune: true
selfHeal: true
Laag een: een rule lezen
Falco is alleen zo goed als de rules die het draait. Een rule is een klein blok YAML, en zodra je er één kunt lezen kun je ze allemaal lezen. Hier is het canonieke voorbeeld, een shell die start in een container:
- rule: Terminal shell in container
desc: Detect shell being spawned in a container
condition: >
spawned_process and
container and
shell_procs and
proc.tty != 0
output: >
Shell spawned in container
(user=%user.name container=%container.name shell=%proc.name
parent=%proc.pname cmdline=%proc.cmdline)
priority: WARNING
tags: [container, shell, mitre_execution]
Vier onderdelen doen al het werk:
- condition bepaalt wanneer het afgaat, geschreven in Falco’s filtertaal
- output is wat in de alert terechtkomt, met
%-velden die op runtime worden ingevuld - priority is de severity, van DEBUG tot CRITICAL
- tags laten je groeperen en filteren, hier gekoppeld aan MITRE technique-categorieën
Lees die condition hardop en het is bijna Nederlands: een proces is gestart, in een container, het proces is een shell, en er hangt een TTY aan. Dat is de grammatica die elke andere rule hergebruikt.
Ingebouwde rules
Je begint niet vanaf een leeg bestand. Falco levert een grote set community rules die de meeste voor de hand liggende aanvallen al afdekt:
# Bekijk geladen rules
kubectl exec -n falco -it falco-xxx -- falco --list
Ze dekken de categorieën die je verwacht: container escape pogingen, privilege escalation, gevoelige bestandstoegang, verdachte netwerkactiviteit, Kubernetes API-misbruik. Een paar voorbeelden, zodat je ziet dat ze hetzelfde lezen als die hierboven:
# Detecteer container escape via mount
- rule: Launch Sensitive Mount Container
condition: >
spawned_process and container and
sensitive_mount
# Detecteer lezen van gevoelige bestanden
- rule: Read sensitive file untrusted
condition: >
open_read and sensitive_files and
not trusted_containers
# Detecteer crypto mining
- rule: Detect outbound connections to crypto mining pools
condition: >
outbound and
fd.sip.name in (cryptomining_pool_domains)
Laag twee: rules die je app kennen
De defaults vangen generieke aanvallen. Ze hebben geen idee wat jouw applicatie hoort te doen. Die kennis is van jou, en die als rule opschrijven is waar Falco zijn geld begint te verdienen:
# custom-rules.yaml
customRules:
rules-custom.yaml: |-
# Alert wanneer onze API server onverwachte processen spawnt
- rule: Unexpected process in api-server
desc: Detect processes other than the main app in api-server containers
condition: >
spawned_process and
container.image.repository contains "api-server" and
not proc.name in (api-server, node, npm)
output: >
Unexpected process in api-server
(command=%proc.cmdline container=%container.name image=%container.image.repository)
priority: WARNING
# Alert op database verbinding van onverwachte pods
- rule: Database connection from non-backend pod
desc: Detect connections to PostgreSQL from pods that shouldn't connect
condition: >
outbound and
fd.sport = 5432 and
not k8s.pod.label.app in (api-server, worker, migration-job)
output: >
Unexpected database connection
(pod=%k8s.pod.name namespace=%k8s.ns.name dest=%fd.sip)
priority: ERROR
Deploy custom rules via Helm values:
# values.yaml
customRules:
rules-custom.yaml: |-
- rule: My custom rule
...
De filtertaal, zodat de rules geen magie meer zijn
Ik kopieer niet graag YAML die ik niet kan lezen, en jij hoort dat ook niet te doen. Zodra je de handvol veldklassen kent en weet hoe macros en lijsten ze aan elkaar lijmen, houdt elke rule hierboven op een black box te zijn.
Fields
# Process fields
proc.name # Proces naam
proc.cmdline # Volledige command line
proc.pname # Parent proces naam
proc.exepath # Executable pad
# Container fields
container.name # Container naam
container.id # Container ID
container.image.repository # Image naam
# File fields
fd.name # File descriptor naam (bestandspad)
fd.directory # Directory van het bestand
# Network fields
fd.sip # Server IP
fd.sport # Server poort
fd.cip # Client IP
# Kubernetes fields
k8s.pod.name # Pod naam
k8s.ns.name # Namespace
k8s.pod.label.app # Pod label
Macros
Macros zijn benoemde, herbruikbare conditiefragmenten. Elke rule hierboven leunde erop zonder dat je het merkte:
- macro: container
condition: container.id != host
- macro: shell_procs
condition: proc.name in (bash, sh, zsh, dash, ksh)
- macro: spawned_process
condition: evt.type = execve and evt.dir = <
- macro: open_read
condition: evt.type in (open, openat) and evt.is_open_read = true
Lijsten
Lijsten zijn benoemde sets van waarden waar je naar verwijst in plaats van ze te herhalen:
- list: sensitive_files
items: [/etc/shadow, /etc/sudoers, /etc/pam.d, /root/.ssh]
- list: package_managers
items: [apt, apt-get, yum, dnf, apk, pip, npm]
- list: shell_binaries
items: [bash, sh, zsh, csh, tcsh, ksh, dash]
Alles samen: rules die hun alert verdienen
Met de grammatica in handen, hier zijn drie detecties die ik de ruis echt waard vind. Dit zijn degene die matchen met hoe een aanvaller zich daadwerkelijk gedraagt zodra die binnen is.
Reverse shells
- rule: Reverse shell detected
desc: Detect reverse shells via common patterns
condition: >
spawned_process and
container and
((proc.name = bash and proc.args contains ">&" and proc.args contains "/dev/tcp") or
(proc.name = nc and proc.args contains "-e") or
(proc.name = python and proc.args contains "socket" and proc.args contains "subprocess"))
output: >
Reverse shell detected (user=%user.name command=%proc.cmdline container=%container.name)
priority: CRITICAL
tags: [mitre_execution, reverse_shell]
Kubernetes secret-toegang
- rule: K8s secret accessed from unexpected namespace
desc: Detect when secrets are read from non-standard locations
condition: >
open_read and
fd.name startswith "/var/run/secrets/kubernetes.io" and
not k8s.ns.name in (kube-system, monitoring, vault)
output: >
K8s secret accessed (file=%fd.name pod=%k8s.pod.name namespace=%k8s.ns.name)
priority: WARNING
Package-installaties op runtime
- rule: Package manager in container
desc: Detect package installations in running containers
condition: >
spawned_process and
container and
proc.name in (apt, apt-get, yum, dnf, apk, pip, npm) and
not container.image.repository in (allowed_builder_images)
output: >
Package manager run in container
(command=%proc.cmdline container=%container.name image=%container.image.repository)
priority: ERROR
tags: [mitre_persistence, package_install]
Een package manager die opstart in een draaiende container is een sterk signaal dat iemand een voet aan de grond bouwt, dus behandel ik het als ERROR en niet als warning.
Alerts ergens krijgen waar je ze ziet
Iets detecteren is nutteloos als de alert sterft in de stdout van een pod. Standaard is dat precies waar Falco naartoe schrijft. Falcosidekick is het stuk dat die alerts uitwaaiert naar waar je daadwerkelijk kijkt:
falcosidekick:
enabled: true
config:
slack:
webhookurl: "https://hooks.slack.com/services/xxx"
minimumpriority: warning
prometheus:
enabled: true
elasticsearch:
hostport: "elasticsearch.logging:9200"
index: "falco"
alertmanager:
hostport: "http://alertmanager.monitoring:9093"
webhook:
address: "http://security-responder.security:8080/falco"
Die config stuurt warnings en hoger naar Slack, elke alert als metric naar Prometheus, alles naar Elasticsearch zodat ik historie kan doorzoeken, criticals naar Alertmanager voor paging, en de volledige stroom naar een custom webhook die ik gebruik voor geautomatiseerde response. Eén detectie, meerdere doelgroepen, elk gefilterd op de severity die het aangaat.
Falco in Prometheus vouwen
Ik draai Prometheus en Grafana al voor al het andere, dus ik wil security-events op dezelfde plek waar ik de rest van het cluster bekijk. Falcosidekick stelt metrics beschikbaar, en een ServiceMonitor is genoeg om ze te scrapen:
# ServiceMonitor
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: falco-sidekick
namespace: monitoring
spec:
selector:
matchLabels:
app.kubernetes.io/name: falcosidekick
endpoints:
- port: http
Vanaf daar maakt een PrometheusRule van een Falco-detectie hetzelfde soort alert als een falende disk of een hete CPU:
# PrometheusRule
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: falco-alerts
spec:
groups:
- name: falco
rules:
- alert: FalcoCriticalAlert
expr: increase(falco_events{priority="Critical"}[5m]) > 0
for: 0m
labels:
severity: critical
annotations:
summary: "Falco critical security event detected"
description: "{{ $labels.rule }} triggered in {{ $labels.k8s_ns_name }}/{{ $labels.k8s_pod_name }}"
Het deel waar niemand je voor waarschuwt: ruis
Hier is de eerlijke trade-off. Out of the box pagen de default rules je voor dingen die in jouw cluster volkomen normaal zijn. De eerste week dat ik Falco draaide werd ik wakker gemaakt door mijn eigen config-reload sidecar. Een alerting-tool die je geleerd hebt te negeren is erger dan geen tool, dus afstemmen is niet optioneel, het is het eigenlijke werk.
Er zijn twee zetten: rules uitschakelen die niet passen, en de macros versmallen waar ze van afhangen.
Rules uitschakelen en overschrijven
# values.yaml
falco:
rules_file:
- /etc/falco/falco_rules.yaml
- /etc/falco/falco_rules.local.yaml # Overrides
- /etc/falco/rules.d # Custom rules
customRules:
falco_rules.local.yaml: |-
# Schakel luidruchtige rule uit
- rule: Terminal shell in container
enabled: false
# Wijzig bestaande rule om bepaalde containers uit te sluiten
- rule: Read sensitive file untrusted
condition: >
open_read and sensitive_files and
not trusted_containers and
not container.image.repository in (my-trusted-app)
Macros afstemmen
customRules:
falco_rules.local.yaml: |-
# Voeg toe aan trusted containers
- macro: trusted_containers
condition: >
(container.image.repository in (
my-company/trusted-app,
my-company/another-app
))
# Sluit namespaces uit van monitoring
- macro: user_namespace
condition: >
k8s.ns.name not in (kube-system, falco, monitoring)
De trusted_containers macro aanpassen is beter dan een rule helemaal uitschakelen, want de rule beschermt dan nog steeds elke andere container. Je leert Falco hoe normaal eruitziet in jouw omgeving in plaats van het blind te maken.
Het volledige plaatje: mijn productie-config
Dit is alles op elkaar gestapeld: de eBPF driver, JSON output, getrapte alerting, en een paar rules die vastleggen wat mijn eigen workloads mogen doen. Vergelijk het met de éénregelige helm install bovenaan en je ziet de hele trap die je net beklommen hebt.
driver:
kind: ebpf
ebpf:
hostNetwork: true
falco:
grpc:
enabled: true
grpc_output:
enabled: true
json_output: true
log_level: info
rules_file:
- /etc/falco/falco_rules.yaml
- /etc/falco/falco_rules.local.yaml
- /etc/falco/rules.d
falcosidekick:
enabled: true
replicaCount: 2
config:
slack:
webhookurl: "${SLACK_WEBHOOK}"
minimumpriority: error
prometheus:
enabled: true
alertmanager:
hostport: "http://alertmanager.monitoring:9093"
minimumpriority: critical
customRules:
rules-custom.yaml: |-
# Onze applicatie-specifieke rules
- rule: Unexpected outbound connection from backend
desc: Backend pods should only connect to known services
condition: >
outbound and
k8s.pod.label.app = "backend" and
not fd.sip.name in (postgres.db, redis.cache, api.internal)
output: >
Backend made unexpected outbound connection
(pod=%k8s.pod.name dest=%fd.sip:%fd.sport)
priority: WARNING
falco_rules.local.yaml: |-
# Stem default rules af voor onze omgeving
- macro: user_known_write_etc_conditions
condition: >
(container.image.repository = "my-company/config-manager")
De reden achter elke keuze: eBPF boven de kernel module omdat het beter presteert en de host schoner houdt, JSON output omdat downstream tools het parsen zonder regex-gymnastiek, getrapte alerting zodat errors Slack porren terwijl criticals iemand wakker pagen, en custom rules omdat alleen ik weet waar mijn backend wel en niet mee mag praten.
De lus sluiten met geautomatiseerde response
Falco detecteert alleen. Wat daarna gebeurt is jouw keuze. Voor de meeste rules wil ik dat een mens kijkt, maar voor een CRITICAL vind ik het prima om het cluster zichzelf te laten verdedigen. De webhook-ontvanger uit de routing-config is waar dat leeft:
# security-responder deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: security-responder
spec:
template:
spec:
containers:
- name: responder
image: my-company/security-responder
env:
- name: SLACK_WEBHOOK
valueFrom:
secretKeyRef:
name: security-responder
key: slack-webhook
De ontvanger zelf kan klein zijn. Kill de betreffende pod, laat de Deployment hem herstellen, en post naar Slack zodat een mens weet dat het gebeurd is:
@app.route('/falco', methods=['POST'])
def handle_falco_alert():
alert = request.json
if alert['priority'] == 'Critical':
# Kill de pod direct
pod = alert['output_fields']['k8s.pod.name']
namespace = alert['output_fields']['k8s.ns.name']
# Delete pod (Deployment zal hem herstellen)
v1.delete_namespaced_pod(pod, namespace)
# Notificeer
send_slack(f"Pod {namespace}/{pod} gekilld vanwege: {alert['rule']}")
return 'ok'
Waarom ik me hier druk om maak
Een pod automatisch killen op één enkele alert is scherp gereedschap, en ik ben er voorzichtig mee. Een ruizige rule plus een geautomatiseerde response is hoe je jezelf DoS’t. Dus ik koppel automatisering alleen aan rules die ik lang genoeg in productie heb zien draaien om te vertrouwen, en al het andere paget gewoon een mens.
Stap weg van de YAML en de reden dat ik Falco draai is dezelfde reden dat ik de rest van mijn stack zelf host. Ik wil begrijpen wat mijn infrastructuur doet, niet aannemen. Trivy en Kyverno vertellen me wat mijn cluster zou moeten zijn. Falco vertelt me wat het is. Zonder die laatste laag is het eerlijke antwoord op “wat draait er nu in je cluster” iets als “wat de image drie weken geleden zei, waarschijnlijk”.
Dat gat tussen zou en is, tussen de spec en het draaiende proces, is precies waar aanvallers werken. Het dichten betekent niet dat mijn preventie faalde. Het betekent dat ik gestopt ben met doen alsof preventie het hele verhaal is.
