Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions .github/workflows/ci-bobctl-autotune.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ on:
- 'pkg/**'
- 'kubescape/**'
- 'example/**'
- 'postgres/**'
- 'postgres-vuln/**'
- 'example/postgres/**'
- 'example/postgres-vuln/**'
- 'scripts/**'
- '.github/workflows/**'
- 'Makefile'
Expand Down Expand Up @@ -284,10 +284,29 @@ jobs:
ALL=$(kubectl get containerprofiles -n ${{ matrix.app_namespace }} \
-o jsonpath='{range .items[?(@.metadata.annotations.kubescape\.io/status=="completed")]}{.metadata.name}{"\n"}{end}' \
2>/dev/null | grep -v "^ug-" | grep -v "^job-" || true)
# Prefer profile matching the app/service name
PROFILE=$(echo "$ALL" | grep -i "$MATCH" | grep -v "client" | head -1)
[ -z "$PROFILE" ] && PROFILE=$(echo "$ALL" | grep -i "$MATCH" | head -1)
[ -z "$PROFILE" ] && PROFILE=$(echo "$ALL" | head -1)
# ONLY a profile matching the app/service name ends the wait.
#
# There used to be a bare `head -1` fallback here, and it made the
# postgres leg a race it usually lost: pg-1-bootstrap-controller is
# a short-lived init container, so its profile completes within
# seconds while pg-client is still being exercised. The fallback
# accepted it on the FIRST poll, broke the loop, and tuned the
# bootstrap container — every attack then landed on pg-client, none
# of it matched the tuned profile, and the leg failed with all 21
# detections missed and 0 false positives. The fallback now lives
# after the timeout (below), where it is a diagnostic rather than a
# way to finish early with the wrong container.
#
# The "client" exclusion is skipped when MATCH itself names a client
# (postgres matches pg-client), where it would filter out precisely
# the profile being looked for.
case "$MATCH" in
*client*)
PROFILE=$(echo "$ALL" | grep -i "$MATCH" | head -1) ;;
*)
PROFILE=$(echo "$ALL" | grep -i "$MATCH" | grep -v "client" | head -1)
[ -z "$PROFILE" ] && PROFILE=$(echo "$ALL" | grep -i "$MATCH" | head -1) ;;
esac
if [ -n "$PROFILE" ]; then
echo "Profile completed: $PROFILE"
echo "All completed profiles:"
Expand All @@ -300,9 +319,17 @@ jobs:
done

if [ -z "$PROFILE" ]; then
echo "WARNING: No profile discovered after ${TIMEOUT}s"
echo "WARNING: no profile matching '$MATCH' completed after ${TIMEOUT}s"
kubectl get containerprofile -n ${{ matrix.app_namespace }} -o wide || true
PROFILE="unknown"
# Fall back to any completed profile so the remaining steps still
# produce diagnostics, but say loudly that the tune is now measuring
# a container nobody asked for — a green result here means nothing.
PROFILE=$(echo "$ALL" | head -1)
if [ -n "$PROFILE" ]; then
echo "WARNING: falling back to unmatched profile '$PROFILE' — results are NOT authoritative"
else
PROFILE="unknown"
fi
fi

echo "name=$PROFILE" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -493,7 +520,7 @@ jobs:
mkdir -p results
STRESS_START=$(date +%s)
bin/bobctl test \
--functional-tests example/redis-load-stress.yaml \
--functional-tests example/redis/redis-load-stress.yaml \
-n redis 2>&1 | tee results/stress-results.txt
STRESS_ELAPSED=$(( $(date +%s) - STRESS_START ))
echo ""
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/ci-postgres-vuln-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI - postgres-vulnerable image

# Builds and publishes the postgres-vuln demo image (postgres:16 +
# plpython3u/plperlu/dblink for the PL-RCE + SSRF attack suite) to GHCR,
# so the postgres-vuln/ demo no longer depends on a locally-built
# so the example/postgres-vuln/ demo no longer depends on a locally-built
# `postgres-vuln:latest` (which ImagePullBackOffs on any cluster that
# didn't build it). Mirrors ci-redis-image.yaml / ci-chain-images.yaml.
#
Expand All @@ -16,7 +16,7 @@ on:
branches:
- main
paths:
- 'postgres-vuln/Dockerfile'
- 'example/postgres-vuln/Dockerfile'
- '.github/workflows/ci-postgres-vuln-image.yaml'
workflow_dispatch:

