906 lines
38 KiB
YAML
906 lines
38 KiB
YAML
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
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
- development
|
|
- feature/beta-release
|
|
# Note: Tags are handled by release-goreleaser.yml to avoid duplicate builds
|
|
pull_request:
|
|
branches:
|
|
- main
|
|
- development
|
|
- feature/beta-release
|
|
workflow_dispatch:
|
|
workflow_call:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
REGISTRY: ghcr.io
|
|
IMAGE_NAME: ${{ github.repository_owner }}/charon
|
|
SYFT_VERSION: v1.17.0
|
|
GRYPE_VERSION: v0.85.0
|
|
|
|
jobs:
|
|
build-and-push:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- 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: ${{ github.actor }}
|
|
EVENT: ${{ github.event_name }}
|
|
HEAD_MSG: ${{ github.event.head_commit.message }}
|
|
REF: ${{ github.ref }}
|
|
run: |
|
|
should_skip=false
|
|
pr_title=""
|
|
if [ "$EVENT" = "pull_request" ]; then
|
|
pr_title=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH" 2>/dev/null || echo '')
|
|
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
|
|
# Always build on beta-release branch to ensure artifacts for testing
|
|
if [[ "$REF" == "refs/heads/feature/beta-release" ]]; then
|
|
should_skip=false
|
|
echo "Force building on beta-release branch"
|
|
fi
|
|
|
|
echo "skip_build=$should_skip" >> $GITHUB_OUTPUT
|
|
|
|
- name: Set up QEMU
|
|
if: steps.skip.outputs.skip_build != 'true'
|
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
|
- name: Set up Docker Buildx
|
|
if: steps.skip.outputs.skip_build != 'true'
|
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
|
- name: Resolve Caddy base digest
|
|
if: steps.skip.outputs.skip_build != 'true'
|
|
id: caddy
|
|
run: |
|
|
docker pull caddy:2-alpine
|
|
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' caddy:2-alpine)
|
|
echo "image=$DIGEST" >> $GITHUB_OUTPUT
|
|
|
|
- name: Log in to Container Registry
|
|
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Extract metadata (tags, labels)
|
|
if: steps.skip.outputs.skip_build != 'true'
|
|
id: meta
|
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
|
with:
|
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
tags: |
|
|
type=raw,value=latest,enable={{is_default_branch}}
|
|
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/development' }}
|
|
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/feature/beta-release' }}
|
|
type=raw,value=pr-${{ github.event.pull_request.number }},enable=${{ github.event_name == 'pull_request' }}
|
|
type=sha,format=short,enable=${{ github.event_name != 'pull_request' }}
|
|
- name: Build and push Docker image
|
|
if: steps.skip.outputs.skip_build != 'true'
|
|
id: build-and-push
|
|
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
|
with:
|
|
context: .
|
|
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
|
push: ${{ github.event_name != 'pull_request' }}
|
|
load: ${{ github.event_name == 'pull_request' }}
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}
|
|
no-cache: true # Prevent false positive vulnerabilities from cached layers
|
|
pull: true # Always pull fresh base images to get latest security patches
|
|
build-args: |
|
|
VERSION=${{ steps.meta.outputs.version }}
|
|
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
|
VCS_REF=${{ github.sha }}
|
|
CADDY_IMAGE=${{ steps.caddy.outputs.image }}
|
|
|
|
# Critical Fix: Use exact tag from metadata instead of manual reconstruction
|
|
# WHY: docker/build-push-action with load:true applies the exact tags from
|
|
# docker/metadata-action. Manual reconstruction can cause mismatches due to:
|
|
# - Case sensitivity variations (owner name normalization)
|
|
# - Tag format differences in Buildx internal behavior
|
|
# - Registry prefix inconsistencies
|
|
#
|
|
# SOLUTION: Extract the first tag from metadata output (which is the PR tag)
|
|
# and use it directly with docker save. This guarantees we reference the
|
|
# exact image that was loaded into the local Docker daemon.
|
|
#
|
|
# VALIDATION: Added defensive checks to fail fast with diagnostics if:
|
|
# 1. No tag found in metadata output
|
|
# 2. Image doesn't exist locally after build
|
|
# 3. Artifact creation fails
|
|
- name: Save Docker Image as Artifact
|
|
if: github.event_name == 'pull_request'
|
|
run: |
|
|
# Extract the first tag from metadata action (PR tag)
|
|
IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n 1)
|
|
|
|
if [[ -z "${IMAGE_TAG}" ]]; then
|
|
echo "❌ ERROR: No image tag found in metadata output"
|
|
echo "Metadata tags output:"
|
|
echo "${{ steps.meta.outputs.tags }}"
|
|
exit 1
|
|
fi
|
|
|
|
echo "🔍 Detected image tag: ${IMAGE_TAG}"
|
|
|
|
# Verify the image exists locally
|
|
if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
|
|
echo "❌ ERROR: Image ${IMAGE_TAG} not found locally"
|
|
echo "📋 Available images:"
|
|
docker images
|
|
exit 1
|
|
fi
|
|
|
|
# Save the image using the exact tag from metadata
|
|
echo "💾 Saving image: ${IMAGE_TAG}"
|
|
docker save "${IMAGE_TAG}" -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: github.event_name == 'pull_request'
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
with:
|
|
name: pr-image-${{ github.event.pull_request.number }}
|
|
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
|
|
run: |
|
|
echo "🔍 Verifying Caddy binary contains patched expr-lang/expr@v1.17.7..."
|
|
echo ""
|
|
|
|
# Determine the image reference based on event type
|
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
IMAGE_REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
|
|
echo "Using PR image: $IMAGE_REF"
|
|
else
|
|
IMAGE_REF="${{ env.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 $IMAGE_REF caddy version || echo "⚠️ Caddy version check timed out or failed"
|
|
|
|
echo ""
|
|
echo "==> Extracting Caddy binary for inspection..."
|
|
CONTAINER_ID=$(docker create $IMAGE_REF)
|
|
docker cp ${CONTAINER_ID}:/usr/bin/caddy ./caddy_binary
|
|
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 ./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()
|
|
run: |
|
|
echo "🔍 Verifying CrowdSec binaries contain patched expr-lang/expr@v1.17.7..."
|
|
echo ""
|
|
|
|
# Determine the image reference based on event type
|
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
IMAGE_REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
|
|
echo "Using PR image: $IMAGE_REF"
|
|
else
|
|
IMAGE_REF="${{ env.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 $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 $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: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
|
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
|
with:
|
|
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
|
format: 'table'
|
|
severity: 'CRITICAL,HIGH'
|
|
exit-code: '0'
|
|
continue-on-error: true
|
|
|
|
- name: Run Trivy vulnerability scanner (SARIF)
|
|
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
|
id: trivy
|
|
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
|
with:
|
|
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
|
format: 'sarif'
|
|
output: 'trivy-results.sarif'
|
|
severity: 'CRITICAL,HIGH'
|
|
continue-on-error: true
|
|
|
|
- name: Check Trivy SARIF exists
|
|
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != '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: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
|
|
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
|
with:
|
|
sarif_file: 'trivy-results.sarif'
|
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
# Generate SBOM (Software Bill of Materials) for supply chain security
|
|
- name: Generate SBOM
|
|
uses: anchore/sbom-action@0b82b0b1a22399a1c542d4d656f70cd903571b5c # v0.21.1
|
|
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
|
with:
|
|
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
|
format: cyclonedx-json
|
|
output-file: sbom.cyclonedx.json
|
|
|
|
# Create verifiable attestation for the SBOM
|
|
- name: Attest SBOM
|
|
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
|
|
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
|
with:
|
|
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
subject-digest: ${{ steps.build-and-push.outputs.digest }}
|
|
sbom-path: sbom.cyclonedx.json
|
|
push-to-registry: true
|
|
|
|
- name: Create summary
|
|
if: steps.skip.outputs.skip_build != 'true'
|
|
run: |
|
|
echo "## 🎉 Docker Image Built Successfully!" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Registry**: GitHub Container Registry (ghcr.io)" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Repository**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
|
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
|
|
test-image:
|
|
name: Test Docker Image
|
|
needs: build-and-push
|
|
runs-on: ubuntu-latest
|
|
if: needs.build-and-push.outputs.skip_build != 'true' && github.event_name != 'pull_request'
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Normalize image name
|
|
run: |
|
|
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
|
|
IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
|
|
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
|
|
- name: Determine image tag
|
|
id: tag
|
|
run: |
|
|
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
|
echo "tag=latest" >> $GITHUB_OUTPUT
|
|
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
|
|
echo "tag=dev" >> $GITHUB_OUTPUT
|
|
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
|
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Log in to GitHub Container Registry
|
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Pull Docker image
|
|
run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
|
- name: Create Docker Network
|
|
run: docker network create charon-test-net
|
|
|
|
- name: Run Upstream Service (whoami)
|
|
run: |
|
|
docker run -d \
|
|
--name whoami \
|
|
--network charon-test-net \
|
|
traefik/whoami
|
|
|
|
- name: Run Charon Container
|
|
timeout-minutes: 3
|
|
run: |
|
|
docker run -d \
|
|
--name test-container \
|
|
--network charon-test-net \
|
|
-p 8080:8080 \
|
|
-p 80:80 \
|
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
|
|
|
# Wait for container to be healthy (max 2 minutes)
|
|
echo "Waiting for container to start..."
|
|
timeout 120s bash -c 'until docker exec test-container wget -q -O- http://localhost:8080/api/v1/health 2>/dev/null | grep -q "status"; do echo "Waiting..."; sleep 2; done' || {
|
|
echo "❌ Container failed to become healthy"
|
|
docker logs test-container
|
|
exit 1
|
|
}
|
|
echo "✅ Container is healthy"
|
|
- name: Run Integration Test
|
|
timeout-minutes: 5
|
|
run: ./scripts/integration-test.sh
|
|
|
|
- name: Check container logs
|
|
if: always()
|
|
run: docker logs test-container
|
|
|
|
- name: Stop container
|
|
if: always()
|
|
run: |
|
|
docker stop test-container whoami || true
|
|
docker rm test-container whoami || true
|
|
docker network rm charon-test-net || true
|
|
|
|
- name: Create test summary
|
|
if: always()
|
|
run: |
|
|
echo "## 🧪 Docker Image Test Results" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Image**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
|
|
echo "- **Integration Test**: ${{ job.status == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY
|
|
|
|
trivy-pr-app-only:
|
|
name: Trivy (PR) - App-only
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'pull_request'
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Build image locally for PR
|
|
run: |
|
|
docker build --no-cache -t charon:pr-${{ github.sha }} .
|
|
|
|
- name: Extract `charon` binary from image
|
|
run: |
|
|
CONTAINER=$(docker create charon:pr-${{ github.sha }})
|
|
docker cp ${CONTAINER}:/app/charon ./charon_binary || true
|
|
docker rm ${CONTAINER} || true
|
|
|
|
- name: Run Trivy filesystem scan on `charon` (fail PR on HIGH/CRITICAL)
|
|
run: |
|
|
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy -v $PWD:/workdir aquasec/trivy:latest fs --exit-code 1 --severity CRITICAL,HIGH /workdir/charon_binary
|
|
|
|
# ============================================================================
|
|
# Supply Chain Verification for PR Builds
|
|
# ============================================================================
|
|
# This job performs SBOM generation and vulnerability scanning for PR builds.
|
|
# It depends on the build-and-push job completing successfully and uses the
|
|
# Docker image artifact uploaded by that job.
|
|
#
|
|
# Dependency Chain: build-and-push (builds & uploads) → verify-supply-chain-pr (downloads & scans)
|
|
# ============================================================================
|
|
verify-supply-chain-pr:
|
|
name: Supply Chain Verification (PR)
|
|
needs: build-and-push
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
# Critical Fix #2: Enhanced conditional with result check
|
|
if: |
|
|
github.event_name == 'pull_request' &&
|
|
needs.build-and-push.outputs.skip_build != 'true' &&
|
|
needs.build-and-push.result == 'success'
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
security-events: write
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
# Critical Fix #1: Download image artifact
|
|
- name: Download Image Artifact
|
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
|
with:
|
|
name: pr-image-${{ github.event.pull_request.number }}
|
|
|
|
# Critical Fix #1: Load Docker image
|
|
- name: Load Docker Image
|
|
run: |
|
|
echo "📦 Loading image from artifact..."
|
|
docker load -i charon-pr-image.tar
|
|
echo "✅ Image loaded successfully"
|
|
|
|
- name: Normalize image name
|
|
run: |
|
|
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
|
|
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
|
|
|
|
- name: Verify Loaded Image
|
|
run: |
|
|
IMAGE_REF="ghcr.io/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
|
|
echo "🔍 Verifying image: ${IMAGE_REF}"
|
|
|
|
if ! docker image inspect "${IMAGE_REF}" >/dev/null 2>&1; then
|
|
echo "❌ ERROR: Expected image ${IMAGE_REF} not found after load"
|
|
echo "📋 Available images:"
|
|
docker images
|
|
exit 1
|
|
fi
|
|
|
|
echo "✅ Image verified: ${IMAGE_REF}"
|
|
|
|
- name: Set PR image reference
|
|
id: image
|
|
run: |
|
|
IMAGE_REF="ghcr.io/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
|
|
echo "ref=${IMAGE_REF}" >> $GITHUB_OUTPUT
|
|
echo "📦 Will verify: ${IMAGE_REF}"
|
|
|
|
- name: Install Verification Tools
|
|
run: |
|
|
# Use workflow-level environment variables for versions
|
|
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin ${{ env.SYFT_VERSION }}
|
|
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin ${{ env.GRYPE_VERSION }}
|
|
syft version
|
|
grype version
|
|
|
|
- name: Generate SBOM
|
|
id: sbom
|
|
run: |
|
|
echo "🔍 Generating SBOM for ${{ steps.image.outputs.ref }}..."
|
|
if ! syft ${{ steps.image.outputs.ref }} -o cyclonedx-json > sbom-pr.cyclonedx.json; then
|
|
echo "❌ SBOM generation failed"
|
|
exit 1
|
|
fi
|
|
COMPONENT_COUNT=$(jq '.components | length' sbom-pr.cyclonedx.json 2>/dev/null || echo "0")
|
|
echo "📦 SBOM contains ${COMPONENT_COUNT} components"
|
|
if [[ ${COMPONENT_COUNT} -eq 0 ]]; then
|
|
echo "⚠️ WARNING: SBOM contains no components"
|
|
exit 1
|
|
fi
|
|
echo "component_count=${COMPONENT_COUNT}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Scan for Vulnerabilities
|
|
id: scan
|
|
run: |
|
|
echo "🔍 Scanning for vulnerabilities..."
|
|
grype db update
|
|
if ! grype sbom:./sbom-pr.cyclonedx.json --output json --file vuln-scan.json; then
|
|
echo "❌ Vulnerability scan failed"
|
|
exit 1
|
|
fi
|
|
echo ""
|
|
echo "=== Vulnerability Summary ==="
|
|
grype sbom:./sbom-pr.cyclonedx.json --output table || true
|
|
CRITICAL=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' vuln-scan.json 2>/dev/null || echo "0")
|
|
HIGH=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' vuln-scan.json 2>/dev/null || echo "0")
|
|
MEDIUM=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' vuln-scan.json 2>/dev/null || echo "0")
|
|
LOW=$(jq '[.matches[] | select(.vulnerability.severity == "Low")] | length' vuln-scan.json 2>/dev/null || echo "0")
|
|
echo ""
|
|
echo "📊 Vulnerability Breakdown:"
|
|
echo " 🔴 Critical: ${CRITICAL}"
|
|
echo " 🟠 High: ${HIGH}"
|
|
echo " 🟡 Medium: ${MEDIUM}"
|
|
echo " 🟢 Low: ${LOW}"
|
|
echo "critical=${CRITICAL}" >> $GITHUB_OUTPUT
|
|
echo "high=${HIGH}" >> $GITHUB_OUTPUT
|
|
echo "medium=${MEDIUM}" >> $GITHUB_OUTPUT
|
|
echo "low=${LOW}" >> $GITHUB_OUTPUT
|
|
if [[ ${CRITICAL} -gt 0 ]]; then
|
|
echo "::error::${CRITICAL} CRITICAL vulnerabilities found - BLOCKING"
|
|
fi
|
|
if [[ ${HIGH} -gt 0 ]]; then
|
|
echo "::warning::${HIGH} HIGH vulnerabilities found"
|
|
fi
|
|
|
|
- name: Generate SARIF Report
|
|
if: always()
|
|
run: |
|
|
echo "📋 Generating SARIF report..."
|
|
grype sbom:./sbom-pr.cyclonedx.json --output sarif --file grype-results.sarif || true
|
|
|
|
# Critical Fix #3: SARIF category includes SHA to prevent conflicts
|
|
- name: Upload SARIF to GitHub Security
|
|
if: always()
|
|
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
|
with:
|
|
sarif_file: grype-results.sarif
|
|
category: supply-chain-pr-${{ github.event.pull_request.number }}-${{ github.sha }}
|
|
continue-on-error: true
|
|
|
|
- name: Upload Artifacts
|
|
if: always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
with:
|
|
name: supply-chain-pr-${{ github.event.pull_request.number }}
|
|
path: |
|
|
sbom-pr.cyclonedx.json
|
|
vuln-scan.json
|
|
grype-results.sarif
|
|
retention-days: 30
|
|
|
|
# Critical Fix #4: Null checks in PR comment
|
|
- name: Comment on PR
|
|
if: always()
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
script: |
|
|
const critical = '${{ steps.scan.outputs.critical }}' || '0';
|
|
const high = '${{ steps.scan.outputs.high }}' || '0';
|
|
const medium = '${{ steps.scan.outputs.medium }}' || '0';
|
|
const low = '${{ steps.scan.outputs.low }}' || '0';
|
|
const components = '${{ steps.sbom.outputs.component_count }}' || 'N/A';
|
|
const commitSha = '${{ github.sha }}'.substring(0, 7);
|
|
|
|
let status = '✅ **PASSED**';
|
|
let statusEmoji = '✅';
|
|
|
|
if (parseInt(critical) > 0) {
|
|
status = '❌ **BLOCKED** - Critical vulnerabilities found';
|
|
statusEmoji = '❌';
|
|
} else if (parseInt(high) > 0) {
|
|
status = '⚠️ **WARNING** - High vulnerabilities found';
|
|
statusEmoji = '⚠️';
|
|
}
|
|
|
|
const body = `## ${statusEmoji} Supply Chain Verification (PR Build)
|
|
|
|
**Status**: ${status}
|
|
**Commit**: \`${commitSha}\`
|
|
**Image**: \`${{ steps.image.outputs.ref }}\`
|
|
**Components Scanned**: ${components}
|
|
|
|
### 📊 Vulnerability Summary
|
|
|
|
| Severity | Count |
|
|
|----------|-------|
|
|
| 🔴 Critical | ${critical} |
|
|
| 🟠 High | ${high} |
|
|
| 🟡 Medium | ${medium} |
|
|
| 🟢 Low | ${low} |
|
|
|
|
${parseInt(critical) > 0 ? '### ❌ Critical Vulnerabilities Detected\n\n**Action Required**: This PR cannot be merged until critical vulnerabilities are resolved.\n\n' : ''}
|
|
${parseInt(high) > 0 ? '### ⚠️ High Vulnerabilities Detected\n\n**Recommendation**: Review and address high-severity vulnerabilities before merging.\n\n' : ''}
|
|
📋 [View Full Report](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})
|
|
📦 [Download Artifacts](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}#artifacts)
|
|
`;
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.issue.number,
|
|
body: body
|
|
});
|
|
|
|
- name: Fail on Critical Vulnerabilities
|
|
if: steps.scan.outputs.critical != '0'
|
|
run: |
|
|
echo "❌ CRITICAL: ${{ steps.scan.outputs.critical }} critical vulnerabilities found"
|
|
echo "This PR is blocked from merging until critical vulnerabilities are resolved."
|
|
exit 1
|
|
|
|
# Critical Fix #4: Null checks in job summary
|
|
- name: Create Job Summary
|
|
if: always()
|
|
run: |
|
|
# Use default values if outputs are not set
|
|
COMPONENT_COUNT="${{ steps.sbom.outputs.component_count }}"
|
|
CRITICAL="${{ steps.scan.outputs.critical }}"
|
|
HIGH="${{ steps.scan.outputs.high }}"
|
|
MEDIUM="${{ steps.scan.outputs.medium }}"
|
|
LOW="${{ steps.scan.outputs.low }}"
|
|
|
|
# Apply defaults
|
|
COMPONENT_COUNT="${COMPONENT_COUNT:-N/A}"
|
|
CRITICAL="${CRITICAL:-0}"
|
|
HIGH="${HIGH:-0}"
|
|
MEDIUM="${MEDIUM:-0}"
|
|
LOW="${LOW:-0}"
|
|
|
|
echo "## 🔒 Supply Chain Verification - PR #${{ github.event.pull_request.number }}" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "**Image**: \`${{ steps.image.outputs.ref }}\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "**Components**: ${COMPONENT_COUNT}" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "### Vulnerability Breakdown" >> $GITHUB_STEP_SUMMARY
|
|
echo "- 🔴 Critical: ${CRITICAL}" >> $GITHUB_STEP_SUMMARY
|
|
echo "- 🟠 High: ${HIGH}" >> $GITHUB_STEP_SUMMARY
|
|
echo "- 🟡 Medium: ${MEDIUM}" >> $GITHUB_STEP_SUMMARY
|
|
echo "- 🟢 Low: ${LOW}" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
|
|
if [[ ${CRITICAL} -gt 0 ]]; then
|
|
echo "❌ **BLOCKED**: Critical vulnerabilities must be resolved" >> $GITHUB_STEP_SUMMARY
|
|
elif [[ ${HIGH} -gt 0 ]]; then
|
|
echo "⚠️ **WARNING**: High vulnerabilities detected" >> $GITHUB_STEP_SUMMARY
|
|
else
|
|
echo "✅ **PASSED**: No critical or high vulnerabilities" >> $GITHUB_STEP_SUMMARY
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Supply Chain Verification - Skipped Feedback
|
|
# ============================================================================
|
|
# This job provides user feedback when the build is skipped (e.g., chore commits).
|
|
# Critical Fix #7: User feedback for skipped builds
|
|
# ============================================================================
|
|
verify-supply-chain-pr-skipped:
|
|
name: Supply Chain Verification (Skipped)
|
|
needs: build-and-push
|
|
runs-on: ubuntu-latest
|
|
if: |
|
|
github.event_name == 'pull_request' &&
|
|
needs.build-and-push.outputs.skip_build == 'true'
|
|
permissions:
|
|
pull-requests: write
|
|
|
|
steps:
|
|
- name: Comment on PR - Build Skipped
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
script: |
|
|
const commitSha = '${{ github.sha }}'.substring(0, 7);
|
|
const body = `## ⏭️ Supply Chain Verification (Skipped)
|
|
|
|
**Commit**: \`${commitSha}\`
|
|
**Reason**: Build was skipped (likely a documentation-only or chore commit)
|
|
|
|
Supply chain verification is not performed for skipped builds. If this commit should trigger a build, ensure it includes changes to application code or dependencies.
|
|
`;
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: context.issue.number,
|
|
body: body
|
|
});
|
|
|
|
# ============================================================================
|
|
# E2E Tests (Playwright) for PR Builds
|
|
# ============================================================================
|
|
# This job runs end-to-end tests using Playwright against the Docker image
|
|
# built for pull requests. It validates the application's functionality from
|
|
# the user's perspective before merging.
|
|
#
|
|
# Dependency Chain: build-and-push → e2e-tests-pr
|
|
# ============================================================================
|
|
e2e-tests-pr:
|
|
name: E2E Tests (Playwright)
|
|
needs: build-and-push
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
if: |
|
|
github.event_name == 'pull_request' &&
|
|
needs.build-and-push.outputs.skip_build != 'true' &&
|
|
needs.build-and-push.result == 'success'
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
|
|
|
- name: Download Docker image artifact
|
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
|
with:
|
|
name: pr-image-${{ github.event.pull_request.number }}
|
|
|
|
- name: Load Docker image
|
|
run: |
|
|
echo "📦 Loading image from artifact..."
|
|
docker load -i charon-pr-image.tar
|
|
echo "✅ Image loaded successfully"
|
|
|
|
- name: Normalize image name
|
|
run: |
|
|
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
|
|
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
|
|
|
|
- name: Verify loaded image
|
|
run: |
|
|
IMAGE_REF="ghcr.io/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
|
|
if ! docker image inspect "${IMAGE_REF}" >/dev/null 2>&1; then
|
|
echo "❌ ERROR: Image not found: ${IMAGE_REF}"
|
|
docker images
|
|
exit 1
|
|
fi
|
|
echo "✅ Image loaded: ${IMAGE_REF}"
|
|
|
|
- name: Start application container
|
|
run: |
|
|
IMAGE_REF="ghcr.io/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
|
|
docker run -d --name charon \
|
|
-p 8080:8080 \
|
|
-e CHARON_ENV=development \
|
|
-e CHARON_DEBUG=1 \
|
|
-e CHARON_ENCRYPTION_KEY=test-key-for-ci-only-not-production \
|
|
"${IMAGE_REF}"
|
|
|
|
- name: Wait for application health
|
|
run: |
|
|
echo "Waiting for application to be ready..."
|
|
timeout 120 bash -c 'until curl -sf http://localhost:8080/api/v1/health > /dev/null; do
|
|
echo "Waiting for health endpoint..."
|
|
sleep 2
|
|
done'
|
|
echo "✅ Application is ready"
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
|
with:
|
|
node-version: lts/*
|
|
|
|
- name: Install Playwright dependencies
|
|
run: |
|
|
npm ci
|
|
npx playwright install --with-deps
|
|
|
|
- name: Run Playwright E2E tests
|
|
env:
|
|
PLAYWRIGHT_BASE_URL: http://localhost:8080
|
|
run: npx playwright test
|
|
|
|
- name: Stop application container
|
|
if: always()
|
|
run: docker stop charon && docker rm charon
|
|
|
|
- name: Upload Playwright report
|
|
if: always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
with:
|
|
name: playwright-report-pr-${{ github.event.pull_request.number }}
|
|
path: playwright-report/
|
|
retention-days: 7
|
|
|
|
- name: Create E2E Test Summary
|
|
if: always()
|
|
run: |
|
|
echo "## 🎭 E2E Test Results - PR #${{ github.event.pull_request.number }}" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "**Image**: \`ghcr.io/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "**Status**: ${{ job.status == 'success' && '✅ All tests passed' || '❌ Tests failed' }}" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
if [[ "${{ job.status }}" != "success" ]]; then
|
|
echo "📊 [View Test Report](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}#artifacts)" >> $GITHUB_STEP_SUMMARY
|
|
fi
|