diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dda922d..135e7672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Run Helm unit tests run: ./eoapi-cli test unit + - name: Check container images for root user + run: ./eoapi-cli test images + integration-tests: name: Integration tests needs: fast-checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 35983870..db6e69a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Ensured non-root container images [#382](https://github.com/developmentseed/eoapi-k8s/pull/382) + ### Fixed - Fixed Helm template to check queryables `file` field with schema validation [#380](https://github.com/developmentseed/eoapi-k8s/pull/380) diff --git a/charts/eoapi/templates/_helpers/services.tpl b/charts/eoapi/templates/_helpers/services.tpl index 430a87c5..36c19cd6 100644 --- a/charts/eoapi/templates/_helpers/services.tpl +++ b/charts/eoapi/templates/_helpers/services.tpl @@ -34,7 +34,7 @@ Helper function for common init containers to wait for pgstac jobs {{- if .Values.pgstacBootstrap.enabled }} initContainers: - name: wait-for-pgstac-jobs - image: alpine/k8s:1.28.0 + image: bitnami/kubectl:latest env: {{- include "eoapi.commonEnvVars" (dict "service" "init" "root" .) | nindent 2 }} resources: diff --git a/charts/eoapi/templates/services/doc-server.yaml b/charts/eoapi/templates/services/doc-server.yaml index b529f5bf..4e7bf81e 100644 --- a/charts/eoapi/templates/services/doc-server.yaml +++ b/charts/eoapi/templates/services/doc-server.yaml @@ -40,12 +40,12 @@ spec: spec: containers: - name: doc-server - image: nginx:alpine + image: nginxinc/nginx-unprivileged:alpine volumeMounts: - name: {{ .Release.Name }}-doc-html mountPath: /usr/share/nginx/html ports: - - containerPort: 80 + - containerPort: 8080 volumes: - name: {{ .Release.Name }}-doc-html configMap: @@ -71,6 +71,6 @@ spec: ports: - protocol: TCP port: 80 - targetPort: 80 + targetPort: 8080 --- {{- end }} diff --git a/scripts/test.sh b/scripts/test.sh index 8fb3e10e..54a3f271 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -25,6 +25,7 @@ COMMANDS: schema Validate Helm chart schema lint Run Helm lint on chart unit Run Helm unit tests + images Check container images for root user integration Run integration tests with pytest notification Run notification tests with database access autoscaling Run autoscaling tests with pytest @@ -115,6 +116,14 @@ test_unit() { fi } +test_images() { + log_info "Checking container images for root user..." + + check_requirements docker helm || return 1 + + "${SCRIPT_DIR}/test/images.sh" +} + test_integration() { local pytest_args="${1:-}" export NAMESPACE="$NAMESPACE" @@ -147,6 +156,7 @@ test_all() { test_schema || ((failed++)) test_lint || ((failed++)) test_unit || ((failed++)) + test_images || ((failed++)) if validate_cluster 2>/dev/null; then test_integration || ((failed++)) @@ -192,7 +202,7 @@ main() { pytest_args="$2" shift 2 ;; - schema|lint|unit|notification|integration|autoscaling|all) + schema|lint|unit|images|notification|integration|autoscaling|all) command="$1" shift break @@ -217,6 +227,9 @@ main() { unit) test_unit ;; + images) + test_images + ;; integration) test_integration "$pytest_args" ;; diff --git a/scripts/test/images.sh b/scripts/test/images.sh new file mode 100755 index 00000000..dd18ad2a --- /dev/null +++ b/scripts/test/images.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +# eoAPI Container Image Root User Check + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +source "${SCRIPT_DIR}/../lib/common.sh" + +CHART_PATH="${PROJECT_ROOT}/charts/eoapi" +PROFILE_PATH="${PROJECT_ROOT}/charts/eoapi/profiles/experimental.yaml" + +echo "======================================" +echo "Container Image Root User Audit" +echo "======================================" +echo "" + +# Extract images from Helm templates +if ! command -v helm &>/dev/null; then + log_error "helm is required but not installed" + exit 1 +fi + +# Extract images, excluding testing-only images +# Filters out: Helm test hooks, mock/sample/test images +if [[ ! -f "$PROFILE_PATH" ]]; then + log_error "Experimental profile not found: $PROFILE_PATH" + exit 1 +fi + +# Update Helm dependencies if needed +log_debug "Updating Helm chart dependencies..." +if ! helm dependency update "$CHART_PATH" &>/dev/null; then + log_warn "Helm dependency update failed, continuing anyway..." +fi + +rendered_yaml=$(helm template test-release "$CHART_PATH" \ + --set gitSha=test \ + -f "$PROFILE_PATH" \ + --set stac-auth-proxy.enabled=false \ + 2>&1 || \ +helm template test-release "$CHART_PATH" \ + --set gitSha=test \ + -f "$PROFILE_PATH" \ + --set stac-auth-proxy.env.OIDC_DISCOVERY_URL=https://dummy.example.com/.well-known/openid-configuration \ + 2>&1) + +if [[ -z "$rendered_yaml" ]] || echo "$rendered_yaml" | grep -q "Error:"; then + log_error "Failed to render Helm templates" + echo "$rendered_yaml" | head -20 + exit 1 +fi + +images=() +while IFS= read -r line; do + [[ -n "$line" ]] && images+=("$line") +done < <( + # Extract images with context to identify test hooks + echo "$rendered_yaml" | awk ' + BEGIN { in_test_hook = 0 } + /^---/ { in_test_hook = 0 } + /helm\.sh\/hook.*test/ { in_test_hook = 1 } + /^\s+(- )?image:/ { + image = $0 + gsub(/.*image:\s*/, "", image) + gsub(/["'\''"]/, "", image) + gsub(/^[[:space:]]+/, "", image) + if (image && image != "") { + # Skip if in test hook + if (!in_test_hook) { + # Skip images with testing patterns (but allow "test-release" in image names) + if (image !~ /\/mock/ && + image !~ /\/sample/ && + image !~ /\/bats\// && + image !~ /mock-/ && + image !~ /-mock/ && + image !~ /sample/ && + image !~ /bats:/) { + print image + } + } + } + } + ' | sort -u +) + +if [[ ${#images[@]} -eq 0 ]]; then + log_error "No images found in Helm templates" + log_info "Rendered YAML length: ${#rendered_yaml} characters" + exit 1 +fi + +log_debug "Found ${#images[@]} images to check" + +total=0 +root_count=0 +non_root_count=0 +error_count=0 + +check_image() { + local image=$1 + local user + + echo -n "Checking: $image ... " + + if docker pull "$image" &>/dev/null; then + if ! user=$(docker inspect "$image" --format='{{.Config.User}}' 2>/dev/null); then + echo -e "${RED}ERROR${NC} (Failed to inspect)" + ((error_count++)) + return + fi + + if [ -z "$user" ] || [ "$user" == "0" ] || [ "$user" == "root" ] || [ "$user" == "0:0" ]; then + echo -e "${RED}⚠️ RUNS AS ROOT${NC} (User: ${user:-not set})" + ((root_count++)) + else + echo -e "${GREEN}✓ Non-root${NC} (User: $user)" + ((non_root_count++)) + fi + else + echo -e "${YELLOW}SKIP${NC} (Failed to pull image)" + ((error_count++)) + fi +} + +for image in "${images[@]}"; do + check_image "$image" || true + ((total++)) || true +done + +echo "" +echo "======================================" +echo "Summary" +echo "======================================" +echo "Total images checked: $total" +echo -e "${RED}Running as root: $root_count${NC}" +echo -e "${GREEN}Running as non-root: $non_root_count${NC}" +echo -e "${YELLOW}Errors/Skipped: $error_count${NC}" +echo "" + +if [ $root_count -gt 0 ]; then + echo -e "${RED}⚠️ WARNING: $root_count image(s) run as root user${NC}" + exit 1 +else + echo -e "${GREEN}✓ All images run as non-root user${NC}" + exit 0 +fi