Expand Down Expand Up @@ -59,8 +59,8 @@ jobs:
- name: Build (load for smoke test)
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: postgres-vuln
file: postgres-vuln/Dockerfile
context: example/postgres-vuln
file: example/postgres-vuln/Dockerfile
load: true
tags: postgres-vuln:smoke

Expand All @@ -85,8 +85,8 @@ jobs:
- name: Push (multi-arch)
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: postgres-vuln
file: postgres-vuln/Dockerfile
context: example/postgres-vuln
file: example/postgres-vuln/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
66 changes: 51 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ deploy-webapp:
.PHONY: deploy-redis
deploy-redis:
@echo "=== Deploying redis (manifest) ==="
kubectl apply -f example/redis-vulnerable.yaml
kubectl apply -f example/redis/redis-vulnerable.yaml
kubectl wait --for=condition=available --timeout=120s deployment/redis -n redis

.PHONY: deploy-mariadb
Expand Down Expand Up @@ -116,11 +116,11 @@ deploy-postgres:
helm upgrade --install cnpg cnpg/cloudnative-pg \
-n cnpg-system --create-namespace --wait --timeout 5m
@# pg-client Service is declared headless (clusterIP: None) in
@# postgres/cluster.yaml. Pre-existing clusterIP-typed Services from
@# example/postgres/cluster.yaml. Pre-existing clusterIP-typed Services from
@# earlier deploys cannot be mutated in-place (K8s spec.clusterIP is
@# immutable once set), so delete it before apply.
-kubectl delete svc pg-client -n postgres --ignore-not-found
kubectl apply -f postgres/cluster.yaml
kubectl apply -f example/postgres/cluster.yaml
@echo "Waiting for CNPG cluster to be ready..."
@TIMEOUT=300; ELAPSED=0; \
while [ $$ELAPSED -lt $$TIMEOUT ]; do \
Expand All @@ -137,7 +137,7 @@ deploy-postgres:
build-postgres-vuln:
@echo "=== Building postgres-vuln image ==="
@DOCKER_HOST=$${DOCKER_HOST:-unix:///var/run/docker.sock}; export DOCKER_HOST; \
docker build -t postgres-vuln:latest postgres-vuln/
docker build -t postgres-vuln:latest example/postgres-vuln/

.PHONY: deploy-postgres-vuln
deploy-postgres-vuln: build-postgres-vuln
Expand All @@ -160,7 +160,7 @@ deploy-postgres-vuln: build-postgres-vuln
echo "Importing image into k3s..."; \
$$DOCKER_CMD save postgres-vuln:latest | sudo k3s ctr images import -; \
fi
kubectl apply -f postgres-vuln/cluster.yaml
kubectl apply -f example/postgres-vuln/cluster.yaml
@# pg-vuln Deployment uses imagePullPolicy: IfNotPresent and pg-vuln-client
@# is a raw Pod with restartPolicy: Never. Without an explicit rollout +
@# pod recreation, repeated runs silently use the previously-loaded image
Expand All @@ -169,7 +169,7 @@ deploy-postgres-vuln: build-postgres-vuln
-kubectl rollout restart deployment/pg-vuln -n postgres-vuln 2>/dev/null
@echo "Recreating pg-vuln-client (raw Pod won't pick up new image otherwise)..."
-kubectl delete pod pg-vuln-client -n postgres-vuln --ignore-not-found --grace-period=0 --force 2>/dev/null
kubectl apply -f postgres-vuln/cluster.yaml
kubectl apply -f example/postgres-vuln/cluster.yaml
@echo "Waiting for pg-vuln deployment..."
kubectl wait --for=condition=available deployment/pg-vuln -n postgres-vuln --timeout=180s
@echo "Waiting for pg-vuln-client pod..."
Expand Down Expand Up @@ -268,7 +268,7 @@ kubescape-orig:
# below instead of a patch-then-bounce. Do not reintroduce `rollout restart ds
# node-agent`. Also do NOT pass --set nodeAgent.privileged=true: the chart
# default is false and privileged node-agent has crashed this host.
KS_POST_RENDERER := ./kubescape/force-network-streaming.sh
KS_POST_RENDERER := ./kubescape/post-render.sh

# node-agent finds NEWLY STARTED containers by fanotify-marking the runc binary
# (Inspektor Gadget's WithContainerFanotifyEbpf). IG only knows the stock paths
Expand All @@ -287,10 +287,47 @@ KS_POST_RENDERER := ./kubescape/force-network-streaming.sh
# The path is passed BARE (no /host prefix): runtimefinder.Notify only resolves
# symlinks chrooted to HOST_ROOT when the value does not already start with /host,
# and `data/current` is an absolute symlink.
KS_RUNC := $(shell ps -eo args 2>/dev/null | grep -oE '[^ ]*/bin/containerd-shim-runc-v2' | head -1 | sed 's|/containerd-shim-runc-v2|/runc|')
KS_RUNC_STOCK := $(shell echo "$(KS_RUNC)" | grep -qE '^$$|^/usr/bin/runc$$|^/var/lib/rancher/k3s/' && echo yes || echo no)
KS_RUNC_MNT := $(shell [ "$(KS_RUNC_STOCK)" = no ] && findmnt -no TARGET --target "$(KS_RUNC)" 2>/dev/null)
KS_RUNC_FLAGS := $(if $(filter no,$(KS_RUNC_STOCK)),--set global.overrideRuntimePath=$(KS_RUNC)$(if $(filter-out /,$(KS_RUNC_MNT)), --set volumes[0].name=ks-runc-fs --set volumes[0].hostPath.path=$(KS_RUNC_MNT) --set volumes[0].hostPath.type=Directory --set volumeMounts[0].name=ks-runc-fs --set volumeMounts[0].mountPath=/host$(KS_RUNC_MNT) --set volumeMounts[0].readOnly=true))
# node-agent finds NEWLY STARTED containers by fanotify-marking the runc binary
# (Inspektor Gadget's WithContainerFanotifyEbpf). IG only knows the stock paths —
# /usr/bin/runc and /var/lib/rancher/k3s/data/current/bin/runc — so a cluster
# whose runc lives anywhere else is silently blind: node-agent still enumerates
# whatever was running when IT started and looks healthy, while every container
# created afterwards gets no ContainerProfile.
#
# This is OPT-IN and must stay that way. It was briefly auto-detected from local
# `ps`, which is wrong on its face: this flag configures node-agent, which runs
# on the CLUSTER NODES, while `ps` reads the machine you happen to type `make`
# on. Those are the same host only on a single-node local cluster. On a
# multi-node or remote cluster the detection reads an unrelated process table —
# and on a client machine with no shim at all it produced a malformed value that
# went straight to helm (see #172). Auto-detection also ran three subshells on
# EVERY make target, including ones with nothing to do with helm.
#
# Find the value on a NODE, not here:
# ps -eo args | grep -oE '[^ ]*/bin/containerd-shim-runc-v2'
# then pass the runc beside it, plus the filesystem holding it if that is a
# separate mount (node-agent's `host` volume is a NON-recursive bind of "/", so
# it does not carry one):
# make kubescape KS_RUNC=/mnt/dev-data/k3s/data/current/bin/runc KS_RUNC_MNT=/mnt/dev-data
KS_RUNC ?=
KS_RUNC_MNT ?=

# Refuse anything that is not an absolute path rather than passing it to helm.
# The $(shell) lives INSIDE the guard so the default path (KS_RUNC unset, which
# is CI and every contributor who is not on a bespoke runtime layout) spawns no
# subshell at all.
ifneq ($(KS_RUNC),)
KS_RUNC_OK := $(shell printf '%s' "$(KS_RUNC)" | grep -q '^/' && echo yes || echo no)
ifneq ($(KS_RUNC_OK),yes)
$(error KS_RUNC must be an absolute path on the cluster NODE, got "$(KS_RUNC)")
endif
endif

# Only the env var goes through helm; the filesystem mount that KS_RUNC_MNT
# implies is applied by $(KS_POST_RENDERER), which reads it from the
# environment — hence the export.
export KS_RUNC_MNT
KS_RUNC_FLAGS := $(if $(KS_RUNC),--set global.overrideRuntimePath=$(KS_RUNC))

# One rule-coverage card per contrast SBoB, defined in kubescape/rule-coverage.yaml.
# Every rule in the ruleset is accounted for as verified / probe / excluded / gap,
Expand All @@ -306,10 +343,9 @@ rule-coverage-gifs:

.PHONY: show-runc
show-runc:
@echo "detected runc: $(KS_RUNC)"
@echo "stock layout: $(KS_RUNC_STOCK)"
@echo "holding mount: $(KS_RUNC_MNT)"
@echo "extra helm flags:$(KS_RUNC_FLAGS)"
@echo "KS_RUNC: $(if $(KS_RUNC),$(KS_RUNC),(unset - using IG's stock paths))"
@echo "KS_RUNC_MNT: $(if $(KS_RUNC_MNT),$(KS_RUNC_MNT),(unset - no extra hostPath mount))"
@echo "extra helm flags: $(if $(KS_RUNC_FLAGS),$(KS_RUNC_FLAGS),(none))"

.PHONY: kubescape
kubescape:
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ It's understood to be an abstraction of linux kernel level behavior to express `
- **to explicitly test for false-negatives**: each attack type can be verified as 'blind' or 'detectable'
- **for continuous anomaly detection at runtime**: allows end-users to calibrate their Detection/Reponse

![redis kill-chain — kubescape rule coverage](example/redis-client/redis-killchain.gif)
![redis kill-chain — kubescape rule coverage](example/redis/redis-killchain.gif)

🚨GOAL for 2027: 90 percent of all CNCF projects (that run on linux-k8s) get an SBOB

Expand Down Expand Up @@ -55,9 +55,9 @@ make kubescape
make alertmanager


kubectl apply -f example/redis-client/sbobs/
kubectl apply -f example/redis-client/redis.yaml
kubectl apply -f example/redis-client/client.yaml
kubectl apply -f example/redis/sbobs/
kubectl apply -f example/redis/redis.yaml
kubectl apply -f example/redis/client.yaml


bobctl attack --attack-suite example/redis-attacks.yaml -n redis-demo --service redis --service-port 6379 --format table
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
30 changes: 30 additions & 0 deletions example/redis/distros/VERDICT.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
baseline: redis-oss

distro score execs opens entries rules fired (execs/opens/entries = RAW baseline)
------------------------------------------------------------------------------
dragonfly 1 1 49 104 12: R0001 R0005 R0006 R0007 R0008 R0010 R0011 R1004 R1005 R1008 R1010 R1012
keydb 1 1 64 69 12: R0001 R0005 R0006 R0007 R0008 R0010 R0011 R1004 R1005 R1008 R1010 R1012
redis-oss 0 1 20 21 13: R0001 R0005 R0006 R0007 R0008 R0010 R0011 R1000 R1004 R1005 R1008 R1010 R1012
valkey 1 1 21 36 12: R0001 R0005 R0006 R0007 R0008 R0010 R0011 R1004 R1005 R1008 R1010 R1012

dragonfly vs redis-oss
CONTRAST : DIFFERS
only in redis-oss: R1000
BEHAVIOUR: opens 20->49, syscalls 0->27
attacks differing in outcome (2): exec-proc-environ recon-info-server

keydb vs redis-oss
CONTRAST : DIFFERS
only in redis-oss: R1000
BEHAVIOUR: opens 20->64, syscalls 0->4
attacks differing in outcome (1): recon-info-server

valkey vs redis-oss
CONTRAST : DIFFERS
only in redis-oss: R1000
BEHAVIOUR: opens 20->21, syscalls 0->14
attacks differing in outcome (1): recon-info-server

summary
contrast-equivalent to redis-oss: none
contrast-differs : dragonfly, keydb, valkey
Loading
Loading