Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 59 additions & 22 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 }}-

Expand All @@ -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: |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
109 changes: 109 additions & 0 deletions .github/workflows/update-base-images.yml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 10 additions & 14 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
Loading