#!/usr/bin/env bash ######################################################################################### #### tetragon-policy-deployer.sh — Deploy and manage Cilium Tetragon eBPF tracing #### #### policies on Kubernetes. Apply, validate, audit, and export TracingPolicies #### #### Requires: bash 4+, kubectl, optionally helm #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./tetragon-policy-deployer.sh --apply --policy ./policies/ #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail VERSION="1.00" # --- ANSI color variables (pre-initialized) --- RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" # --- Defaults --- MODE="" POLICY_PATH="" NAMESPACE="${TETRAGON_NAMESPACE:-kube-system}" OUTPUT_DIR="." VALUES_FILE="" CONFIRM_YES=false KUBECONFIG_FILE="${KUBECONFIG:-}" KUBE_CTX="${KUBE_CONTEXT:-}" OUTPUT_FORMAT="text" VERBOSE_FLAG="${VERBOSE:-false}" COLOR_FLAG="${COLOR:-true}" GENERATE_TEMPLATE="" DELETE_ALL=false DELETE_NAME="" # --- Color setup --- setup_colors() { if [[ "$COLOR_FLAG" == "true" ]] && [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' fi } # --- Logging --- log() { printf "%b\n" "${GREEN}✔${RESET} $*"; } warn() { printf "%b\n" "${YELLOW}⚠${RESET} $*" >&2; } err() { printf "%b\n" "${RED}✖${RESET} $*" >&2; } verbose() { [[ "$VERBOSE_FLAG" == "true" ]] && printf "%b\n" "${DIM}▸ $*${RESET}" >&2; return 0; } die() { err "$*"; exit 1; } section_header() { printf "\n%b━━━ %s ━━━%b\n" "${BOLD}${BLUE}" "$1" "${RESET}" } field() { printf " %-22s %s\n" "$1:" "$2" } field_color() { printf " %-22s %b%s%b\n" "$1:" "$2" "$3" "${RESET}" } # --- kubectl / helm wrappers --- kubectl_cmd() { local -a args=("kubectl") [[ -n "$KUBECONFIG_FILE" ]] && args+=("--kubeconfig" "$KUBECONFIG_FILE") [[ -n "$KUBE_CTX" ]] && args+=("--context" "$KUBE_CTX") "${args[@]}" "$@" } helm_cmd() { local -a args=("helm") [[ -n "$KUBECONFIG_FILE" ]] && args+=("--kubeconfig" "$KUBECONFIG_FILE") [[ -n "$KUBE_CTX" ]] && args+=("--kube-context" "$KUBE_CTX") "${args[@]}" "$@" } # --- Dependency checks --- require_kubectl() { command -v kubectl >/dev/null 2>&1 || die "kubectl is required but not found in PATH" } require_helm() { command -v helm >/dev/null 2>&1 || die "helm is required but not found in PATH" } # --- Usage --- usage() { cat < policy.yaml ./tetragon-policy-deployer.sh --delete --policy my-policy --yes EOF exit 0 } # --- Parse arguments --- parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --install) MODE="install"; shift ;; --apply) MODE="apply"; shift ;; --audit) MODE="audit"; shift ;; --status) MODE="status"; shift ;; --export) MODE="export"; shift ;; --delete) MODE="delete"; shift ;; --generate) MODE="generate"; GENERATE_TEMPLATE="${2:-}"; shift 2 || die "--generate requires a template name" ;; --policy) POLICY_PATH="${2:-}"; shift 2 || die "--policy requires a path or name" ;; --namespace) NAMESPACE="${2:-}"; shift 2 || die "--namespace requires a value" ;; --output-dir) OUTPUT_DIR="${2:-}"; shift 2 || die "--output-dir requires a path" ;; --values) VALUES_FILE="${2:-}"; shift 2 || die "--values requires a file path" ;; --yes) CONFIRM_YES=true; shift ;; --all) DELETE_ALL=true; shift ;; --kubeconfig) KUBECONFIG_FILE="${2:-}"; shift 2 || die "--kubeconfig requires a file" ;; --context) KUBE_CTX="${2:-}"; shift 2 || die "--context requires a name" ;; --format) OUTPUT_FORMAT="${2:-}"; shift 2 || die "--format requires a value" ;; --verbose) VERBOSE_FLAG="true"; shift ;; --no-color) COLOR_FLAG="false"; shift ;; --help) usage ;; *) die "Unknown option: $1" ;; esac done if [[ -z "$MODE" ]]; then err "No mode specified"; echo ""; usage; exit 1; fi } # --- Install Tetragon --- do_install() { section_header "Installing Tetragon" require_kubectl require_helm log "Adding Cilium Helm repository" verbose "helm repo add cilium https://helm.cilium.io" helm_cmd repo add cilium https://helm.cilium.io 2>/dev/null || true helm_cmd repo update local -a install_args=("upgrade" "--install" "tetragon" "cilium/tetragon" "--namespace" "$NAMESPACE" "--create-namespace") [[ -n "$VALUES_FILE" ]] && install_args+=("--values" "$VALUES_FILE") log "Installing Tetragon chart into namespace ${CYAN}${NAMESPACE}${RESET}" verbose "helm ${install_args[*]}" helm_cmd "${install_args[@]}" log "Waiting for Tetragon DaemonSet to be ready" kubectl_cmd rollout status daemonset/tetragon -n "$NAMESPACE" --timeout=120s log "Verifying Tetragon pods" local pods pods=$(kubectl_cmd get pods -n "$NAMESPACE" -l app.kubernetes.io/name=tetragon \ -o jsonpath='{range .items[*]}{.metadata.name} {.status.phase}{"\n"}{end}') if [[ -z "$pods" ]]; then warn "No Tetragon pods found" else local running=0 total=0 while IFS= read -r line; do [[ -z "$line" ]] && continue total=$((total + 1)) local phase phase=$(echo "$line" | awk '{print $2}') if [[ "$phase" == "Running" ]]; then running=$((running + 1)) fi done <<< "$pods" field_color "Pods" "${GREEN}" "${running}/${total} running" fi log "Tetragon installation complete" } # --- Apply policies --- do_apply() { section_header "Applying TracingPolicies" require_kubectl [[ -z "$POLICY_PATH" ]] && POLICY_PATH="${TETRAGON_POLICY_DIR:-}" [[ -z "$POLICY_PATH" ]] && die "No policy path specified. Use --policy DIR|FILE" local applied=0 failed=0 local -a files=() if [[ -d "$POLICY_PATH" ]]; then while IFS= read -r -d '' f; do files+=("$f") done < <(find "$POLICY_PATH" -type f \( -name '*.yaml' -o -name '*.yml' \) -print0 | sort -z) [[ ${#files[@]} -eq 0 ]] && die "No YAML files found in ${POLICY_PATH}" elif [[ -f "$POLICY_PATH" ]]; then files=("$POLICY_PATH") else die "Policy path not found: ${POLICY_PATH}" fi for f in "${files[@]}"; do local basename_f basename_f=$(basename "$f") verbose "Validating ${basename_f}" if ! kubectl_cmd apply --dry-run=client -f "$f" >/dev/null 2>&1; then err "Validation failed: ${basename_f}" failed=$((failed + 1)) continue fi verbose "Applying ${basename_f}" if kubectl_cmd apply -f "$f" -n "$NAMESPACE"; then applied=$((applied + 1)) log "Applied: ${basename_f}" else err "Failed to apply: ${basename_f}" failed=$((failed + 1)) fi done section_header "Apply Summary" field_color "Applied" "${GREEN}" "$applied" [[ $failed -gt 0 ]] && field_color "Failed" "${RED}" "$failed" verbose "Checking policy status" kubectl_cmd get tracingpolicy -n "$NAMESPACE" 2>/dev/null || true } # --- Audit policies --- do_audit() { section_header "Auditing Tetragon Policies" require_kubectl log "Checking TracingPolicy resources" local tp_output tp_output=$(kubectl_cmd get tracingpolicy --all-namespaces -o json 2>/dev/null || echo '{"items":[]}') local tp_count tp_count=$(echo "$tp_output" | grep -c '"kind"' || true) field "TracingPolicies" "$tp_count found" if command -v jq >/dev/null 2>&1 && [[ "$tp_count" -gt 0 ]]; then echo "$tp_output" | jq -r '.items[] | " \(.metadata.name) — sensors: \(.spec.kprobes // [] | length) kprobes, \(.spec.tracepoints // [] | length) tracepoints"' 2>/dev/null || true fi log "Checking ClusterTracingPolicy resources" local ctp_count ctp_count=$(kubectl_cmd get clustertracingpolicy --all-namespaces -o name 2>/dev/null | wc -l || echo 0) field "ClusterTracingPolicies" "${ctp_count} found" section_header "Tetragon Pod Health" local ds_json ds_json=$(kubectl_cmd get daemonset tetragon -n "$NAMESPACE" -o json 2>/dev/null || echo "") if [[ -n "$ds_json" ]] && command -v jq >/dev/null 2>&1; then local desired ready desired=$(echo "$ds_json" | jq '.status.desiredNumberScheduled // 0') ready=$(echo "$ds_json" | jq '.status.numberReady // 0') field "DaemonSet desired" "$desired" if [[ "$ready" -eq "$desired" ]]; then field_color "DaemonSet ready" "${GREEN}" "$ready" else field_color "DaemonSet ready" "${RED}" "${ready} (expected ${desired})" fi else kubectl_cmd get daemonset tetragon -n "$NAMESPACE" 2>/dev/null || warn "Tetragon DaemonSet not found" fi section_header "Common Misconfiguration Checks" local pods_not_ready pods_not_ready=$(kubectl_cmd get pods -n "$NAMESPACE" -l app.kubernetes.io/name=tetragon \ --field-selector=status.phase!=Running -o name 2>/dev/null | wc -l || echo 0) if [[ "$pods_not_ready" -gt 0 ]]; then warn "${pods_not_ready} Tetragon pod(s) not in Running state" else log "All Tetragon pods are Running" fi log "Audit complete" } # --- Status --- do_status() { section_header "Tetragon Status" require_kubectl local ds_output ds_output=$(kubectl_cmd get daemonset tetragon -n "$NAMESPACE" -o wide 2>/dev/null || echo "") if [[ -z "$ds_output" ]]; then warn "Tetragon DaemonSet not found in namespace ${NAMESPACE}" return fi echo "$ds_output" printf "\n" local pod_count pod_count=$(kubectl_cmd get pods -n "$NAMESPACE" -l app.kubernetes.io/name=tetragon \ -o name 2>/dev/null | wc -l || echo 0) local ready_count ready_count=$(kubectl_cmd get pods -n "$NAMESPACE" -l app.kubernetes.io/name=tetragon \ --field-selector=status.phase=Running -o name 2>/dev/null | wc -l || echo 0) field "Pods total" "$pod_count" field_color "Pods ready" "${GREEN}" "$ready_count" local policy_count policy_count=$(kubectl_cmd get tracingpolicy --all-namespaces -o name 2>/dev/null | wc -l || echo 0) field "Active policies" "$policy_count" section_header "Recent Tetragon Events (last 10)" kubectl_cmd get events -n "$NAMESPACE" --field-selector involvedObject.name=tetragon \ --sort-by=.lastTimestamp 2>/dev/null | tail -n 10 || warn "No events found" } # --- Export --- do_export() { section_header "Exporting TracingPolicies" require_kubectl mkdir -p "$OUTPUT_DIR" local policies policies=$(kubectl_cmd get tracingpolicy --all-namespaces -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true) if [[ -z "$policies" ]]; then warn "No TracingPolicy resources found to export" return fi local count=0 while IFS= read -r name; do [[ -z "$name" ]] && continue local outfile="${OUTPUT_DIR}/${name}.yaml" verbose "Exporting ${name} to ${outfile}" kubectl_cmd get tracingpolicy "$name" -o yaml > "$outfile" log "Exported: ${name} → ${outfile}" count=$((count + 1)) done <<< "$policies" field_color "Total exported" "${GREEN}" "$count" } # --- Delete --- do_delete() { section_header "Deleting TracingPolicies" require_kubectl if [[ "$DELETE_ALL" == "true" ]]; then if [[ "$CONFIRM_YES" != "true" ]]; then printf "Delete ALL TracingPolicies? [y/N] " read -r answer [[ "$answer" =~ ^[Yy]$ ]] || die "Aborted" fi local deleted deleted=$(kubectl_cmd delete tracingpolicy --all -n "$NAMESPACE" 2>&1) log "$deleted" elif [[ -n "$POLICY_PATH" ]]; then DELETE_NAME="$POLICY_PATH" if [[ "$CONFIRM_YES" != "true" ]]; then printf "Delete TracingPolicy '%s'? [y/N] " "$DELETE_NAME" read -r answer [[ "$answer" =~ ^[Yy]$ ]] || die "Aborted" fi kubectl_cmd delete tracingpolicy "$DELETE_NAME" -n "$NAMESPACE" log "Deleted TracingPolicy: ${DELETE_NAME}" else die "Specify --policy NAME or --all for deletion" fi } # --- Generate templates --- generate_file_monitor() { cat <<'YAML' apiVersion: cilium.io/v1alpha1 kind: TracingPolicy metadata: name: file-monitor spec: kprobes: - call: "security_file_open" syscall: false args: - index: 0 type: "file" selectors: - matchArgs: - index: 0 operator: "Prefix" values: - "/etc/shadow" - "/etc/passwd" - "/etc/sudoers" - "/etc/pam.d" - "/root/.ssh" YAML } generate_process_exec() { cat <<'YAML' apiVersion: cilium.io/v1alpha1 kind: TracingPolicy metadata: name: process-exec spec: kprobes: - call: "__x64_sys_execve" syscall: true args: - index: 0 type: "string" YAML } generate_network_connect() { cat <<'YAML' apiVersion: cilium.io/v1alpha1 kind: TracingPolicy metadata: name: network-connect spec: kprobes: - call: "tcp_connect" syscall: false args: - index: 0 type: "sock" YAML } generate_privilege_escalation() { cat <<'YAML' apiVersion: cilium.io/v1alpha1 kind: TracingPolicy metadata: name: privilege-escalation spec: kprobes: - call: "__x64_sys_setuid" syscall: true args: - index: 0 type: "int" - call: "__x64_sys_setgid" syscall: true args: - index: 0 type: "int" - call: "commit_creds" syscall: false args: - index: 0 type: "cred" YAML } generate_sensitive_mount() { cat <<'YAML' apiVersion: cilium.io/v1alpha1 kind: TracingPolicy metadata: name: sensitive-mount spec: kprobes: - call: "__x64_sys_mount" syscall: true args: - index: 0 type: "string" - index: 1 type: "string" selectors: - matchArgs: - index: 1 operator: "Prefix" values: - "/host" - "/var/run/docker.sock" - "/proc" - "/sys" YAML } generate_crypto_mining() { cat <<'YAML' apiVersion: cilium.io/v1alpha1 kind: TracingPolicy metadata: name: crypto-mining spec: kprobes: - call: "tcp_connect" syscall: false args: - index: 0 type: "sock" - call: "__x64_sys_execve" syscall: true args: - index: 0 type: "string" selectors: - matchArgs: - index: 0 operator: "Postfix" values: - "xmrig" - "minerd" - "cpuminer" - "ethminer" - "cgminer" - "bfgminer" YAML } do_generate() { [[ -z "$GENERATE_TEMPLATE" ]] && die "No template specified for --generate" case "$GENERATE_TEMPLATE" in file-monitor) generate_file_monitor ;; process-exec) generate_process_exec ;; network-connect) generate_network_connect ;; privilege-escalation) generate_privilege_escalation ;; sensitive-mount) generate_sensitive_mount ;; crypto-mining) generate_crypto_mining ;; *) die "Unknown template: ${GENERATE_TEMPLATE}. Use --help for available templates." ;; esac } # --- Main --- main() { parse_args "$@" setup_colors verbose "Mode: ${MODE}" verbose "Format: ${OUTPUT_FORMAT}" verbose "Namespace: ${NAMESPACE}" [[ -n "$KUBECONFIG_FILE" ]] && verbose "Kubeconfig: ${KUBECONFIG_FILE}" [[ -n "$KUBE_CTX" ]] && verbose "Context: ${KUBE_CTX}" case "$MODE" in install) do_install ;; apply) do_apply ;; audit) do_audit ;; status) do_status ;; export) do_export ;; delete) do_delete ;; generate) do_generate ;; *) die "Unknown mode: ${MODE}" ;; esac } main "$@"