chore(deps): update release-drafter/release-drafter digest to eada3c9 #3511
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docker Build, Publish & Test | |
| # This workflow replaced .github/workflows/docker-publish.yml (deleted in commit f640524b on Dec 21, 2025) | |
| # Enhancements over the previous workflow: | |
| # - SBOM generation and attestation for supply chain security | |
| # - CVE-2025-68156 verification for Caddy security patches | |
| # - Enhanced PR handling with dedicated scanning | |
| # - Improved workflow orchestration with supply-chain-verify.yml | |
| # | |
| # PHASE 1 OPTIMIZATION (February 2026): | |
| # - PR images now pushed to GHCR registry (enables downstream workflow consumption) | |
| # - Stable PR tagging: pr-{number} (freshness gate prevents stale scans) | |
| # - Feature branch tagging: {sanitized-branch-name}-{short-sha} (enables unique testing) | |
| # - Tag sanitization per spec Section 3.2 (handles special chars, slashes, etc.) | |
| # - Mandatory security scanning for PR images (blocks on CRITICAL/HIGH vulnerabilities) | |
| # - Retry logic for registry pushes (3 attempts, 10s wait - handles transient failures) | |
| # - Enhanced metadata labels for image freshness validation | |
| # - Artifact upload retained as fallback during migration period | |
| # - Reduced build timeout from 30min to 25min for faster feedback (with retry buffer) | |
| # | |
| # See: docs/plans/current_spec.md (Section 4.1 - docker-build.yml changes) | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main, development] | |
| workflow_dispatch: | |
| workflow_run: | |
| workflows: ["Docker Lint"] | |
| types: [completed] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.ref_name }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| env: | |
| GHCR_REGISTRY: ghcr.io | |
| DOCKERHUB_REGISTRY: docker.io | |
| IMAGE_NAME: wikid82/charon | |
| TRIVY_SARIF_CATEGORY: .github/workflows/docker-build.yml:trivy-image-scan | |
| TRIGGER_EVENT: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }} | |
| TRIGGER_HEAD_BRANCH: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }} | |
| TRIGGER_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} | |
| TRIGGER_REF: ${{ github.event_name == 'workflow_run' && format('refs/heads/{0}', github.event.workflow_run.head_branch) || github.ref }} | |
| TRIGGER_HEAD_REF: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref }} | |
| TRIGGER_PR_NUMBER: ${{ github.event_name == 'pull_request' && format('{0}', github.event.pull_request.number) || '' }} | |
| TRIGGER_ACTOR: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.actor.login || github.actor }} | |
| TRIGGER_BASE_REF: ${{ github.event_name == 'workflow_run' && '' || github.base_ref }} | |
| jobs: | |
| build-and-push: | |
| if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.name == 'Docker Lint' && github.event.workflow_run.path == '.github/workflows/docker-lint.yml') }} | |
| env: | |
| HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 # Phase 1: Reduced timeout for faster feedback | |
| permissions: | |
| contents: read | |
| packages: write | |
| security-events: write | |
| id-token: write # Required for SBOM attestation | |
| attestations: write # Required for SBOM attestation | |
| outputs: | |
| skip_build: ${{ steps.skip.outputs.skip_build }} | |
| digest: ${{ steps.build-and-push.outputs.digest }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ env.TRIGGER_HEAD_SHA }} | |
| - name: Normalize image name | |
| run: | | |
| IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') | |
| echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITHUB_ENV" | |
| - name: Determine skip condition | |
| id: skip | |
| env: | |
| ACTOR: ${{ env.TRIGGER_ACTOR }} | |
| EVENT: ${{ env.TRIGGER_EVENT }} | |
| REF: ${{ env.TRIGGER_REF }} | |
| HEAD_REF: ${{ env.TRIGGER_HEAD_REF }} | |
| PR_NUMBER: ${{ env.TRIGGER_PR_NUMBER }} | |
| REPO: ${{ github.repository }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| should_skip=false | |
| pr_title="" | |
| force_refresh=false | |
| changed_files="" | |
| head_msg=$(git log -1 --pretty=%s) | |
| if [ "$EVENT" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then | |
| pr_title=$(curl -sS \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.title // empty') | |
| files_url="https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100&page=1" | |
| while [ -n "$files_url" ]; do | |
| response_file=$(mktemp) | |
| headers_file=$(mktemp) | |
| if ! curl -fsS \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -D "$headers_file" \ | |
| "$files_url" > "$response_file"; then | |
| echo "Failed to fetch changed files from GitHub API, forcing refresh" | |
| force_refresh=true | |
| rm -f "$response_file" "$headers_file" | |
| break | |
| fi | |
| page_files=$(jq -r '.[].filename // empty' "$response_file") | |
| if [ -n "$page_files" ]; then | |
| if [ -n "$changed_files" ]; then | |
| changed_files="${changed_files}\n${page_files}" | |
| else | |
| changed_files="${page_files}" | |
| fi | |
| fi | |
| files_url=$(awk 'BEGIN{IGNORECASE=1} /^link:/ {print}' "$headers_file" \ | |
| | sed -n 's/.*<\([^>]*\)>; rel="next".*/\1/p' | head -n 1) | |
| rm -f "$response_file" "$headers_file" | |
| done | |
| else | |
| changed_files=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || true) | |
| fi | |
| if echo "$changed_files" | grep -E '^(Dockerfile|\.github/workflows/(docker-build|docker-lint|security-weekly-rebuild|supply-chain-verify|supply-chain-pr|nightly-build)\.yml|trivy\.yaml|\.grype\.yaml)$' >/dev/null 2>&1; then | |
| force_refresh=true | |
| fi | |
| if [ "$ACTOR" = "renovate[bot]" ]; then should_skip=true; fi | |
| if echo "$head_msg" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi | |
| if echo "$head_msg" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi | |
| if echo "$pr_title" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi | |
| if echo "$pr_title" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi | |
| if [[ "$force_refresh" == "true" ]]; then | |
| should_skip=false | |
| echo "Force refresh enabled due to Docker/security workflow changes" | |
| fi | |
| # Always build on feature branches to ensure artifacts for testing | |
| # For PRs: use HEAD_REF (actual source branch) | |
| # For pushes: use REF (refs/heads/branch-name) | |
| is_feature_push=false | |
| if [[ "$EVENT" != "pull_request" && "$REF" == refs/heads/feature/* ]]; then | |
| should_skip=false | |
| is_feature_push=true | |
| echo "Force building on feature branch (push)" | |
| elif [[ "$HEAD_REF" == feature/* ]]; then | |
| should_skip=false | |
| echo "Force building on feature branch (PR)" | |
| fi | |
| { | |
| echo "skip_build=$should_skip" | |
| echo "is_feature_push=$is_feature_push" | |
| echo "force_refresh=$force_refresh" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Set up QEMU | |
| if: steps.skip.outputs.skip_build != 'true' | |
| uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 | |
| - name: Set up Docker Buildx | |
| if: steps.skip.outputs.skip_build != 'true' | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 | |
| - name: Resolve Alpine base image digest | |
| if: steps.skip.outputs.skip_build != 'true' | |
| id: alpine | |
| run: | | |
| ALPINE_TAG=$(grep -m1 'ARG ALPINE_IMAGE=' Dockerfile | sed 's/ARG ALPINE_IMAGE=alpine://' | cut -d'@' -f1) | |
| docker pull "alpine:${ALPINE_TAG}" | |
| DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "alpine:${ALPINE_TAG}") | |
| echo "image=$DIGEST" >> "$GITHUB_OUTPUT" | |
| - name: Log in to GitHub Container Registry | |
| if: steps.skip.outputs.skip_build != 'true' | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 | |
| with: | |
| registry: ${{ env.GHCR_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Log in to Docker Hub | |
| if: steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true' | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 | |
| with: | |
| registry: docker.io | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Compute branch tags | |
| if: steps.skip.outputs.skip_build != 'true' | |
| id: branch-tags | |
| run: | | |
| if [[ "$TRIGGER_EVENT" == "pull_request" ]]; then | |
| BRANCH_NAME="${TRIGGER_HEAD_REF}" | |
| else | |
| BRANCH_NAME="${TRIGGER_REF#refs/heads/}" | |
| fi | |
| SHORT_SHA="$(echo "${{ env.TRIGGER_HEAD_SHA }}" | cut -c1-7)" | |
| sanitize_tag() { | |
| local raw="$1" | |
| local max_len="$2" | |
| local sanitized | |
| sanitized=$(echo "$raw" | tr '[:upper:]' '[:lower:]') | |
| sanitized=${sanitized//[^a-z0-9-]/-} | |
| while [[ "$sanitized" == *"--"* ]]; do | |
| sanitized=${sanitized//--/-} | |
| done | |
| sanitized=${sanitized##[^a-z0-9]*} | |
| sanitized=${sanitized%%[^a-z0-9-]*} | |
| if [ -z "$sanitized" ]; then | |
| sanitized="branch" | |
| fi | |
| sanitized=$(echo "$sanitized" | cut -c1-"$max_len") | |
| sanitized=${sanitized##[^a-z0-9]*} | |
| if [ -z "$sanitized" ]; then | |
| sanitized="branch" | |
| fi | |
| echo "$sanitized" | |
| } | |
| SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 128) | |
| BASE_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 120) | |
| BRANCH_SHA_TAG="${BASE_BRANCH}-${SHORT_SHA}" | |
| if [[ "$TRIGGER_EVENT" == "pull_request" ]]; then | |
| if [[ "$BRANCH_NAME" == feature/* ]]; then | |
| echo "pr_feature_branch_sha_tag=${BRANCH_SHA_TAG}" >> "$GITHUB_OUTPUT" | |
| fi | |
| else | |
| echo "branch_sha_tag=${BRANCH_SHA_TAG}" >> "$GITHUB_OUTPUT" | |
| if [[ "$TRIGGER_REF" == refs/heads/feature/* ]]; then | |
| echo "feature_branch_tag=${SANITIZED_BRANCH}" >> "$GITHUB_OUTPUT" | |
| echo "feature_branch_sha_tag=${BRANCH_SHA_TAG}" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| - name: Resolve security scan image reference (contract) | |
| if: steps.skip.outputs.skip_build != 'true' | |
| id: pr_image_ref_output | |
| run: | | |
| set -euo pipefail | |
| sanitize_branch() { | |
| local raw_branch="$1" | |
| local sanitized_branch | |
| sanitized_branch=$(echo "${raw_branch}" | tr '[:upper:]' '[:lower:]') | |
| sanitized_branch=${sanitized_branch//[^a-z0-9-]/-} | |
| while [[ "${sanitized_branch}" == *"--"* ]]; do | |
| sanitized_branch=${sanitized_branch//--/-} | |
| done | |
| sanitized_branch=${sanitized_branch##[^a-z0-9]*} | |
| sanitized_branch=${sanitized_branch%%[^a-z0-9-]*} | |
| if [[ -z "${sanitized_branch}" ]]; then | |
| sanitized_branch="branch" | |
| fi | |
| echo "${sanitized_branch}" | |
| } | |
| IMAGE_TAG="" | |
| if [[ "${TRIGGER_EVENT}" == "pull_request" ]]; then | |
| PR_NUMBER="${TRIGGER_PR_NUMBER}" | |
| if [[ "${GITHUB_EVENT_NAME}" == "workflow_run" ]]; then | |
| mapfile -t WORKFLOW_RUN_PR_NUMBERS < <(jq -r '.workflow_run.pull_requests[].number // empty' "$GITHUB_EVENT_PATH") | |
| if [[ "${#WORKFLOW_RUN_PR_NUMBERS[@]}" != "1" ]]; then | |
| echo "❌ ERROR: Expected exactly one workflow_run pull request number, found ${#WORKFLOW_RUN_PR_NUMBERS[@]}" | |
| exit 1 | |
| fi | |
| PR_NUMBER="${WORKFLOW_RUN_PR_NUMBERS[0]}" | |
| fi | |
| if [[ -z "${PR_NUMBER}" || "${PR_NUMBER}" == "null" ]]; then | |
| echo "❌ ERROR: Missing pull request number for stable security scan tag" | |
| exit 1 | |
| fi | |
| IMAGE_TAG="pr-${PR_NUMBER}" | |
| elif [[ "${TRIGGER_REF}" == refs/heads/nightly ]]; then | |
| IMAGE_TAG="nightly" | |
| elif [[ "${TRIGGER_REF}" == refs/heads/main ]]; then | |
| IMAGE_TAG="main" | |
| elif [[ "${TRIGGER_REF}" == refs/heads/development ]]; then | |
| IMAGE_TAG="development" | |
| else | |
| BRANCH_NAME="${TRIGGER_HEAD_BRANCH:-${TRIGGER_REF#refs/heads/}}" | |
| IMAGE_TAG="branch-$(sanitize_branch "${BRANCH_NAME}")" | |
| fi | |
| IMAGE_REF="${GHCR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" | |
| echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" | |
| echo "image_ref=${IMAGE_REF}" >> "$GITHUB_OUTPUT" | |
| echo "Resolved security scan image reference: ${IMAGE_REF}" | |
| { | |
| echo "## PR Image Reference Diagnostics" | |
| echo "" | |
| echo "- Image tag: ${IMAGE_TAG}" | |
| echo "- Emitted image_ref: ${IMAGE_REF}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Generate Docker metadata | |
| id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: | | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} | |
| ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }} | |
| tags: | | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=semver,pattern={{major}} | |
| type=raw,value=latest,enable=${{ env.TRIGGER_REF == 'refs/heads/main' }} | |
| type=raw,value=dev,enable=${{ env.TRIGGER_REF == 'refs/heads/development' }} | |
| type=raw,value=nightly,enable=${{ env.TRIGGER_REF == 'refs/heads/nightly' }} | |
| type=raw,value=beta,enable=${{ env.TRIGGER_EVENT == 'pull_request' && env.TRIGGER_BASE_REF == 'development' }} | |
| type=raw,value=${{ steps.pr_image_ref_output.outputs.image_tag }},enable=${{ steps.pr_image_ref_output.outputs.image_tag != '' }} | |
| type=raw,value=${{ steps.branch-tags.outputs.feature_branch_tag }},enable=${{ env.TRIGGER_EVENT != 'pull_request' && startsWith(env.TRIGGER_REF, 'refs/heads/feature/') && steps.branch-tags.outputs.feature_branch_tag != '' }} | |
| type=raw,value=${{ steps.branch-tags.outputs.branch_sha_tag }},enable=${{ env.TRIGGER_EVENT != 'pull_request' && steps.branch-tags.outputs.branch_sha_tag != '' }} | |
| type=sha,format=short,prefix=,suffix=,enable=${{ env.TRIGGER_EVENT != 'pull_request' && (env.TRIGGER_REF == 'refs/heads/main' || env.TRIGGER_REF == 'refs/heads/development' || env.TRIGGER_REF == 'refs/heads/nightly') }} | |
| flavor: | | |
| latest=false | |
| labels: | | |
| org.opencontainers.image.revision=${{ env.TRIGGER_HEAD_SHA }} | |
| io.charon.pr.number=${{ env.TRIGGER_PR_NUMBER }} | |
| io.charon.build.timestamp=${{ github.event.repository.updated_at }} | |
| io.charon.feature.branch=${{ steps.branch-tags.outputs.feature_branch_tag }} | |
| - name: Assert PR output contract | |
| if: steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request' | |
| run: | | |
| IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}" | |
| if [[ -z "${IMAGE_REF}" ]]; then | |
| echo "❌ ERROR: image_ref output contract violated (empty output)" | |
| exit 1 | |
| fi | |
| # Phase 1 Optimization: Build once, test many | |
| # - For PRs: Multi-platform (amd64, arm64) + stable security-scan tag (pr-{number}) | |
| # - For feature branches: Multi-platform (amd64, arm64) + sanitized tags ({branch}-{short-sha}) | |
| # - For main/dev/nightly: Multi-platform (amd64, arm64) for production | |
| # - Always push to registry (enables downstream workflow consumption) | |
| # - Retry logic handles transient registry failures (3 attempts, 10s wait) | |
| # See: docs/plans/current_spec.md Section 4.1 | |
| - name: Build and push Docker image (with retry) | |
| if: steps.skip.outputs.skip_build != 'true' | |
| id: build-and-push | |
| uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 | |
| with: | |
| timeout_minutes: 25 | |
| max_attempts: 3 | |
| retry_wait_seconds: 10 | |
| retry_on: error | |
| warning_on_retry: true | |
| command: | | |
| set -euo pipefail | |
| echo "🔨 Building Docker image with retry logic..." | |
| PLATFORMS="linux/amd64,linux/arm64" | |
| echo "Platform: ${PLATFORMS}" | |
| # Build tag arguments array from metadata output (properly quoted) | |
| TAG_ARGS_ARRAY=() | |
| while IFS= read -r tag; do | |
| [[ -n "$tag" ]] && TAG_ARGS_ARRAY+=("--tag" "$tag") | |
| done <<< "${{ steps.meta.outputs.tags }}" | |
| # Build label arguments array from metadata output (properly quoted) | |
| LABEL_ARGS_ARRAY=() | |
| while IFS= read -r label; do | |
| [[ -n "$label" ]] && LABEL_ARGS_ARRAY+=("--label" "$label") | |
| done <<< "${{ steps.meta.outputs.labels }}" | |
| # Build the complete command as an array (handles spaces in label values correctly) | |
| BUILD_CMD=( | |
| docker buildx build | |
| --platform "${PLATFORMS}" | |
| --push | |
| "${TAG_ARGS_ARRAY[@]}" | |
| "${LABEL_ARGS_ARRAY[@]}" | |
| --no-cache | |
| --pull | |
| --build-arg "VERSION=${{ steps.meta.outputs.version }}" | |
| --build-arg "BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}" | |
| --build-arg "VCS_REF=${{ env.TRIGGER_HEAD_SHA }}" | |
| --build-arg "ALPINE_IMAGE=${{ steps.alpine.outputs.image }}" | |
| --iidfile /tmp/image-digest.txt | |
| . | |
| ) | |
| # Execute build | |
| echo "Executing: ${BUILD_CMD[*]}" | |
| "${BUILD_CMD[@]}" | |
| # Extract digest for downstream jobs (format: sha256:xxxxx) | |
| DIGEST=$(cat /tmp/image-digest.txt) | |
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | |
| echo "✅ Build complete. Digest: ${DIGEST}" | |
| # For PRs only, pull the image back locally for artifact creation | |
| # Feature branches now build multi-platform and cannot be loaded locally | |
| # This enables backward compatibility with workflows that use artifacts | |
| if [[ "${{ env.TRIGGER_EVENT }}" == "pull_request" ]]; then | |
| echo "📥 Pulling image back for artifact creation..." | |
| PR_IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}" | |
| if [[ -z "${PR_IMAGE_REF}" ]]; then | |
| echo "❌ ERROR: image_ref output contract violated during pull-back" | |
| exit 1 | |
| fi | |
| docker pull "${PR_IMAGE_REF}" | |
| echo "✅ Image pulled: ${PR_IMAGE_REF}" | |
| fi | |
| # Use the stable security scan reference so the saved artifact matches the | |
| # image that the PR scan job will pull and validate. | |
| - name: Save Docker Image as Artifact | |
| if: success() && steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request' | |
| run: | | |
| IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}" | |
| if [[ -z "${IMAGE_REF}" ]]; then | |
| echo "❌ ERROR: image_ref output contract violated (empty image ref)" | |
| exit 1 | |
| fi | |
| echo "🔍 Detected image ref: ${IMAGE_REF}" | |
| # Verify the image exists locally | |
| if ! docker image inspect "${IMAGE_REF}" >/dev/null 2>&1; then | |
| echo "❌ ERROR: Image ${IMAGE_REF} not found locally" | |
| echo "📋 Available images:" | |
| docker images | |
| exit 1 | |
| fi | |
| # Save the image using the exact tag from metadata | |
| echo "💾 Saving image: ${IMAGE_REF}" | |
| docker save "${IMAGE_REF}" -o /tmp/charon-pr-image.tar | |
| # Verify the artifact was created | |
| echo "✅ Artifact created:" | |
| ls -lh /tmp/charon-pr-image.tar | |
| - name: Upload Image Artifact | |
| if: success() && steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: ${{ env.TRIGGER_EVENT == 'pull_request' && format('pr-image-{0}', env.TRIGGER_PR_NUMBER) || 'push-image' }} | |
| path: /tmp/charon-pr-image.tar | |
| retention-days: 1 # Only needed for workflow duration | |
| - name: Verify Caddy Security Patches (CVE-2025-68156) | |
| if: steps.skip.outputs.skip_build != 'true' | |
| timeout-minutes: 2 | |
| continue-on-error: true | |
| run: | | |
| echo "🔍 Verifying Caddy binary contains patched expr-lang/expr@v1.17.7..." | |
| echo "" | |
| # Determine the image reference based on event type | |
| if [ "${{ env.TRIGGER_EVENT }}" = "pull_request" ]; then | |
| IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}" | |
| if [ -z "${IMAGE_REF}" ]; then | |
| echo "❌ ERROR: Failed to load PR image reference from image_ref output" | |
| exit 1 | |
| fi | |
| echo "Using PR image: $IMAGE_REF" | |
| else | |
| if [ -z "${{ steps.build-and-push.outputs.digest }}" ]; then | |
| echo "❌ ERROR: Build digest is empty" | |
| exit 1 | |
| fi | |
| IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}" | |
| echo "Using digest: $IMAGE_REF" | |
| fi | |
| echo "" | |
| echo "==> Caddy version:" | |
| timeout 30s docker run --rm --pull=never "$IMAGE_REF" caddy version || echo "⚠️ Caddy version check timed out or failed" | |
| echo "" | |
| echo "==> Extracting Caddy binary for inspection..." | |
| CONTAINER_ID=$(docker create --pull=never "$IMAGE_REF") | |
| docker cp "${CONTAINER_ID}:/usr/bin/caddy" ./caddy_binary | |
| docker rm "$CONTAINER_ID" | |
| # Determine the image reference based on event type | |
| if [ "${{ env.TRIGGER_EVENT }}" = "pull_request" ]; then | |
| IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}" | |
| if [ -z "${IMAGE_REF}" ]; then | |
| echo "❌ ERROR: Failed to load PR image reference from image_ref output" | |
| exit 1 | |
| fi | |
| echo "Using PR image: $IMAGE_REF" | |
| else | |
| if [ -z "${{ steps.build-and-push.outputs.digest }}" ]; then | |
| echo "❌ ERROR: Build digest is empty" | |
| exit 1 | |
| fi | |
| IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}" | |
| echo "Using digest: $IMAGE_REF" | |
| fi | |
| echo "" | |
| echo "==> Checking if Go toolchain is available locally..." | |
| if command -v go >/dev/null 2>&1; then | |
| echo "✅ Go found locally, inspecting binary dependencies..." | |
| go version -m ./caddy_binary > caddy_deps.txt | |
| echo "" | |
| echo "==> Searching for expr-lang/expr dependency:" | |
| if grep -i "expr-lang/expr" caddy_deps.txt; then | |
| EXPR_VERSION=$(grep "expr-lang/expr" caddy_deps.txt | awk '{print $3}') | |
| echo "" | |
| echo "✅ Found expr-lang/expr: $EXPR_VERSION" | |
| # Check if version is v1.17.7 or higher (vulnerable version is v1.16.9) | |
| if echo "$EXPR_VERSION" | grep -E "^v1\.(1[7-9]|[2-9][0-9])\.[0-9]+$" >/dev/null; then | |
| echo "✅ PASS: expr-lang version $EXPR_VERSION is patched (>= v1.17.7)" | |
| else | |
| echo "⚠️ WARNING: expr-lang version $EXPR_VERSION may be vulnerable (< v1.17.7)" | |
| echo "Expected: v1.17.7 or higher to mitigate CVE-2025-68156" | |
| exit 1 | |
| fi | |
| else | |
| echo "⚠️ expr-lang/expr not found in binary dependencies" | |
| echo "This could mean:" | |
| echo " 1. The dependency was stripped/optimized out" | |
| echo " 2. Caddy was built without the expression evaluator" | |
| echo " 3. Binary inspection failed" | |
| echo "" | |
| echo "Displaying all dependencies for review:" | |
| cat caddy_deps.txt | |
| fi | |
| else | |
| echo "⚠️ Go toolchain not available in CI environment" | |
| echo "Cannot inspect binary modules - skipping dependency verification" | |
| echo "Note: Runtime image does not require Go as Caddy is a standalone binary" | |
| fi | |
| # Cleanup | |
| rm -f ./caddy_binary caddy_deps.txt | |
| echo "" | |
| echo "==> Verification complete" | |
| - name: Verify CrowdSec Security Patches (CVE-2025-68156) | |
| if: success() | |
| continue-on-error: true | |
| run: | | |
| echo "🔍 Verifying CrowdSec binaries contain patched expr-lang/expr@v1.17.7..." | |
| echo "" | |
| # Determine the image reference based on event type | |
| if [ "${{ env.TRIGGER_EVENT }}" = "pull_request" ]; then | |
| IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}" | |
| if [ -z "${IMAGE_REF}" ]; then | |
| echo "❌ ERROR: Failed to load PR image reference from image_ref output" | |
| exit 1 | |
| fi | |
| echo "Using PR image: $IMAGE_REF" | |
| else | |
| if [ -z "${{ steps.build-and-push.outputs.digest }}" ]; then | |
| echo "❌ ERROR: Build digest is empty" | |
| exit 1 | |
| fi | |
| IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}" | |
| echo "Using digest: $IMAGE_REF" | |
| fi | |
| echo "" | |
| echo "==> CrowdSec cscli version:" | |
| timeout 30s docker run --rm --pull=never "$IMAGE_REF" cscli version || echo "⚠️ CrowdSec version check timed out or failed (may not be installed for this architecture)" | |
| echo "" | |
| echo "==> Extracting cscli binary for inspection..." | |
| CONTAINER_ID=$(docker create --pull=never "$IMAGE_REF") | |
| docker cp "${CONTAINER_ID}:/usr/local/bin/cscli" ./cscli_binary 2>/dev/null || { | |
| echo "⚠️ cscli binary not found - CrowdSec may not be available for this architecture" | |
| docker rm "$CONTAINER_ID" | |
| exit 0 | |
| } | |
| docker rm "$CONTAINER_ID" | |
| echo "" | |
| echo "==> Checking if Go toolchain is available locally..." | |
| if command -v go >/dev/null 2>&1; then | |
| echo "✅ Go found locally, inspecting binary dependencies..." | |
| go version -m ./cscli_binary > cscli_deps.txt | |
| echo "" | |
| echo "==> Searching for expr-lang/expr dependency:" | |
| if grep -i "expr-lang/expr" cscli_deps.txt; then | |
| EXPR_VERSION=$(grep "expr-lang/expr" cscli_deps.txt | awk '{print $3}') | |
| echo "" | |
| echo "✅ Found expr-lang/expr: $EXPR_VERSION" | |
| # Check if version is v1.17.7 or higher (vulnerable version is v1.17.2) | |
| if echo "$EXPR_VERSION" | grep -E "^v1\.(1[7-9]|[2-9][0-9])\.[7-9][0-9]*$|^v1\.17\.([7-9]|[1-9][0-9]+)$" >/dev/null; then | |
| echo "✅ PASS: expr-lang version $EXPR_VERSION is patched (>= v1.17.7)" | |
| else | |
| echo "❌ FAIL: expr-lang version $EXPR_VERSION is vulnerable (< v1.17.7)" | |
| echo "⚠️ WARNING: expr-lang version $EXPR_VERSION may be vulnerable (< v1.17.7)" | |
| echo "Expected: v1.17.7 or higher to mitigate CVE-2025-68156" | |
| exit 1 | |
| fi | |
| else | |
| echo "⚠️ expr-lang/expr not found in binary dependencies" | |
| echo "This could mean:" | |
| echo " 1. The dependency was stripped/optimized out" | |
| echo " 2. CrowdSec was built without the expression evaluator" | |
| echo " 3. Binary inspection failed" | |
| echo "" | |
| echo "Displaying all dependencies for review:" | |
| cat cscli_deps.txt | |
| fi | |
| else | |
| echo "⚠️ Go toolchain not available in CI environment" | |
| echo "Cannot inspect binary modules - skipping dependency verification" | |
| echo "Note: Runtime image does not require Go as CrowdSec is a standalone binary" | |
| fi | |
| # Cleanup | |
| rm -f ./cscli_binary cscli_deps.txt | |
| echo "" | |
| echo "==> CrowdSec verification complete" | |
| - name: Run Trivy scan (table output) | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} | |
| format: 'table' | |
| severity: 'CRITICAL,HIGH' | |
| exit-code: '0' | |
| version: 'v0.72.0' | |
| trivyignores: '.trivyignore' | |
| continue-on-error: true | |
| - name: Run Trivy vulnerability scanner (SARIF) | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| id: trivy | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} | |
| format: 'sarif' | |
| output: 'trivy-results.sarif' | |
| severity: 'CRITICAL,HIGH' | |
| version: 'v0.72.0' | |
| trivyignores: '.trivyignore' | |
| continue-on-error: true | |
| - name: Check Trivy SARIF exists | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| id: trivy-check | |
| run: | | |
| if [ -f trivy-results.sarif ]; then | |
| echo "exists=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Upload Trivy results | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true' | |
| uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 | |
| with: | |
| sarif_file: 'trivy-results.sarif' | |
| category: ${{ env.TRIVY_SARIF_CATEGORY }} | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Record Trivy scan traceability | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true' | |
| env: | |
| IMAGE_REF: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} | |
| run: | | |
| CADDY_VERSION=$(docker run --rm --pull=never "${IMAGE_REF}" caddy version 2>/dev/null || echo "unknown") | |
| { | |
| echo "## Trivy SARIF Traceability" | |
| echo "" | |
| echo "- Category: ${{ env.TRIVY_SARIF_CATEGORY }}" | |
| echo "- Image: ${IMAGE_REF}" | |
| echo "- Digest: ${{ steps.build-and-push.outputs.digest }}" | |
| echo "- Caddy version: ${CADDY_VERSION}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Generate SBOM (Software Bill of Materials) for supply chain security | |
| # Only for production builds (main/development) - feature branches use downstream supply-chain-pr.yml | |
| - name: Generate SBOM | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| with: | |
| image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} | |
| format: cyclonedx-json | |
| output-file: sbom.cyclonedx.json | |
| syft-version: v1.45.1 | |
| # Create verifiable attestation for the SBOM | |
| # (actions/attest-sbom is deprecated; actions/attest supports sbom-path natively) | |
| # GitHub's OIDC token endpoint occasionally fails transiently | |
| # ("CI: no tokens available"), so retry once before failing the build | |
| - name: Attest SBOM | |
| id: attest-sbom | |
| uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| continue-on-error: true | |
| with: | |
| subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} | |
| subject-digest: ${{ steps.build-and-push.outputs.digest }} | |
| sbom-path: sbom.cyclonedx.json | |
| push-to-registry: true | |
| - name: Attest SBOM (retry) | |
| if: steps.attest-sbom.outcome == 'failure' | |
| uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 | |
| with: | |
| subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} | |
| subject-digest: ${{ steps.build-and-push.outputs.digest }} | |
| sbom-path: sbom.cyclonedx.json | |
| push-to-registry: true | |
| # Install Cosign for keyless signing | |
| - name: Install Cosign | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| # Sign GHCR image with keyless signing (Sigstore/Fulcio) | |
| # Retry up to 3 times to handle transient Fulcio/Rekor INTERNAL_ERROR (HTTP/2 stream errors) | |
| - name: Sign GHCR Image | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' | |
| run: | | |
| echo "Signing GHCR image with keyless signing..." | |
| for attempt in 1 2 3; do | |
| if cosign sign --yes ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}; then | |
| echo "✅ GHCR image signed successfully" | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "❌ GHCR signing failed after 3 attempts" | |
| exit 1 | |
| fi | |
| echo "⚠️ Attempt $attempt failed — retrying in 15s..." | |
| sleep 15 | |
| done | |
| # Sign Docker Hub image with keyless signing (Sigstore/Fulcio) | |
| # Retry up to 3 times to handle transient Fulcio/Rekor INTERNAL_ERROR (HTTP/2 stream errors) | |
| - name: Sign Docker Hub Image | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true' | |
| run: | | |
| echo "Signing Docker Hub image with keyless signing..." | |
| for attempt in 1 2 3; do | |
| if cosign sign --yes ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}; then | |
| echo "✅ Docker Hub image signed successfully" | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "❌ Docker Hub signing failed after 3 attempts" | |
| exit 1 | |
| fi | |
| echo "⚠️ Attempt $attempt failed — retrying in 15s..." | |
| sleep 15 | |
| done | |
| # Attach SBOM to Docker Hub image | |
| - name: Attach SBOM to Docker Hub | |
| if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true' | |
| run: | | |
| echo "Attaching SBOM to Docker Hub image..." | |
| cosign attach sbom --sbom sbom.cyclonedx.json ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} | |
| echo "✅ SBOM attached to Docker Hub image" | |
| - name: Create summary | |
| if: steps.skip.outputs.skip_build != 'true' | |
| run: | | |
| { | |
| echo "## 🎉 Docker Image Built Successfully!" | |
| echo "" | |
| echo "### 📦 Image Details" | |
| echo "- **GHCR**: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}" | |
| echo "- **Docker Hub**: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}" | |
| echo "- **Tags**: " | |
| echo '```' | |
| echo "${{ steps.meta.outputs.tags }}" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| scan-pr-image: | |
| name: Security Scan PR Image | |
| needs: build-and-push | |
| if: needs.build-and-push.outputs.skip_build != 'true' && needs.build-and-push.result == 'success' && github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| packages: read | |
| security-events: write | |
| steps: | |
| - name: Checkout repository for Trivy ignore rules | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| - name: Normalize image name | |
| run: | | |
| IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') | |
| echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITHUB_ENV" | |
| - name: Load PR image reference | |
| id: pr-image | |
| run: | | |
| DIGEST="${{ needs.build-and-push.outputs.digest }}" | |
| if [[ -z "$DIGEST" ]]; then | |
| echo "❌ ERROR: build-and-push digest output is empty; cannot proceed without immutable reference" | |
| exit 1 | |
| fi | |
| if ! [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "❌ ERROR: Invalid digest format (expected sha256:<64 hex chars>): ${DIGEST}" | |
| exit 1 | |
| fi | |
| IMAGE_REF="ghcr.io/${IMAGE_NAME}@${DIGEST}" | |
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | |
| echo "image_ref=${IMAGE_REF}" >> "$GITHUB_OUTPUT" | |
| echo "Using immutable digest reference: ${IMAGE_REF}" | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 | |
| with: | |
| registry: ${{ env.GHCR_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Inspect PR image metadata | |
| id: image-metadata | |
| run: | | |
| echo "🔍 Inspecting image metadata for PR #${{ env.TRIGGER_PR_NUMBER }}..." | |
| echo "Image: ${{ steps.pr-image.outputs.image_ref }}" | |
| # Pull the immutable digest reference to inspect labels. | |
| docker pull "${{ steps.pr-image.outputs.image_ref }}" | |
| LABEL_SHA=$(docker inspect "${{ steps.pr-image.outputs.image_ref }}" \ | |
| --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') | |
| echo "Image label SHA: ${LABEL_SHA}" | |
| echo "label_sha=${LABEL_SHA}" >> "$GITHUB_OUTPUT" | |
| - name: Validate image freshness | |
| run: | | |
| LABEL_SHA="${{ steps.image-metadata.outputs.label_sha }}" | |
| if [[ -z "${LABEL_SHA}" ]]; then | |
| echo "❌ ERROR: Missing image revision label" | |
| exit 1 | |
| fi | |
| if [[ "${LABEL_SHA}" != "${{ env.TRIGGER_HEAD_SHA }}" ]]; then | |
| echo "❌ ERROR: Image SHA mismatch!" | |
| echo " Expected: ${{ env.TRIGGER_HEAD_SHA }}" | |
| echo " Got: ${LABEL_SHA}" | |
| echo "Image may be stale. Failing closed." | |
| exit 1 | |
| fi | |
| echo "✅ Image freshness validated" | |
| - name: Run Trivy scan on PR image (table output) | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: ${{ steps.pr-image.outputs.image_ref }} | |
| format: 'table' | |
| scanners: 'vuln' | |
| trivyignores: '.trivyignore' | |
| severity: 'CRITICAL,HIGH' | |
| exit-code: '0' | |
| version: 'v0.72.0' | |
| - name: Run Trivy scan on PR image (SARIF - blocking) | |
| id: trivy-scan | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: ${{ steps.pr-image.outputs.image_ref }} | |
| format: 'sarif' | |
| output: 'trivy-pr-results.sarif' | |
| scanners: 'vuln' | |
| trivyignores: '.trivyignore' | |
| severity: 'CRITICAL,HIGH' | |
| # Keep scanning strict for CRITICAL/HIGH; fail is enforced explicitly | |
| # at the end so SARIF upload and summaries still run. | |
| exit-code: '1' | |
| version: 'v0.72.0' | |
| continue-on-error: true | |
| - name: Check Trivy PR SARIF exists | |
| if: always() | |
| id: trivy-pr-check | |
| run: | | |
| if [ -f trivy-pr-results.sarif ]; then | |
| echo "exists=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Upload Trivy scan results | |
| if: always() && steps.trivy-pr-check.outputs.exists == 'true' | |
| uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 | |
| with: | |
| sarif_file: 'trivy-pr-results.sarif' | |
| category: ${{ env.TRIVY_SARIF_CATEGORY }} | |
| - name: Record PR Trivy scan traceability | |
| if: always() && steps.trivy-pr-check.outputs.exists == 'true' | |
| env: | |
| IMAGE_REF: ${{ steps.pr-image.outputs.image_ref }} | |
| run: | | |
| CADDY_VERSION="unknown" | |
| CADDY_VERSION_SOURCE="fallback-not-available" | |
| # Prefer absolute entrypoint to avoid shell/command resolution differences. | |
| if CADDY_VERSION=$(timeout 30s docker run --rm --pull=never --entrypoint /usr/bin/caddy "${IMAGE_REF}" version 2>/dev/null); then | |
| CADDY_VERSION_SOURCE="--entrypoint /usr/bin/caddy" | |
| elif CADDY_VERSION=$(timeout 30s docker run --rm --pull=never --entrypoint caddy "${IMAGE_REF}" version 2>/dev/null); then | |
| CADDY_VERSION_SOURCE="--entrypoint caddy" | |
| fi | |
| { | |
| echo "## PR Trivy SARIF Traceability" | |
| echo "" | |
| echo "- Category: ${{ env.TRIVY_SARIF_CATEGORY }}" | |
| echo "- Image: ${IMAGE_REF}" | |
| echo "- Digest: ${IMAGE_REF#*@}" | |
| echo "- Caddy version: ${CADDY_VERSION}" | |
| echo "- Caddy version source: ${CADDY_VERSION_SOURCE}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Create scan summary | |
| if: always() | |
| run: | | |
| { | |
| echo "## 🔒 PR Image Security Scan" | |
| echo "" | |
| echo "- **Image**: ${{ steps.pr-image.outputs.image_ref }}" | |
| echo "- **Image Digest**: ${{ steps.pr-image.outputs.digest }}" | |
| echo "- **Image Revision Label SHA**: ${{ steps.image-metadata.outputs.label_sha || 'missing' }}" | |
| echo "- **PR**: #${{ env.TRIGGER_PR_NUMBER }}" | |
| echo "- **Commit**: ${{ env.TRIGGER_HEAD_SHA }}" | |
| echo "- **Scan Status**: ${{ steps.trivy-scan.outcome == 'success' && '✅ No critical vulnerabilities' || '❌ Vulnerabilities detected' }}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Diagnose unsuppressed PR Trivy blockers | |
| if: always() | |
| continue-on-error: true | |
| run: | | |
| SARIF_PATH="trivy-pr-results.sarif" | |
| FALLBACK_REASON="" | |
| FINDINGS_COUNT="0" | |
| UNIQUE_IDS_CSV="none" | |
| PARSER_EXIT_CODE="" | |
| PARSER_HINT="" | |
| echo "Unsuppressed HIGH/CRITICAL findings (from ${SARIF_PATH}):" | |
| if [[ ! -f "${SARIF_PATH}" ]]; then | |
| FALLBACK_REASON="file missing" | |
| elif [[ ! -r "${SARIF_PATH}" ]]; then | |
| FALLBACK_REASON="file unreadable" | |
| elif ! jq -e '.' "${SARIF_PATH}" >/dev/null 2>&1; then | |
| FALLBACK_REASON="invalid JSON" | |
| elif ! jq -e '(.runs | type) == "array"' "${SARIF_PATH}" >/dev/null 2>&1; then | |
| FALLBACK_REASON="unexpected schema" | |
| else | |
| PARSER_ERR_FILE=$(mktemp) | |
| if IDS_JSON=$(jq -c ' | |
| [ | |
| .runs[]? as $run | |
| | (($run.results // [])[]?) as $result | |
| | select((($result.suppressions // []) | length) == 0) | |
| | { | |
| id: ( | |
| $result.ruleId | |
| // ($result.rule // {} | .id) | |
| // ( | |
| if ($result.ruleIndex != null and (($run.tool.driver.rules? // null) | type) == "array") then | |
| ($run.tool.driver.rules[$result.ruleIndex].id // empty) | |
| else | |
| empty | |
| end | |
| ) | |
| // "unknown" | |
| ) | |
| } | |
| ] | |
| ' "${SARIF_PATH}" 2>"${PARSER_ERR_FILE}"); then | |
| FINDINGS_COUNT=$(echo "${IDS_JSON}" | jq 'length' 2>/dev/null || echo "0") | |
| UNIQUE_IDS_CSV=$(echo "${IDS_JSON}" | jq -r '[.[].id | select(. != "" and . != null)] | unique | join(", ")' 2>/dev/null || echo "none") | |
| if [[ -z "${UNIQUE_IDS_CSV}" ]]; then | |
| UNIQUE_IDS_CSV="none" | |
| fi | |
| if [[ "${FINDINGS_COUNT}" -gt 0 ]]; then | |
| if ! jq -r ' | |
| .runs[]? as $run | |
| | (($run.results // [])[]?) as $result | |
| | select((($result.suppressions // []) | length) == 0) | |
| | "- \(( | |
| $result.ruleId | |
| // ($result.rule // {} | .id) | |
| // ( | |
| if ($result.ruleIndex != null and (($run.tool.driver.rules? // null) | type) == \"array\") then | |
| ($run.tool.driver.rules[$result.ruleIndex].id // \"unknown\") | |
| else | |
| \"unknown\" | |
| end | |
| ) | |
| )) | package: \(( | |
| ($result.message.text // \"\") | |
| | (try capture(\"(?i)(?:Package|PkgName|Pkg|Library)\\\\s*[:=]\\\\s*`?(?<pkg>[A-Za-z0-9._+:+-]+)`?\").pkg catch \"n/a\") | |
| ))" | |
| ' "${SARIF_PATH}"; then | |
| echo "- unable to render parsed findings" | |
| fi | |
| else | |
| echo "- none" | |
| fi | |
| else | |
| FALLBACK_REASON="parser command failure" | |
| PARSER_EXIT_CODE="$?" | |
| PARSER_HINT=$(head -n 1 "${PARSER_ERR_FILE}" | tr -d '\r' || true) | |
| if [[ -z "${PARSER_HINT}" ]]; then | |
| PARSER_HINT="no parser stderr available" | |
| fi | |
| fi | |
| rm -f "${PARSER_ERR_FILE}" | |
| fi | |
| { | |
| echo "## PR Trivy Unsuppressed Blockers" | |
| echo "" | |
| if [[ -n "${FALLBACK_REASON}" ]]; then | |
| echo "- Diagnostics status: fallback" | |
| echo "- Fallback reason: ${FALLBACK_REASON}" | |
| if [[ "${FALLBACK_REASON}" == "parser command failure" ]]; then | |
| echo "- Parser exit code: ${PARSER_EXIT_CODE:-unknown}" | |
| echo "- Parser hint: ${PARSER_HINT}" | |
| fi | |
| echo "- Count: unknown" | |
| echo "- Blocker IDs: unknown" | |
| else | |
| echo "- Diagnostics status: parsed" | |
| echo "- Count: ${FINDINGS_COUNT}" | |
| if [[ "${FINDINGS_COUNT}" -gt 0 ]]; then | |
| echo "- Blocker IDs: ${UNIQUE_IDS_CSV}" | |
| else | |
| echo "- Blocker IDs: none" | |
| fi | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Enforce PR Trivy security gate | |
| if: always() | |
| run: | | |
| if [[ "${{ steps.trivy-scan.outcome }}" != "success" ]]; then | |
| echo "❌ Blocking merge: PR image has CRITICAL/HIGH vulnerabilities or scan failed" | |
| exit 1 | |
| fi |