# Security Scan for Pull Requests # Runs Trivy security scanning on PR Docker images after the build workflow completes # This workflow extracts the charon binary from the container and performs filesystem scanning name: Security Scan (PR) on: workflow_run: workflows: ["Docker Build, Publish & Test"] types: [completed] workflow_dispatch: inputs: pr_number: description: 'PR number to scan' required: true type: string pull_request: push: branches: [main] concurrency: group: security-pr-${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: true permissions: contents: read jobs: security-scan: name: Trivy Binary Scan runs-on: ubuntu-latest timeout-minutes: 10 # Run for manual dispatch, direct PR/push, or successful upstream workflow_run if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.status == 'completed' && github.event.workflow_run.conclusion == 'success') permissions: contents: read security-events: write actions: read steps: - name: Checkout repository # actions/checkout v4.2.2 uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 with: ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} - name: Extract PR number from workflow_run id: pr-info env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if [[ "${{ github.event_name }}" == "push" ]]; then echo "pr_number=" >> "$GITHUB_OUTPUT" echo "is_push=true" >> "$GITHUB_OUTPUT" echo "✅ Push event detected; using local image path" exit 0 fi if [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" echo "is_push=false" >> "$GITHUB_OUTPUT" echo "✅ Pull request event detected: PR #${{ github.event.pull_request.number }}" exit 0 fi if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then INPUT_PR_NUMBER="${{ inputs.pr_number }}" if [[ -z "${INPUT_PR_NUMBER}" ]]; then echo "❌ workflow_dispatch requires inputs.pr_number" exit 1 fi if [[ ! "${INPUT_PR_NUMBER}" =~ ^[0-9]+$ ]]; then echo "❌ reason_category=invalid_input" echo "reason=workflow_dispatch pr_number must be digits-only" exit 1 fi PR_NUMBER="${INPUT_PR_NUMBER}" echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" echo "is_push=false" >> "$GITHUB_OUTPUT" echo "✅ Using manually provided PR number: ${PR_NUMBER}" exit 0 fi if [[ "${{ github.event_name }}" == "workflow_run" ]]; then if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then # Explicit contract validation happens in the dedicated guard step. echo "pr_number=" >> "$GITHUB_OUTPUT" echo "is_push=false" >> "$GITHUB_OUTPUT" exit 0 fi if [[ -n "${{ github.event.workflow_run.pull_requests[0].number || '' }}" ]]; then echo "pr_number=${{ github.event.workflow_run.pull_requests[0].number }}" >> "$GITHUB_OUTPUT" echo "is_push=false" >> "$GITHUB_OUTPUT" echo "✅ Found PR number from workflow_run payload: ${{ github.event.workflow_run.pull_requests[0].number }}" exit 0 fi fi # Extract PR number from context HEAD_SHA="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}" echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}" # Query GitHub API for PR associated with this commit PR_NUMBER=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \ --jq '.[0].number // empty' 2>/dev/null || echo "") if [[ -n "${PR_NUMBER}" ]]; then echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" echo "is_push=false" >> "$GITHUB_OUTPUT" echo "✅ Found PR number: ${PR_NUMBER}" else echo "❌ Could not determine PR number for workflow_run SHA: ${HEAD_SHA}" exit 1 fi - name: Validate workflow_run trust boundary and event contract if: github.event_name == 'workflow_run' run: | if [[ "${{ github.event.workflow_run.name }}" != "Docker Build, Publish & Test" ]]; then echo "❌ reason_category=unexpected_upstream_workflow" echo "workflow_name=${{ github.event.workflow_run.name }}" exit 1 fi if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then echo "❌ reason_category=unsupported_upstream_event" echo "upstream_event=${{ github.event.workflow_run.event }}" echo "run_id=${{ github.event.workflow_run.id }}" exit 1 fi if [[ "${{ github.event.workflow_run.head_repository.full_name }}" != "${{ github.repository }}" ]]; then echo "❌ reason_category=untrusted_upstream_repository" echo "upstream_head_repository=${{ github.event.workflow_run.head_repository.full_name }}" echo "expected_repository=${{ github.repository }}" exit 1 fi echo "✅ workflow_run trust boundary and event contract validated" - name: Build Docker image (Local) if: github.event_name == 'push' || github.event_name == 'pull_request' run: | echo "Building image locally for security scan..." docker build -t charon:local . echo "✅ Successfully built charon:local" - name: Check for PR image artifact id: check-artifact if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}" if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then echo "❌ reason_category=invalid_input" echo "reason=Resolved PR number must be digits-only" exit 1 fi ARTIFACT_NAME="pr-image-${PR_NUMBER}" RUN_ID="${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || '' }}" echo "🔍 Checking for artifact: ${ARTIFACT_NAME}" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then # Manual replay path: find latest successful docker-build pull_request run for this PR. RUNS_JSON=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1) RUNS_STATUS=$? if [[ ${RUNS_STATUS} -ne 0 ]]; then echo "❌ reason_category=api_error" echo "reason=Failed to query workflow runs for PR lookup" echo "upstream_run_id=unknown" echo "artifact_name=${ARTIFACT_NAME}" echo "api_output=${RUNS_JSON}" exit 1 fi RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1) if [[ -z "${RUN_ID}" ]]; then echo "❌ reason_category=not_found" echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}" echo "upstream_run_id=unknown" echo "artifact_name=${ARTIFACT_NAME}" exit 1 fi fi echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" # Check if the artifact exists in the workflow run ARTIFACTS_JSON=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" 2>&1) ARTIFACTS_STATUS=$? if [[ ${ARTIFACTS_STATUS} -ne 0 ]]; then echo "❌ reason_category=api_error" echo "reason=Failed to query artifacts for upstream run" echo "upstream_run_id=${RUN_ID}" echo "artifact_name=${ARTIFACT_NAME}" echo "api_output=${ARTIFACTS_JSON}" exit 1 fi ARTIFACT_ID=$(printf '%s' "${ARTIFACTS_JSON}" | jq -r --arg name "${ARTIFACT_NAME}" '.artifacts[] | select(.name == $name) | .id' | head -n 1) if [[ -z "${ARTIFACT_ID}" ]]; then echo "❌ reason_category=not_found" echo "reason=Required artifact was not found" echo "upstream_run_id=${RUN_ID}" echo "artifact_name=${ARTIFACT_NAME}" exit 1 fi { echo "artifact_exists=true" echo "artifact_id=${ARTIFACT_ID}" echo "artifact_name=${ARTIFACT_NAME}" } >> "$GITHUB_OUTPUT" echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})" - name: Download PR image artifact if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' # actions/download-artifact v4.1.8 uses: actions/download-artifact@484a0b528fb4d7bd804637ccb632e47a0e638317 with: name: ${{ steps.check-artifact.outputs.artifact_name }} run-id: ${{ steps.check-artifact.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Load Docker image if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' id: load-image run: | echo "📦 Loading Docker image..." if [[ ! -r "charon-pr-image.tar" ]]; then echo "❌ ERROR: Artifact image tar is missing or unreadable" exit 1 fi MANIFEST_TAGS="" if tar -tf charon-pr-image.tar | grep -qx "manifest.json"; then MANIFEST_TAGS=$(tar -xOf charon-pr-image.tar manifest.json 2>/dev/null | jq -r '.[]?.RepoTags[]?' 2>/dev/null | sed '/^$/d' || true) else echo "⚠️ manifest.json not found in artifact tar; will try docker-load-image-id fallback" fi LOAD_OUTPUT=$(docker load < charon-pr-image.tar 2>&1) echo "${LOAD_OUTPUT}" SOURCE_IMAGE_REF="" SOURCE_RESOLUTION_MODE="" while IFS= read -r tag; do [[ -z "${tag}" ]] && continue if docker image inspect "${tag}" >/dev/null 2>&1; then SOURCE_IMAGE_REF="${tag}" SOURCE_RESOLUTION_MODE="manifest_tag" break fi done <<< "${MANIFEST_TAGS}" if [[ -z "${SOURCE_IMAGE_REF}" ]]; then LOAD_IMAGE_ID=$(printf '%s\n' "${LOAD_OUTPUT}" | sed -nE 's/^Loaded image ID: (sha256:[0-9a-f]+)$/\1/p' | head -n1) if [[ -n "${LOAD_IMAGE_ID}" ]] && docker image inspect "${LOAD_IMAGE_ID}" >/dev/null 2>&1; then SOURCE_IMAGE_REF="${LOAD_IMAGE_ID}" SOURCE_RESOLUTION_MODE="load_image_id" fi fi if [[ -z "${SOURCE_IMAGE_REF}" ]]; then echo "❌ ERROR: Could not resolve a valid image reference from manifest tags or docker load image ID" exit 1 fi docker tag "${SOURCE_IMAGE_REF}" "charon:artifact" { echo "source_image_ref=${SOURCE_IMAGE_REF}" echo "source_resolution_mode=${SOURCE_RESOLUTION_MODE}" echo "image_ref=charon:artifact" } >> "$GITHUB_OUTPUT" echo "✅ Docker image resolved via ${SOURCE_RESOLUTION_MODE} and tagged as charon:artifact" docker images | grep charon - name: Extract charon binary from container if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request' id: extract run: | # Use local image for Push/PR events if [[ "${{ github.event_name }}" == "push" || "${{ github.event_name }}" == "pull_request" ]]; then echo "Using local image: charon:local" CONTAINER_ID=$(docker create "charon:local") echo "container_id=${CONTAINER_ID}" >> "$GITHUB_OUTPUT" # Extract the charon binary mkdir -p ./scan-target docker cp "${CONTAINER_ID}:/app/charon" ./scan-target/charon docker rm "${CONTAINER_ID}" if [[ -f "./scan-target/charon" ]]; then echo "✅ Binary extracted successfully" ls -lh ./scan-target/charon echo "binary_path=./scan-target" >> "$GITHUB_OUTPUT" else echo "❌ Failed to extract binary" exit 1 fi exit 0 fi # For workflow_run artifact path, always use locally tagged image from loaded artifact. IMAGE_REF="${{ steps.load-image.outputs.image_ref }}" if [[ -z "${IMAGE_REF}" ]]; then echo "❌ ERROR: Loaded artifact image reference is empty" exit 1 fi echo "🔍 Extracting binary from: ${IMAGE_REF}" # Create container without starting it CONTAINER_ID=$(docker create "${IMAGE_REF}") echo "container_id=${CONTAINER_ID}" >> "$GITHUB_OUTPUT" # Extract the charon binary mkdir -p ./scan-target docker cp "${CONTAINER_ID}:/app/charon" ./scan-target/charon # Cleanup container docker rm "${CONTAINER_ID}" # Verify extraction if [[ -f "./scan-target/charon" ]]; then echo "✅ Binary extracted successfully" ls -lh ./scan-target/charon echo "binary_path=./scan-target" >> "$GITHUB_OUTPUT" else echo "❌ Failed to extract binary" exit 1 fi - name: Run Trivy filesystem scan (SARIF output) if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request' # aquasecurity/trivy-action 0.35.0 uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 with: scan-type: 'fs' scan-ref: ${{ steps.extract.outputs.binary_path }} format: 'sarif' output: 'trivy-binary-results.sarif' severity: 'CRITICAL,HIGH,MEDIUM' continue-on-error: true - name: Check Trivy SARIF output exists if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request') id: trivy-sarif-check run: | if [[ -f trivy-binary-results.sarif ]]; then echo "exists=true" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" echo "ℹ️ No Trivy SARIF output found; skipping SARIF/artifact upload steps" fi - name: Upload Trivy SARIF to GitHub Security if: always() && steps.trivy-sarif-check.outputs.exists == 'true' # github/codeql-action v4 uses: github/codeql-action/upload-sarif@34950e1b113b30df4edee1a6d3a605242df0c40b with: sarif_file: 'trivy-binary-results.sarif' category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }} continue-on-error: true - name: Run Trivy filesystem scan (fail on CRITICAL/HIGH) if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request' # aquasecurity/trivy-action 0.35.0 uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 with: scan-type: 'fs' scan-ref: ${{ steps.extract.outputs.binary_path }} format: 'table' severity: 'CRITICAL,HIGH' exit-code: '1' - name: Upload scan artifacts if: always() && steps.trivy-sarif-check.outputs.exists == 'true' # actions/upload-artifact v4.4.3 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }} path: | trivy-binary-results.sarif retention-days: 14 - name: Create job summary if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request') run: | { if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then echo "## 🔒 Security Scan Results - Branch: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }}" else echo "## 🔒 Security Scan Results - PR #${{ steps.pr-info.outputs.pr_number }}" fi echo "" echo "**Scan Type**: Trivy Filesystem Scan" echo "**Target**: \`/app/charon\` binary" echo "**Severity Filter**: CRITICAL, HIGH" echo "" if [[ "${{ job.status }}" == "success" ]]; then echo "✅ **PASSED**: No CRITICAL or HIGH vulnerabilities found" else echo "❌ **FAILED**: CRITICAL or HIGH vulnerabilities detected" echo "" echo "Please review the Trivy scan output and address the vulnerabilities." fi } >> "$GITHUB_STEP_SUMMARY" - name: Cleanup if: always() && steps.check-artifact.outputs.artifact_exists == 'true' run: | echo "🧹 Cleaning up..." rm -rf ./scan-target echo "✅ Cleanup complete"