#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TARGET_IMAGE="${1:-geointel-ci:local}" OUTPUT="${2:-artifacts/geointel-container-vulnerabilities.json}" IMAGE_ARCHIVE="${GEOINTEL_IMAGE_ARCHIVE:-artifacts/geointel-image.tar}" ARCHIVE_ID_FILE="${IMAGE_ARCHIVE}.image-id" KEEP_ARCHIVE="${GEOINTEL_KEEP_IMAGE_ARCHIVE:-false}" TRIVY_IMAGE="aquasec/trivy:0.70.0@sha256:be1190afcb28352bfddc4ddeb71470835d16462af68d310f9f4bca710961a41e" CACHE_DIR="${GEOINTEL_TRIVY_CACHE:-$ROOT/.cache/trivy}" PYTHON_CMD="${PYTHON_BIN:-python3}" IGNORE_FILE="$CACHE_DIR/geointel-trivy-ignore.txt" ARCHIVE_CREATED=false PARTIAL_ARCHIVE="" cleanup() { rm -f -- "$IGNORE_FILE" if [ -n "$PARTIAL_ARCHIVE" ]; then rm -f -- "$PARTIAL_ARCHIVE" fi if [ "$ARCHIVE_CREATED" = true ] && [ "$KEEP_ARCHIVE" != true ]; then rm -f -- "$ROOT/$IMAGE_ARCHIVE" "$ROOT/$ARCHIVE_ID_FILE" fi } trap cleanup EXIT for candidate in "$OUTPUT" "$IMAGE_ARCHIVE"; do case "$candidate" in /*|*..*) echo "Scan paths must be repository-relative and may not contain '..'." >&2 exit 1 ;; esac done IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$TARGET_IMAGE")" test -n "$IMAGE_ID" case "$CACHE_DIR" in "$ROOT"/*) CACHE_RELATIVE="${CACHE_DIR#"$ROOT/"}" ;; *) echo "Trivy cache must remain inside the repository workspace." >&2 exit 1 ;; esac case "$CACHE_RELATIVE" in *..*) echo "Trivy cache may not contain '..'." >&2 exit 1 ;; esac mkdir -p "$ROOT/$(dirname "$OUTPUT")" "$ROOT/$(dirname "$IMAGE_ARCHIVE")" "$CACHE_DIR" ARCHIVE_ID="" if [ -s "$ROOT/$ARCHIVE_ID_FILE" ]; then ARCHIVE_ID="$(tr -d '[:space:]' < "$ROOT/$ARCHIVE_ID_FILE")" fi if [ ! -s "$ROOT/$IMAGE_ARCHIVE" ] || [ "$ARCHIVE_ID" != "$IMAGE_ID" ]; then rm -f -- "$ROOT/$IMAGE_ARCHIVE" "$ROOT/$ARCHIVE_ID_FILE" PARTIAL_ARCHIVE="$ROOT/${IMAGE_ARCHIVE}.partial.$$" docker save "$IMAGE_ID" --output "$PARTIAL_ARCHIVE" mv "$PARTIAL_ARCHIVE" "$ROOT/$IMAGE_ARCHIVE" PARTIAL_ARCHIVE="" printf '%s\n' "$IMAGE_ID" > "$ROOT/$ARCHIVE_ID_FILE" ARCHIVE_CREATED=true fi WORKDIR="/workspace" WORKSPACE_ARGS=(-v "$ROOT:/workspace") if [[ -n "${HOSTNAME:-}" ]] && docker inspect --type container "$HOSTNAME" >/dev/null 2>&1; then WORKDIR="$ROOT" WORKSPACE_ARGS=(--volumes-from "$HOSTNAME") fi CONTAINER_CACHE_DIR="$WORKDIR/$CACHE_RELATIVE" CONTAINER_IGNORE_FILE="$CONTAINER_CACHE_DIR/geointel-trivy-ignore.txt" "$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py" "$PYTHON_CMD" "$ROOT/scripts/verify_security_exceptions.py" \ --print-container-ids | tr -d '\r' > "$IGNORE_FILE" # Keep the complete report, including vulnerabilities without an available fix. docker run --rm \ --user 0:0 \ "${WORKSPACE_ARGS[@]}" \ "$TRIVY_IMAGE" \ image \ --input "$WORKDIR/$IMAGE_ARCHIVE" \ --scanners vuln \ --timeout 20m \ --skip-version-check \ --format json \ --cache-dir "$CONTAINER_CACHE_DIR" \ --output "$WORKDIR/$OUTPUT" # Release policy: fixed HIGH/CRITICAL findings block the build. Unfixed findings # remain visible in the full report and must be reviewed before release. The # postgres base image's Go gosu binary remains discoverable in a lower layer, # but the final filesystem replaces it with the audited setpriv shell wrapper. docker run --rm \ --user 0:0 \ "${WORKSPACE_ARGS[@]}" \ "$TRIVY_IMAGE" \ image \ --input "$WORKDIR/$IMAGE_ARCHIVE" \ --scanners vuln \ --timeout 20m \ --skip-version-check \ --ignore-unfixed \ --cache-dir "$CONTAINER_CACHE_DIR" \ --ignorefile "$CONTAINER_IGNORE_FILE" \ --skip-files /usr/local/bin/gosu \ --severity HIGH,CRITICAL \ --exit-code 1 test -s "$ROOT/$OUTPUT" echo "Container vulnerability report written to $OUTPUT for $IMAGE_ID"