From 3777d2899ce1b1e566bcd065373a63d9683a41b0 Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Sat, 28 Mar 2026 23:17:47 +0530 Subject: [PATCH] feat: implement weekly base image digest rotation workflow for security updates --- .github/workflows/deploy.yml | 81 ++++++++++++----- .github/workflows/pr.yml | 7 ++ .github/workflows/update-base-images.yml | 109 +++++++++++++++++++++++ apps/api/Dockerfile | 24 +++-- 4 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/update-base-images.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5973df0..f451857 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -230,6 +230,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Pull base images (force fresh manifest, prevent stale GHA cache) + run: | + docker pull node:24.2.0-bookworm-slim + docker pull gcr.io/distroless/nodejs24-debian12:nonroot + # Phase 1: Build into local Docker daemon for scanning. # EXACT same parameters as pr.yml production-simulation: # target: production, build-args: NODE_ENV=production, GHA cache. @@ -246,29 +251,30 @@ jobs: CACHE_BUSTER=${{ hashFiles('**/package-lock.json') }} push: false load: true + pull: true tags: | fieldtrack-backend:${{ steps.meta.outputs.sha_short }} cache-from: type=gha,scope=production cache-to: type=gha,mode=max,scope=production - # Verify Node.js runtime crypto via OpenSSL binding in PRODUCTION stage. - # Confirms the Node runtime has OpenSSL linked (critical for TLS/HTTPS). - # Uses --entrypoint to bypass the distroless ENTRYPOINT. - - name: Verify Node.js runtime crypto + # Verify Node.js runtime — exercises TLS stack, not just compile-time version constant. + # tls.createSecureContext() fails if libssl linkage is broken, proving runtime health. + - name: Verify Node.js runtime (TLS operational check) run: | IMAGE_NAME="fieldtrack-backend:${{ steps.meta.outputs.sha_short }}" echo "Testing image: $IMAGE_NAME" - - # Override ENTRYPOINT to run node directly (distroless sets ENTRYPOINT ["/nodejs/bin/node"]) - echo "Verifying Node.js OpenSSL binding..." - docker run --rm --entrypoint /nodejs/bin/node "$IMAGE_NAME" -e " - const openssl = process.versions.openssl; - if (!openssl) { - console.error('ERROR: OpenSSL not linked to Node.js runtime'); - process.exit(1); - } - console.log('✓ OpenSSL version:', openssl); - " + docker run --rm \ + --entrypoint /nodejs/bin/node \ + "$IMAGE_NAME" \ + -e " + const crypto = require('crypto'); + const tls = require('tls'); + const ctx = tls.createSecureContext(); + if (!ctx) { process.stderr.write('FAIL: TLS context failed\n'); process.exit(1); } + const h = crypto.createHash('sha256').update('smoke').digest('hex'); + if (!h) { process.stderr.write('FAIL: hash failed\n'); process.exit(1); } + process.stdout.write('node=' + process.versions.node + ' openssl=' + process.versions.openssl + ' tls=ok\n'); + " # Capture the content-addressable image digest. # With cache scoping and cache busting, digest should always reproduce correctly. @@ -348,11 +354,15 @@ jobs: # aquasec/trivy:0.49.1 → sha256:91494b87ddc64f62860d52997532643956c24eeee0d0dda317d563c28c8581bc # Identical severity gates to pr.yml (HIGH,CRITICAL / exit-code 1). # Two-phase: DB downloaded first (needs network), then scan runs --network none. - - name: Cache Trivy DB + - name: Get date for Trivy DB cache key + id: trivy-date + run: echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + + - name: Cache Trivy DB (daily refresh) uses: actions/cache@v4 with: path: /tmp/trivy-cache - key: trivy-db-${{ runner.os }}-v1 + key: trivy-db-${{ runner.os }}-${{ steps.trivy-date.outputs.date }} restore-keys: | trivy-db-${{ runner.os }}- @@ -363,7 +373,7 @@ jobs: aquasec/trivy@sha256:91494b87ddc64f62860d52997532643956c24eeee0d0dda317d563c28c8581bc \ image --download-db-only - - name: Scan image with Trivy (HIGH/CRITICAL, ignore-unfixed, air-gapped) + - name: Scan image with Trivy (HIGH/CRITICAL, ignore-unfixed) env: IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} run: | @@ -391,6 +401,37 @@ jobs: echo "::error::Trivy scan failed after 3 attempts — HIGH/CRITICAL vulnerabilities found or scan error." exit 1 fi + echo "✓ Trivy scan passed (HIGH/CRITICAL, ignore-unfixed)" + + - name: Scan for unfixed CRITICAL vulnerabilities (informational) + continue-on-error: true + env: + IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + run: | + UNFIXED_COUNT=$(docker run --rm \ + --network none \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /tmp/trivy-cache:/root/.cache \ + aquasec/trivy@sha256:91494b87ddc64f62860d52997532643956c24eeee0d0dda317d563c28c8581bc image \ + --skip-db-update \ + --severity CRITICAL \ + --format json \ + "$IMAGE_NAME" | jq '[.Results[]?.Misconfigurations[]? // .Results[]?.Vulnerabilities[]? | select(.FixedVersion == null or .FixedVersion == "")] | length') + + if [ "$UNFIXED_COUNT" -gt 0 ]; then + echo "⚠ WARNING: $UNFIXED_COUNT unfixed CRITICAL vulnerabilities found" + echo " (No patches available upstream — waiting for vendor fix)" + docker run --rm \ + --network none \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /tmp/trivy-cache:/root/.cache \ + aquasec/trivy@sha256:91494b87ddc64f62860d52997532643956c24eeee0d0dda317d563c28c8581bc image \ + --skip-db-update \ + --severity CRITICAL \ + "$IMAGE_NAME" >> /tmp/unfixed-critical.log || true + else + echo "✓ No unfixed CRITICAL vulnerabilities" + fi # Phase 3: Scan passed — push the exact scanned image (same layer digests). # Uses docker tag + push rather than rebuilding to guarantee what was scanned @@ -424,11 +465,7 @@ jobs: docker tag \ fieldtrack-backend:${{ steps.meta.outputs.sha_short }} \ ghcr.io/${OWNER}/fieldtrack-backend:${{ steps.meta.outputs.sha_short }} - docker tag \ - fieldtrack-backend:${{ steps.meta.outputs.sha_short }} \ - ghcr.io/${OWNER}/fieldtrack-backend:latest docker push ghcr.io/${OWNER}/fieldtrack-backend:${{ steps.meta.outputs.sha_short }} - docker push ghcr.io/${OWNER}/fieldtrack-backend:latest echo "✓ Pushed ghcr.io/${OWNER}/fieldtrack-backend:${{ steps.meta.outputs.sha_short }}" # Use the same pinned Trivy image to generate the SBOM — no additional diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a672ec1..7c60a53 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -91,10 +91,17 @@ jobs: working-directory: apps/api run: npx vitest run tests/integration/ + - name: Pull base images (force fresh manifest, prevent stale GHA cache) + if: needs.detect-changes.outputs.backend == 'true' + run: | + docker pull node:24.2.0-bookworm-slim + docker pull gcr.io/distroless/nodejs24-debian12:nonroot + - name: Build and validate container if: needs.detect-changes.outputs.backend == 'true' run: | docker build \ + --pull \ --target production \ --build-arg CACHE_BUSTER=${{ hashFiles('**/package-lock.json') }} \ --cache-from=type=gha,scope=pr \ diff --git a/.github/workflows/update-base-images.yml b/.github/workflows/update-base-images.yml new file mode 100644 index 0000000..ec2594c --- /dev/null +++ b/.github/workflows/update-base-images.yml @@ -0,0 +1,109 @@ +name: Update Base Image Digests (Weekly) + +on: + schedule: + - cron: '0 3 * * 1' # Monday 03:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-digests: + name: Rotate base image digests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve current node digest (linux/amd64) + id: node-digest + run: | + DIGEST=$(docker manifest inspect node:24.2.0-bookworm-slim 2>/dev/null \ + | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux") | .digest' \ + | sed 's/sha256://' | head -c 64) + if [ -z "$DIGEST" ]; then + echo "::error::Failed to resolve node digest" + exit 1 + fi + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "Resolved node:24.2.0-bookworm-slim@sha256:$DIGEST" + + - name: Resolve current distroless digest (linux/amd64) + id: distroless-digest + run: | + DIGEST=$(docker manifest inspect gcr.io/distroless/nodejs24-debian12:nonroot 2>/dev/null \ + | jq -r '.manifests[] | select(.platform.architecture=="amd64" and .platform.os=="linux") | .digest' \ + | sed 's/sha256://' | head -c 64) + if [ -z "$DIGEST" ]; then + echo "::error::Failed to resolve distroless digest" + exit 1 + fi + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "Resolved gcr.io/distroless/nodejs24-debian12:nonroot@sha256:$DIGEST" + + - name: Update Dockerfile digests + run: | + FILE="apps/api/Dockerfile" + + # Update node digest in Stage 1 (builder) + sed -i "s|node:24.2.0-bookworm-slim@sha256:[a-f0-9]*|node:24.2.0-bookworm-slim@sha256:${{ steps.node-digest.outputs.digest }}|g" "$FILE" + + # Update node digest in Stage 2 (runtime-deps) + sed -i "s|node:24.2.0-bookworm-slim@sha256:[a-f0-9]*|node:24.2.0-bookworm-slim@sha256:${{ steps.node-digest.outputs.digest }}|g" "$FILE" + + # Update distroless digest in Stage 3 (production) + sed -i "s|gcr.io/distroless/nodejs24-debian12:nonroot@sha256:[a-f0-9]*|gcr.io/distroless/nodejs24-debian12:nonroot@sha256:${{ steps.distroless-digest.outputs.digest }}|g" "$FILE" + + # Update the comment date + sed -i "s|resolved [0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}|resolved $(date +'%Y-%m-%d')|g" "$FILE" + + - name: Check if digests changed + id: changes + run: | + if git diff --quiet apps/api/Dockerfile; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "✓ Digests are already current" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "⚠ Base image digests have updated:" + git diff apps/api/Dockerfile + fi + + - name: Create Pull Request + if: steps.changes.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v6 + with: + commit-message: 'chore: weekly base image digest rotation' + title: 'chore: update base image digests' + body: | + ## Base Image Digest Rotation + + Automated weekly rotation of base image digests to capture security patches. + + ### Updated Images + + - **node** `24.2.0-bookworm-slim` + - Previous: *see git diff* + - Current: `sha256:${{ steps.node-digest.outputs.digest }}` + + - **distroless** `nodejs24-debian12:nonroot` + - Previous: *see git diff* + - Current: `sha256:${{ steps.distroless-digest.outputs.digest }}` + + ### Why This Matters + + Digest pinning ensures reproducible builds. Weekly rotation ensures we receive security patches that Google publishes without requiring manual intervention. + + If the digests haven't changed, no PR is created (already current). + + --- + + Triggered: `${{ github.event_name }}` at `$(date -u +'%Y-%m-%d %H:%M:%S UTC')` + branch: chore/update-base-image-digests-${{ github.run_id }} + delete-branch: true + labels: 'security,dependencies,automation' + reviewers: | + ashish diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 0c87880..e1c72cd 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -15,12 +15,10 @@ # ---- Stage 1: Build -------------------------------------------------------- # Pinned to specific version to prevent supply chain attacks. -# NOTE: To pin by digest and prevent base image drift: -# 1. Run: docker pull node:24.2.0-bookworm-slim -# 2. Run: docker inspect node:24.2.0-bookworm-slim | grep RepoDigests -# 3. Replace node:24.2.0-bookworm-slim with node:24.2.0-bookworm-slim@sha256:DIGEST -# This ensures identical binaries even if the tag is re-released. -FROM node:24.2.0-bookworm-slim AS builder +# Digest pinned to linux/amd64 manifest (resolved 2026-03-28). +# To rotate: docker manifest inspect node:24.2.0-bookworm-slim +# and replace the sha256 below with the current amd64 digest. +FROM node:24.2.0-bookworm-slim@sha256:1a6a7b2e2e2c80a6973f57aa8b0c6ad67a961ddbc5ef326c448e133f93564ff9 AS builder # Cache buster: force rebuild when package-lock.json changes. # Prevents stale dependency layers from being reused on deployment. @@ -50,8 +48,8 @@ RUN npm run build # Separate stage: installs --omit=dev so distroless never needs npm or a shell. # mkdir -p guards ensure workspace subdirectories always exist for the COPY in # stage 3, even when npm hoists all deps to the root node_modules. -# NOTE: Must use SAME base image tag as Stage 1 to ensure consistency. -FROM node:24.2.0-bookworm-slim AS runtime-deps +# NOTE: Must use SAME base image digest as Stage 1 to ensure consistency. +FROM node:24.2.0-bookworm-slim@sha256:1a6a7b2e2e2c80a6973f57aa8b0c6ad67a961ddbc5ef326c448e133f93564ff9 AS runtime-deps # Cache buster: force rebuild when package-lock.json changes. ARG CACHE_BUSTER=1 @@ -76,13 +74,11 @@ RUN npm ci \ # • Minimal glibc + libssl from Debian 12 # • No shell, no package manager, no OS utilities # Trivy finds near-zero OS CVEs in this image. -# NOTE: To pin by digest and prevent base image drift: -# 1. Run: docker pull gcr.io/distroless/nodejs24-debian12:nonroot -# 2. Run: docker inspect gcr.io/distroless/nodejs24-debian12:nonroot | grep RepoDigests -# 3. Replace tag with @sha256:DIGEST in FROM statement -# This prevents "same Dockerfile → different result" risks. +# Digest pinned to linux/amd64 manifest (resolved 2026-03-28). +# To rotate: docker manifest inspect gcr.io/distroless/nodejs24-debian12:nonroot +# and replace the sha256 below with the current amd64 digest. # ENTRYPOINT is ["/nodejs/bin/node"]; CMD supplies the script path argument. -FROM gcr.io/distroless/nodejs24-debian12:nonroot AS production +FROM gcr.io/distroless/nodejs24-debian12:nonroot@sha256:6c75c6e4771c2ea5f02aaf991abdc77391acd3a580accd9d7b68651f12c60dc0 AS production WORKDIR /app