TL;DR Trivy en Kyverno keuren een image vóór hij draait. Falco kijkt wat de container daarna doet, syscall voor syscall, en slaat alarm als het gedrag niet bij een rule past. Deze post gaat van het ene ding dat Falco doet naar de config die ik draai, met het ruisprobleem ertussenin.
Ik scande m’n images met Trivy. Ik dwong policies af met Kyverno. Mijn workloads kregen cryptografische identiteit via SPIFFE. Drie lagen preventie, allemaal groen, en een tijdlang voelde dat als genoeg.
Toen vroeg ik me af wat er gebeurt nadat een pod draait. Mijn scanner keek naar de image die erin ging. Mijn admission controller keek naar de spec bij het deployen. Zodra het proces draait, kijkt geen van beide nog mee. Een zero-day die in een draaiende container landt, komt binnen als al die controls al klaar zijn.
Dat zat me meer dwars dan ik graag toegeef. Ik kan niet beweren dat ik mijn cluster begrijp als ik geen idee heb wat de processen erin uitvoeren. Preventie vertelt me wat waar zou moeten zijn. Ik wilde iets dat me vertelt wat wáár is, nu.
Dat iets is Falco. Het kijkt naar de system calls die elke container doet, bestandstoegang, netwerkverbindingen, processen die starten, en slaat alarm als het gedrag niet past bij de rules die ik het gaf. Zo heb ik het zelf geleerd. Eerst het ene ding dat het doet, dan een rule, dan mijn eigen rules, en dan wat ik draai en hoeveel daarvan tunen is.
Het gat dat preventie laat liggen
Clusterbeveiliging zie ik als drie momenten. Build time, waar Trivy de image scant op bekende kwetsbaarheden. Deploy time, waar Kyverno en de andere admission controllers de spec keuren. En runtime, waar iets moet kijken wat de workload doet. Falco is dat derde moment.
De meeste setups stoppen na de eerste twee, en de mijne lang ook. Goedkoper, past in de pipeline, en het dashboard wordt groen. Alleen speelt een aanvaller die al in een draaiende container zit jouw pipeline niet opnieuw af. Die werkt met wat er draait.
Dit is het soort gedrag dat die eerste twee momenten 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
Niks daarvan duikt op in een scan of een admission check, want niks ervan is configuratie. Het is gedrag, en gedrag is het enige waar Falco naar kijkt.
Eén ding, vóór er een rule is
Falco zit onder je containers, leest hun system calls en legt elke call langs een set condities. eBPF (of een kernelmodule) is hoe het die plek krijgt:
Elke syscall gaat door de rules engine. Een match wordt een alert. Dat is het hele model. De rest van Falco is detail daarbovenop, en zodra dit plaatje in je hoofd zit leest dat detail vanzelf.
Installeren
Ik begin met de Helm -chart en de eBPF-driver. eBPF betekent geen kernelmodule op elke node, en dat maakt uit 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
helm install draai ik 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
Een rule lezen
Falco is zo goed als z’n rules en geen haar beter. Een rule is een klein blok YAML, en kun je er één lezen, dan kun je ze allemaal lezen. 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 het werk. De condition bepaalt wanneer hij afgaat, in Falco’s filtertaal. De output is wat in de alert komt, met de %-velden ingevuld op runtime. De priority is de severity, van DEBUG tot CRITICAL. De tags groeperen en filteren, hier gekoppeld aan MITRE-technique-categorieën.
Lees die condition hardop en het is bijna een gewone zin. Een proces is gestart, in een container, het proces is een shell, en er hangt een TTY aan. Elke andere rule hergebruikt die grammatica.
De rules die meegeleverd worden
Je begint niet met een leeg bestand. Falco levert een flinke set community rules mee die de voor de hand liggende aanvallen al afdekken:
# Bekijk geladen rules
kubectl exec -n falco -it falco-xxx -- falco --list
Container escapes, privilege escalation, gevoelige bestanden lezen, verdacht netwerkverkeer, misbruik van de Kubernetes-API. Een paar, zodat je ziet dat ze net zo 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)
Rules die je app kennen
De defaults vangen generieke aanvallen. Wat jouw applicatie hoort te doen, weten ze niet. Die kennis heb jij, en die opschrijven is waar Falco interessant wordt:
# 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
Custom rules gaan er via Helm-values in:
# values.yaml
customRules:
rules-custom.yaml: |-
- rule: My custom rule
...
De filtertaal
YAML kopiëren die ik niet kan lezen, daar heb ik een hekel aan, en ik hoop jij ook. Er is een handvol veldklassen, plus macros en lijsten om ze aan elkaar te lijmen. Ken die, en elke rule hierboven is geen black box meer.
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 stukken conditie die je hergebruikt. Elke rule tot nu toe leunde erop zonder het te zeggen:
- 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 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]
Drie rules die hun alert waard zijn
Met de grammatica in de hand, dit zijn de drie detecties die ik de ruis waard vind. Ze passen bij wat een aanvaller doet 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-secrets lezen
- 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 betekent dat iemand een voet tussen de deur zet, of op z’n best dat een developer in productie aan het debuggen is. Allebei wil ik weten, dus het is een ERROR en geen warning.
De alerts ergens krijgen waar je ze ziet
Iets detecteren heeft geen zin als de alert sterft in de stdout van een pod, en standaard schrijft Falco precies daarheen. Falcosidekick waaiert de alerts uit naar waar je wél 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 voor de historie, criticals naar Alertmanager om te pagen, en de hele stroom naar een webhook voor geautomatiseerde response. Eén detectie, en elke bestemming krijgt de severity die haar aangaat.
Falco in Prometheus vouwen
Prometheus en Grafana draai ik al voor al het andere, dus ik wil security-events op de plek waar ik de rest van het cluster bekijk. Falcosidekick stelt metrics beschikbaar, en een ServiceMonitor scrapet ze:
# 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 volle 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 }}"
De eerste week is ruis
Dit is de trade-off die niemand op de landingspagina zet. Out of the box pagen de default rules je voor dingen die in jouw cluster doodnormaal zijn. De eerste week dat ik Falco draaide, werd ik wakker gemaakt door m’n eigen config-reload-sidecar. Een tool die je hebt leren negeren heb je uitgezet zonder het te merken, dus tunen is geen stapje na de installatie. Het is het meeste werk.
Twee zetten. Rules uitzetten die niet passen, en de macros versmallen waar de rest van afhangt.
Rules uitzetten 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 tunen
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)
trusted_containers aanpassen wint van de rule uitzetten, want de rule beschermt dan nog elke andere container. Je leert Falco hoe normaal er hier uitziet, in plaats van het blind te maken.
Wat ik draai
Alles op elkaar. De eBPF-driver, JSON-output, getrapte alerting, en twee rules die zeggen wat m’n eigen workloads mogen. Leg het naast de helm install van bovenaan en je ziet hoeveel ervan tuning is:
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")
Waarom elke keuze. eBPF boven de kernelmodule omdat het beter presteert en de host schoner laat. JSON-output omdat downstream tools het parsen zonder regex. Getrapte alerting zodat een error Slack port en een critical iemand paget. En custom rules omdat alleen ik weet waar mijn backend mee hoort te praten.
Geautomatiseerde response
Falco detecteert alleen. Wat daarna gebeurt is aan jou. Voor de meeste rules wil ik dat een mens kijkt. Voor een CRITICAL vind ik het best dat het cluster zichzelf verdedigt, en de webhook-ontvanger uit de routing-config is waar dat zit:
# 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 kan klein zijn. Kill de pod, laat de Deployment hem opnieuw maken, en post naar Slack zodat een mens het weet:
@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'
Een pod killen op één alert is scherp gereedschap en ik ga er voorzichtig mee om. Hang er een ruizige rule aan en je hebt iets gebouwd dat je eigen cluster platlegt. Dus automatisering komt alleen op rules die ik lang genoeg in productie heb zien draaien om ze te vertrouwen. De rest paget een mens.
Zou moeten en is
Stap weg van de YAML en de reden dat ik Falco draai is de reden dat ik de rest van mijn stack zelf host. Ik wil weten wat m’n infrastructuur doet, niet aannemen. Trivy en Kyverno vertellen me wat mijn cluster zou moeten zijn. Falco vertelt me wat het is. Zonder Falco was m’n eerlijke antwoord op “wat draait er nu in je cluster” iets als “wat de image drie weken geleden zei, waarschijnlijk”.
Dat gat tussen zou moeten en is, tussen de spec en het draaiende proces, is waar een aanvaller werkt. De drie lagen waar ik mee opende zijn nog steeds groen. Falco is de laag die sindsdien iets te melden had, en de eerste week ging dat vooral over m’n eigen sidecar.
