diff --git a/k8s/diagnose/tests/kubectl_get.bats b/k8s/diagnose/tests/kubectl_get.bats new file mode 100644 index 00000000..0057714a --- /dev/null +++ b/k8s/diagnose/tests/kubectl_get.bats @@ -0,0 +1,371 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for kubectl_get - read-only kubectl wrapper for troubleshooting +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + source "$PROJECT_ROOT/testing/assertions.sh" + + log() { if [ "$1" = "error" ]; then echo "$2" >&2; else echo "$2"; fi; } + export -f log + + export SCRIPT="$PROJECT_ROOT/k8s/kubectl_get" + export K8S_NAMESPACE="default-ns" + + # Mock kubectl: echo back what was received so tests can assert the args. + kubectl() { + echo "kubectl-called: $*" + return 0 + } + export -f kubectl +} + +teardown() { + unset -f kubectl log + unset K8S_NAMESPACE SCRIPT PROJECT_ROOT +} + +# ============================================================================= +# Usage +# ============================================================================= +@test "kubectl_get: shows usage and exits 1 when no args provided" { + run bash "$SCRIPT" + + [ "$status" -eq 1 ] + assert_contains "$output" "Usage:" + assert_contains "$output" "kubectl get" +} + +# ============================================================================= +# Hardcoded verb: only 'get' can be invoked +# ============================================================================= +@test "kubectl_get: invokes kubectl with 'get' verb followed by user args" { + run bash "$SCRIPT" pods -o wide + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods -o wide" +} + +# ============================================================================= +# Default namespace injection +# ============================================================================= +@test "kubectl_get: injects K8S_NAMESPACE when no namespace flag provided" { + run bash "$SCRIPT" pods + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods -n default-ns" +} + +@test "kubectl_get: does not inject namespace when -n is provided" { + run bash "$SCRIPT" pods -n kube-system + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods -n kube-system" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_get: does not inject namespace when --namespace is provided" { + run bash "$SCRIPT" pods --namespace kube-system + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods --namespace kube-system" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_get: does not inject namespace when --namespace=value form is provided" { + run bash "$SCRIPT" pods --namespace=kube-system + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods --namespace=kube-system" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_get: does not inject namespace when -A is provided" { + run bash "$SCRIPT" pods -A + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods -A" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_get: does not inject namespace when --all-namespaces is provided" { + run bash "$SCRIPT" pods --all-namespaces + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods --all-namespaces" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_get: does not inject namespace when K8S_NAMESPACE is unset" { + unset K8S_NAMESPACE + + run bash "$SCRIPT" pods + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods" + [[ "$output" != *"-n "* ]] +} + +# ============================================================================= +# Blocked flags +# ============================================================================= +@test "kubectl_get: rejects --server" { + run bash "$SCRIPT" pods --server https://evil.example.com + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--server'" +} + +@test "kubectl_get: rejects --server=value form" { + run bash "$SCRIPT" pods --server=https://evil.example.com + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--server=https://evil.example.com'" +} + +@test "kubectl_get: rejects --kubeconfig" { + run bash "$SCRIPT" pods --kubeconfig /tmp/evil.yaml + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--kubeconfig'" +} + +@test "kubectl_get: rejects --token" { + run bash "$SCRIPT" pods --token abc123 + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--token'" +} + +@test "kubectl_get: rejects --as (impersonation)" { + run bash "$SCRIPT" pods --as cluster-admin + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--as'" +} + +@test "kubectl_get: rejects --as-group" { + run bash "$SCRIPT" pods --as-group system:masters + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--as-group'" +} + +@test "kubectl_get: rejects --context" { + run bash "$SCRIPT" pods --context other-cluster + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--context'" +} + +@test "kubectl_get: rejects --insecure-skip-tls-verify" { + run bash "$SCRIPT" pods --insecure-skip-tls-verify + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--insecure-skip-tls-verify'" +} + +@test "kubectl_get: rejects -w (avoid hangs)" { + run bash "$SCRIPT" pods -w + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '-w'" +} + +@test "kubectl_get: rejects --watch" { + run bash "$SCRIPT" pods --watch + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--watch'" +} + +@test "kubectl_get: blocked flag in middle of args is still detected" { + run bash "$SCRIPT" pods -n my-ns --token abc123 -o yaml + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--token'" +} + +# ============================================================================= +# Shell injection safety +# ============================================================================= +@test "kubectl_get: passes args verbatim — no shell interpretation of metachars" { + # If any of these metachars were interpreted by a shell, kubectl would + # never see them as part of a single arg. Mock echoes args back as-is. + run bash "$SCRIPT" pods -l 'app=foo;bar|baz`whoami`' + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: get pods -l app=foo;bar|baz\`whoami\` -n default-ns" +} + +# ============================================================================= +# Exit code propagation +# ============================================================================= +@test "kubectl_get: propagates kubectl exit code on failure" { + kubectl() { + echo "Error from server (NotFound): pods 'foo' not found" >&2 + return 1 + } + export -f kubectl + + run bash "$SCRIPT" pods foo + + [ "$status" -eq 1 ] +} + +# ============================================================================= +# Secret content stripping +# ============================================================================= +# Mock that returns realistic secret JSON when invoked with secret + -o json. +mock_kubectl_with_secrets() { + kubectl() { + if [[ "$*" == *"secret"* && "$*" == *"-o json"* ]]; then + # Single secret (when name is in args) returns object; otherwise list. + if [[ "$*" == *"secret foo"* || "$*" == *"secret/foo"* ]]; then + cat <<'EOF' +{ + "metadata": {"name": "foo", "namespace": "default-ns"}, + "type": "Opaque", + "data": {"password": "c3VwZXJzZWNyZXQ="}, + "stringData": {"plain": "alsosecret"} +} +EOF + else + cat <<'EOF' +{ + "items": [ + { + "metadata": {"name": "foo", "namespace": "default-ns"}, + "type": "Opaque", + "data": {"password": "c3VwZXJzZWNyZXQ="}, + "stringData": {"plain": "alsosecret"} + } + ] +} +EOF + fi + return 0 + fi + echo "kubectl-called: $*" + } + export -f kubectl +} + +@test "kubectl_get: strips .data and .stringData from secret list output" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" secrets + + [ "$status" -eq 0 ] + # Metadata still present + assert_contains "$output" "\"name\": \"foo\"" + assert_contains "$output" "\"type\": \"Opaque\"" + # Sensitive content gone + [[ "$output" != *"c3VwZXJzZWNyZXQ="* ]] + [[ "$output" != *"alsosecret"* ]] + [[ "$output" != *"\"data\""* ]] + [[ "$output" != *"\"stringData\""* ]] +} + +@test "kubectl_get: strips .data and .stringData from single secret output" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" secret foo + + [ "$status" -eq 0 ] + assert_contains "$output" "\"name\": \"foo\"" + [[ "$output" != *"c3VwZXJzZWNyZXQ="* ]] + [[ "$output" != *"alsosecret"* ]] + [[ "$output" != *"\"data\""* ]] +} + +@test "kubectl_get: works for 'secret' (singular) resource name" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" secret + + [ "$status" -eq 0 ] + [[ "$output" != *"c3VwZXJzZWNyZXQ="* ]] +} + +@test "kubectl_get: works for secret/name slash form" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" secret/foo + + [ "$status" -eq 0 ] + [[ "$output" != *"c3VwZXJzZWNyZXQ="* ]] +} + +@test "kubectl_get: works for secret,configmap comma form" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" secret,configmap + + [ "$status" -eq 0 ] + [[ "$output" != *"c3VwZXJzZWNyZXQ="* ]] +} + +@test "kubectl_get: forces -o json when user requested -o yaml on secrets" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" secrets -o yaml + + [ "$status" -eq 0 ] + assert_contains "$output" "Output forced to JSON" + [[ "$output" != *"c3VwZXJzZWNyZXQ="* ]] +} + +@test "kubectl_get: rejects -o jsonpath on secrets" { + run bash "$SCRIPT" secrets -o "jsonpath={.items[*].data.password}" + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing -o" + assert_contains "$output" "jsonpath" +} + +@test "kubectl_get: rejects -o go-template on secrets" { + run bash "$SCRIPT" secrets -o "go-template={{.items}}" + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing -o" + assert_contains "$output" "go-template" +} + +@test "kubectl_get: rejects -o custom-columns on secrets" { + run bash "$SCRIPT" secrets -o "custom-columns=NAME:.metadata.name,DATA:.data" + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing -o" + assert_contains "$output" "custom-columns" +} + +@test "kubectl_get: rejects --output=jsonpath= on secrets" { + run bash "$SCRIPT" secrets --output="jsonpath={.items[*].data}" + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing -o" +} + +@test "kubectl_get: secret filtering does not affect non-secret resources" { + mock_kubectl_with_secrets + + run bash "$SCRIPT" pods -o yaml + + [ "$status" -eq 0 ] + # Goes through the normal (non-filtered) path: mock echoes args. + assert_contains "$output" "kubectl-called: get pods -o yaml -n default-ns" +} + +@test "kubectl_get: propagates kubectl failure exit code through jq pipe" { + kubectl() { + echo "Error from server (Forbidden)" >&2 + return 1 + } + export -f kubectl + + run bash "$SCRIPT" secrets + + [ "$status" -eq 1 ] +} diff --git a/k8s/diagnose/tests/kubectl_logs.bats b/k8s/diagnose/tests/kubectl_logs.bats new file mode 100644 index 00000000..088fc9a7 --- /dev/null +++ b/k8s/diagnose/tests/kubectl_logs.bats @@ -0,0 +1,230 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for kubectl_logs - read-only, non-streaming kubectl logs wrapper +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + source "$PROJECT_ROOT/testing/assertions.sh" + + log() { if [ "$1" = "error" ]; then echo "$2" >&2; else echo "$2"; fi; } + export -f log + + export SCRIPT="$PROJECT_ROOT/k8s/kubectl_logs" + export K8S_NAMESPACE="default-ns" + + # Mock kubectl: echo back what was received so tests can assert the args. + kubectl() { + echo "kubectl-called: $*" + return 0 + } + export -f kubectl +} + +teardown() { + unset -f kubectl log + unset K8S_NAMESPACE SCRIPT PROJECT_ROOT +} + +# ============================================================================= +# Usage +# ============================================================================= +@test "kubectl_logs: shows usage and exits 1 when no args provided" { + run bash "$SCRIPT" + + [ "$status" -eq 1 ] + assert_contains "$output" "Usage:" + assert_contains "$output" "kubectl logs" +} + +# ============================================================================= +# Hardcoded verb: only 'logs' can be invoked +# ============================================================================= +@test "kubectl_logs: invokes kubectl with 'logs' verb followed by user args" { + run bash "$SCRIPT" my-pod -c my-container + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod -c my-container" +} + +@test "kubectl_logs: passes --tail / --since / --previous through unchanged" { + run bash "$SCRIPT" my-pod --tail 200 --since 1h --previous + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod --tail 200 --since 1h --previous" +} + +# ============================================================================= +# Default namespace injection +# ============================================================================= +@test "kubectl_logs: injects K8S_NAMESPACE when no namespace flag provided" { + run bash "$SCRIPT" my-pod + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod -n default-ns" +} + +@test "kubectl_logs: does not inject namespace when -n is provided" { + run bash "$SCRIPT" my-pod -n kube-system + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod -n kube-system" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_logs: does not inject namespace when --namespace is provided" { + run bash "$SCRIPT" my-pod --namespace kube-system + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod --namespace kube-system" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_logs: does not inject namespace when --namespace=value form is provided" { + run bash "$SCRIPT" my-pod --namespace=kube-system + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod --namespace=kube-system" + [[ "$output" != *"-n default-ns"* ]] +} + +@test "kubectl_logs: does not inject namespace when K8S_NAMESPACE is unset" { + unset K8S_NAMESPACE + + run bash "$SCRIPT" my-pod + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs my-pod" + [[ "$output" != *"-n "* ]] +} + +# ============================================================================= +# Streaming flags are blocked +# ============================================================================= +@test "kubectl_logs: rejects -f (would stream)" { + run bash "$SCRIPT" my-pod -f + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '-f'" +} + +@test "kubectl_logs: rejects --follow (would stream)" { + run bash "$SCRIPT" my-pod --follow + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--follow'" +} + +@test "kubectl_logs: rejects --follow=true (would stream)" { + run bash "$SCRIPT" my-pod --follow=true + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--follow=true'" +} + +@test "kubectl_logs: rejects --follow=false too (simpler to block the flag entirely)" { + run bash "$SCRIPT" my-pod --follow=false + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--follow=false'" +} + +# ============================================================================= +# Blocked auth/context flags +# ============================================================================= +@test "kubectl_logs: rejects --server" { + run bash "$SCRIPT" my-pod --server https://evil.example.com + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--server'" +} + +@test "kubectl_logs: rejects --server=value form" { + run bash "$SCRIPT" my-pod --server=https://evil.example.com + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--server=https://evil.example.com'" +} + +@test "kubectl_logs: rejects --kubeconfig" { + run bash "$SCRIPT" my-pod --kubeconfig /tmp/evil.yaml + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--kubeconfig'" +} + +@test "kubectl_logs: rejects --token" { + run bash "$SCRIPT" my-pod --token abc123 + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--token'" +} + +@test "kubectl_logs: rejects --as (impersonation)" { + run bash "$SCRIPT" my-pod --as cluster-admin + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--as'" +} + +@test "kubectl_logs: rejects --as-group" { + run bash "$SCRIPT" my-pod --as-group system:masters + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--as-group'" +} + +@test "kubectl_logs: rejects --context" { + run bash "$SCRIPT" my-pod --context other-cluster + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--context'" +} + +@test "kubectl_logs: rejects --insecure-skip-tls-verify" { + run bash "$SCRIPT" my-pod --insecure-skip-tls-verify + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--insecure-skip-tls-verify'" +} + +@test "kubectl_logs: blocked flag in middle of args is still detected" { + run bash "$SCRIPT" my-pod -n my-ns --token abc123 --tail 100 + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '--token'" +} + +@test "kubectl_logs: blocked streaming flag in middle of args is still detected" { + run bash "$SCRIPT" my-pod --tail 100 -f --timestamps + + [ "$status" -eq 1 ] + assert_contains "$output" "Refusing argument '-f'" +} + +# ============================================================================= +# Shell injection safety +# ============================================================================= +@test "kubectl_logs: passes args verbatim — no shell interpretation of metachars" { + # If any of these metachars were interpreted by a shell, kubectl would + # never see them as part of a single arg. Mock echoes args back as-is. + run bash "$SCRIPT" -l 'app=foo;bar|baz`whoami`' + + [ "$status" -eq 0 ] + assert_contains "$output" "kubectl-called: logs -l app=foo;bar|baz\`whoami\` -n default-ns" +} + +# ============================================================================= +# Exit code propagation +# ============================================================================= +@test "kubectl_logs: propagates kubectl exit code on failure" { + kubectl() { + echo "Error from server (NotFound): pods 'foo' not found" >&2 + return 1 + } + export -f kubectl + + run bash "$SCRIPT" foo + + [ "$status" -eq 1 ] +} diff --git a/k8s/kubectl_get b/k8s/kubectl_get new file mode 100755 index 00000000..7f55e687 --- /dev/null +++ b/k8s/kubectl_get @@ -0,0 +1,166 @@ +#!/bin/bash +# Read-only kubectl wrapper for troubleshooting. +# Hardcodes `kubectl get` so no other verb can be invoked, and rejects flags +# that change auth/server/context or could hang the script. When no namespace +# flag is supplied, defaults to $K8S_NAMESPACE so the agent can target the +# scope's namespace without repeating it. +# +# When 'secret' / 'secrets' is in the resource args, output is forced to JSON +# and piped through jq to strip .data and .stringData (the only fields that +# carry secret values). Output formats that can extract those fields directly +# (jsonpath, go-template, custom-columns) are rejected for secret queries. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "$(type -t log 2>/dev/null)" != "function" ]]; then source "$SCRIPT_DIR/logging"; fi + +usage() { + cat >&2 < + +Runs 'kubectl get' with the provided arguments. Read-only: any other verb +or auth/context override is rejected. Secret values are stripped from output. + +Examples: + $(basename "$0") pods + $(basename "$0") pods -l app=foo -o yaml + $(basename "$0") events --sort-by=.lastTimestamp -n kube-system + $(basename "$0") nodes -A + $(basename "$0") secrets # data fields stripped +EOF +} + +if [[ $# -eq 0 ]]; then + usage + exit 1 +fi + +BLOCKED_FLAGS=( + --server + --kubeconfig + --token + --as + --as-group + --as-uid + --certificate-authority + --client-certificate + --client-key + --username + --password + --user + --cluster + --context + --insecure-skip-tls-verify + -w + --watch + --watch-only +) + +is_blocked() { + local flag_name="${1%%=*}" + for blocked in "${BLOCKED_FLAGS[@]}"; do + if [[ "$flag_name" == "$blocked" ]]; then + return 0 + fi + done + return 1 +} + +# True if any arg references the 'secret' / 'secrets' resource. Handles +# comma-separated resources (secret,configmap), slash form (secret/foo) +# and apiVersion form (secrets.v1). Uppercase tolerated since kubectl is +# case-insensitive on resource names. +involves_secrets() { + local arg lower token res + for arg in "$@"; do + lower="${arg,,}" + local IFS=, + for token in $lower; do + res="${token%%/*}" + res="${res%%.*}" + if [[ "$res" == "secret" || "$res" == "secrets" ]]; then + return 0 + fi + done + unset IFS + done + return 1 +} + +# Echoes the value of -o / --output if set; empty otherwise. +get_output_format() { + local prev="" arg + for arg in "$@"; do + if [[ "$prev" == "-o" || "$prev" == "--output" ]]; then + printf '%s\n' "$arg" + return + fi + case "$arg" in + -o=*|--output=*) printf '%s\n' "${arg#*=}"; return ;; + esac + prev="$arg" + done +} + +HAS_NAMESPACE_FLAG=false + +for arg in "$@"; do + if is_blocked "$arg"; then + log error "❌ Refusing argument '$arg': flag is blocked to keep this script read-only and safe." + log error " Blocked flags: ${BLOCKED_FLAGS[*]}" + exit 1 + fi + + case "${arg%%=*}" in + -n|--namespace|-A|--all-namespaces) + HAS_NAMESPACE_FLAG=true + ;; + esac +done + +ARGS=("$@") + +if [[ "$HAS_NAMESPACE_FLAG" == "false" ]] && [[ -n "${K8S_NAMESPACE:-}" ]]; then + ARGS+=(-n "$K8S_NAMESPACE") +fi + +if involves_secrets "${ARGS[@]}"; then + USER_OUTPUT="$(get_output_format "${ARGS[@]}")" + + case "$USER_OUTPUT" in + jsonpath*|go-template*|custom-columns*) + log error "❌ Refusing -o '$USER_OUTPUT' on secrets: this format can extract .data directly, bypassing the safety filter." + log error " Use -o yaml, -o json, -o wide, or no -o flag — values are stripped from data/stringData." + exit 1 + ;; + esac + + # Strip user's -o flag (we force JSON to feed jq). + STRIPPED=() + prev="" + for arg in "${ARGS[@]}"; do + if [[ "$prev" == "-o" || "$prev" == "--output" ]]; then + prev="" + continue + fi + case "$arg" in + -o|--output) prev="$arg"; continue ;; + -o=*|--output=*) prev=""; continue ;; + esac + prev="" + STRIPPED+=("$arg") + done + STRIPPED+=(-o json) + + if [[ -n "$USER_OUTPUT" && "$USER_OUTPUT" != "json" ]]; then + log info "ℹ️ Output forced to JSON (was: $USER_OUTPUT) — secret data fields are stripped for safety." + fi + + log debug "📋 Running: kubectl get ${STRIPPED[*]} | jq " + + kubectl get "${STRIPPED[@]}" | jq 'if .items then .items |= map(del(.data, .stringData)) else del(.data, .stringData) end' + exit "${PIPESTATUS[0]}" +fi + +log debug "📋 Running: kubectl get ${ARGS[*]}" + +kubectl get "${ARGS[@]}" diff --git a/k8s/kubectl_logs b/k8s/kubectl_logs new file mode 100755 index 00000000..80d36268 --- /dev/null +++ b/k8s/kubectl_logs @@ -0,0 +1,88 @@ +#!/bin/bash +# Read-only kubectl logs wrapper for troubleshooting. +# Hardcodes `kubectl logs` so no other verb can be invoked, and rejects +# flags that change auth/server/context or turn the call into a stream +# (--follow / -f), which would hang the script. When no namespace flag +# is supplied, defaults to $K8S_NAMESPACE so the agent can target the +# scope's namespace without repeating it. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "$(type -t log 2>/dev/null)" != "function" ]]; then source "$SCRIPT_DIR/logging"; fi + +usage() { + cat >&2 < + +Runs 'kubectl logs' with the provided arguments. Read-only: any other +verb, auth/context override, or streaming flag (--follow / -f) is +rejected to keep invocations bounded. + +Examples: + $(basename "$0") my-pod + $(basename "$0") my-pod -c my-container --tail 200 + $(basename "$0") my-pod --previous + $(basename "$0") -l app=foo --tail 100 + $(basename "$0") my-pod --since 1h --timestamps +EOF +} + +if [[ $# -eq 0 ]]; then + usage + exit 1 +fi + +BLOCKED_FLAGS=( + --server + --kubeconfig + --token + --as + --as-group + --as-uid + --certificate-authority + --client-certificate + --client-key + --username + --password + --user + --cluster + --context + --insecure-skip-tls-verify + -f + --follow +) + +is_blocked() { + local flag_name="${1%%=*}" + for blocked in "${BLOCKED_FLAGS[@]}"; do + if [[ "$flag_name" == "$blocked" ]]; then + return 0 + fi + done + return 1 +} + +HAS_NAMESPACE_FLAG=false + +for arg in "$@"; do + if is_blocked "$arg"; then + log error "❌ Refusing argument '$arg': flag is blocked to keep this script read-only and non-streaming." + log error " Blocked flags: ${BLOCKED_FLAGS[*]}" + exit 1 + fi + + case "${arg%%=*}" in + -n|--namespace) + HAS_NAMESPACE_FLAG=true + ;; + esac +done + +ARGS=("$@") + +if [[ "$HAS_NAMESPACE_FLAG" == "false" ]] && [[ -n "${K8S_NAMESPACE:-}" ]]; then + ARGS+=(-n "$K8S_NAMESPACE") +fi + +log debug "📋 Running: kubectl logs ${ARGS[*]}" + +kubectl logs "${ARGS[@]}"