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:

  1. Build time - scan images op bekende kwetsbaarheden (Trivy)
  2. Deploy time - dwing policies af op de spec (Kyverno, admission controllers)
  3. 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/bash uitvoert in productie, waar niets ooit een shell zou moeten starten
  • Een proces dat /etc/shadow leest
  • 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.