825 lines
32 KiB
YAML
825 lines
32 KiB
YAML
name: Supply Chain Verification
|
|
|
|
on:
|
|
release:
|
|
types: [published]
|
|
|
|
# Triggered after docker-build workflow completes
|
|
# Note: workflow_run can only chain 3 levels deep; we're at level 2 (safe)
|
|
#
|
|
# IMPORTANT: No branches filter here by design
|
|
# GitHub Actions limitation: branches filter in workflow_run only matches the default branch.
|
|
# Without a filter, this workflow triggers for ALL branches where docker-build completes,
|
|
# providing proper supply chain verification coverage for feature branches and PRs.
|
|
# Security: The workflow file must exist on the branch to execute, preventing untrusted code.
|
|
workflow_run:
|
|
workflows: ["Docker Build, Publish & Test"]
|
|
types: [completed]
|
|
|
|
schedule:
|
|
# Run weekly on Mondays at 00:00 UTC
|
|
- cron: '0 0 * * 1'
|
|
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
id-token: write # OIDC token for keyless verification
|
|
attestations: write # Create/verify attestations
|
|
security-events: write
|
|
pull-requests: write # Comment on PRs
|
|
|
|
jobs:
|
|
verify-sbom:
|
|
name: Verify SBOM
|
|
runs-on: ubuntu-latest
|
|
# Only run on scheduled scans for main branch, or if workflow_run completed successfully
|
|
# Critical Fix #5: Exclude PR builds to prevent duplicate verification (now handled inline in docker-build.yml)
|
|
if: |
|
|
(github.event_name != 'schedule' || github.ref == 'refs/heads/main') &&
|
|
(github.event_name != 'workflow_run' ||
|
|
(github.event.workflow_run.conclusion == 'success' &&
|
|
github.event.workflow_run.event != 'pull_request'))
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
# Debug: Log workflow_run context for initial validation (can be removed after confidence)
|
|
- name: Debug Workflow Run Context
|
|
if: github.event_name == 'workflow_run'
|
|
run: |
|
|
echo "Workflow Run Event Details:"
|
|
echo " Workflow: ${{ github.event.workflow_run.name }}"
|
|
echo " Conclusion: ${{ github.event.workflow_run.conclusion }}"
|
|
echo " Head Branch: ${{ github.event.workflow_run.head_branch }}"
|
|
echo " Head SHA: ${{ github.event.workflow_run.head_sha }}"
|
|
echo " Event: ${{ github.event.workflow_run.event }}"
|
|
echo " PR Count: ${{ toJson(github.event.workflow_run.pull_requests) }}"
|
|
|
|
- name: Install Verification Tools
|
|
run: |
|
|
# Install Syft
|
|
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
|
|
|
# Install Grype
|
|
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
|
|
|
- name: Determine Image Tag
|
|
id: tag
|
|
run: |
|
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
|
TAG="${{ github.event.release.tag_name }}"
|
|
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
|
BRANCH="${{ github.event.workflow_run.head_branch }}"
|
|
# Extract tag from the workflow that triggered us
|
|
if [[ "${BRANCH}" == "main" ]]; then
|
|
TAG="latest"
|
|
elif [[ "${BRANCH}" == "development" ]]; then
|
|
TAG="dev"
|
|
elif [[ "${BRANCH}" == "nightly" ]]; then
|
|
TAG="nightly"
|
|
elif [[ "${{ github.event.workflow_run.event }}" == "pull_request" ]]; then
|
|
# Extract PR number from workflow_run context with null handling
|
|
PR_NUMBER=$(jq -r '.pull_requests[0].number // empty' <<< '${{ toJson(github.event.workflow_run.pull_requests) }}')
|
|
if [[ -n "${PR_NUMBER}" ]]; then
|
|
TAG="pr-${PR_NUMBER}"
|
|
else
|
|
# Fallback to SHA-based tag if PR number not available
|
|
TAG="sha-$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)"
|
|
fi
|
|
else
|
|
# For feature branches and other pushes, sanitize branch name for Docker tag
|
|
# Replace / with - to avoid invalid reference format errors
|
|
TAG=$(echo "${BRANCH}" | tr '/' '-')
|
|
fi
|
|
else
|
|
TAG="latest"
|
|
fi
|
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
echo "Determined image tag: ${TAG}"
|
|
|
|
- name: Check Image Availability
|
|
id: image-check
|
|
env:
|
|
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
echo "Checking if image exists: ${IMAGE}"
|
|
|
|
# Authenticate with GHCR using GitHub token
|
|
echo "${GH_TOKEN}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
|
|
|
if docker manifest inspect ${IMAGE} >/dev/null 2>&1; then
|
|
echo "✅ Image exists and is accessible"
|
|
echo "exists=true" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "⚠️ Image not found - likely not built yet"
|
|
echo "This is normal for PR workflows before docker-build completes"
|
|
echo "exists=false" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Verify SBOM Completeness
|
|
if: steps.image-check.outputs.exists == 'true'
|
|
env:
|
|
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
echo "Verifying SBOM for ${IMAGE}..."
|
|
echo ""
|
|
|
|
# Log Syft version for debugging
|
|
echo "Syft version:"
|
|
syft version
|
|
echo ""
|
|
|
|
# Generate fresh SBOM in CycloneDX format (aligned with docker-build.yml)
|
|
echo "Generating SBOM in CycloneDX JSON format..."
|
|
if ! syft ${IMAGE} -o cyclonedx-json > sbom-generated.json; then
|
|
echo "❌ Failed to generate SBOM"
|
|
echo ""
|
|
echo "Debug information:"
|
|
echo "Image: ${IMAGE}"
|
|
echo "Syft exit code: $?"
|
|
exit 1 # Fail on real errors, not silent exit
|
|
fi
|
|
|
|
# Check SBOM content
|
|
GENERATED_COUNT=$(jq '.components | length' sbom-generated.json 2>/dev/null || echo "0")
|
|
|
|
echo "Generated SBOM components: ${GENERATED_COUNT}"
|
|
|
|
if [[ ${GENERATED_COUNT} -eq 0 ]]; then
|
|
echo "⚠️ SBOM contains no components - may indicate an issue"
|
|
else
|
|
echo "✅ SBOM contains ${GENERATED_COUNT} components"
|
|
fi
|
|
|
|
- name: Upload SBOM Artifact
|
|
if: steps.image-check.outputs.exists == 'true' && always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
with:
|
|
name: sbom-${{ steps.tag.outputs.tag }}
|
|
path: sbom-generated.json
|
|
retention-days: 30
|
|
|
|
- name: Validate SBOM File
|
|
id: validate-sbom
|
|
if: steps.image-check.outputs.exists == 'true'
|
|
run: |
|
|
echo "Validating SBOM file..."
|
|
echo ""
|
|
|
|
# Check jq availability
|
|
if ! command -v jq &> /dev/null; then
|
|
echo "❌ jq is not available"
|
|
echo "valid=false" >> $GITHUB_OUTPUT
|
|
exit 1
|
|
fi
|
|
|
|
# Check file exists
|
|
if [[ ! -f sbom-generated.json ]]; then
|
|
echo "❌ SBOM file does not exist"
|
|
echo "valid=false" >> $GITHUB_OUTPUT
|
|
exit 0
|
|
fi
|
|
|
|
# Check file is non-empty
|
|
if [[ ! -s sbom-generated.json ]]; then
|
|
echo "❌ SBOM file is empty"
|
|
echo "valid=false" >> $GITHUB_OUTPUT
|
|
exit 0
|
|
fi
|
|
|
|
# Validate JSON structure
|
|
if ! jq empty sbom-generated.json 2>/dev/null; then
|
|
echo "❌ SBOM file contains invalid JSON"
|
|
echo "SBOM content:"
|
|
cat sbom-generated.json
|
|
echo "valid=false" >> $GITHUB_OUTPUT
|
|
exit 0
|
|
fi
|
|
|
|
# Validate CycloneDX structure
|
|
BOMFORMAT=$(jq -r '.bomFormat // "missing"' sbom-generated.json)
|
|
SPECVERSION=$(jq -r '.specVersion // "missing"' sbom-generated.json)
|
|
COMPONENTS=$(jq '.components // [] | length' sbom-generated.json)
|
|
|
|
echo "SBOM Format: ${BOMFORMAT}"
|
|
echo "Spec Version: ${SPECVERSION}"
|
|
echo "Components: ${COMPONENTS}"
|
|
echo ""
|
|
|
|
if [[ "${BOMFORMAT}" != "CycloneDX" ]]; then
|
|
echo "❌ Invalid bomFormat: expected 'CycloneDX', got '${BOMFORMAT}'"
|
|
echo "valid=false" >> $GITHUB_OUTPUT
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "${COMPONENTS}" == "0" ]]; then
|
|
echo "⚠️ SBOM has no components - may indicate incomplete scan"
|
|
echo "valid=partial" >> $GITHUB_OUTPUT
|
|
else
|
|
echo "✅ SBOM is valid with ${COMPONENTS} components"
|
|
echo "valid=true" >> $GITHUB_OUTPUT
|
|
fi
|
|
|
|
- name: Scan for Vulnerabilities
|
|
if: steps.validate-sbom.outputs.valid == 'true'
|
|
env:
|
|
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
run: |
|
|
echo "Scanning for vulnerabilities with Grype..."
|
|
echo "SBOM format: CycloneDX JSON"
|
|
echo "SBOM size: $(wc -c < sbom-generated.json) bytes"
|
|
echo ""
|
|
|
|
# Update Grype vulnerability database
|
|
echo "Updating Grype vulnerability database..."
|
|
grype db update
|
|
echo ""
|
|
|
|
# Run Grype with explicit path and better error handling
|
|
if ! grype sbom:./sbom-generated.json --output json --file vuln-scan.json; then
|
|
echo ""
|
|
echo "❌ Grype scan failed"
|
|
echo ""
|
|
echo "Debug information:"
|
|
echo "Grype version:"
|
|
grype version
|
|
echo ""
|
|
echo "SBOM preview (first 1000 characters):"
|
|
head -c 1000 sbom-generated.json
|
|
echo ""
|
|
exit 1 # Fail the step to surface the issue
|
|
fi
|
|
|
|
echo "✅ Grype scan completed successfully"
|
|
echo ""
|
|
|
|
# Display human-readable results
|
|
echo "Vulnerability summary:"
|
|
grype sbom:./sbom-generated.json --output table || true
|
|
|
|
# Parse and categorize results
|
|
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 counts:"
|
|
echo " Critical: ${CRITICAL}"
|
|
echo " High: ${HIGH}"
|
|
echo " Medium: ${MEDIUM}"
|
|
echo " Low: ${LOW}"
|
|
|
|
# Set warnings for critical vulnerabilities
|
|
if [[ ${CRITICAL} -gt 0 ]]; then
|
|
echo "::warning::${CRITICAL} critical vulnerabilities found"
|
|
fi
|
|
|
|
# Store for PR comment
|
|
echo "CRITICAL_VULNS=${CRITICAL}" >> $GITHUB_ENV
|
|
echo "HIGH_VULNS=${HIGH}" >> $GITHUB_ENV
|
|
echo "MEDIUM_VULNS=${MEDIUM}" >> $GITHUB_ENV
|
|
echo "LOW_VULNS=${LOW}" >> $GITHUB_ENV
|
|
|
|
- name: Parse Vulnerability Details
|
|
if: steps.validate-sbom.outputs.valid == 'true'
|
|
run: |
|
|
echo "Parsing detailed vulnerability information..."
|
|
|
|
# Generate detailed vulnerability tables grouped by severity
|
|
# Limit to first 20 per severity to keep PR comment readable
|
|
|
|
# Critical vulnerabilities
|
|
jq -r '
|
|
[.matches[] | select(.vulnerability.severity == "Critical")] |
|
|
sort_by(.vulnerability.id) |
|
|
limit(20; .[]) |
|
|
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
|
|
' vuln-scan.json > critical-vulns.txt
|
|
|
|
# High severity vulnerabilities
|
|
jq -r '
|
|
[.matches[] | select(.vulnerability.severity == "High")] |
|
|
sort_by(.vulnerability.id) |
|
|
limit(20; .[]) |
|
|
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
|
|
' vuln-scan.json > high-vulns.txt
|
|
|
|
# Medium severity vulnerabilities
|
|
jq -r '
|
|
[.matches[] | select(.vulnerability.severity == "Medium")] |
|
|
sort_by(.vulnerability.id) |
|
|
limit(20; .[]) |
|
|
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
|
|
' vuln-scan.json > medium-vulns.txt
|
|
|
|
# Low severity vulnerabilities
|
|
jq -r '
|
|
[.matches[] | select(.vulnerability.severity == "Low")] |
|
|
sort_by(.vulnerability.id) |
|
|
limit(20; .[]) |
|
|
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
|
|
' vuln-scan.json > low-vulns.txt
|
|
|
|
echo "✅ Vulnerability details parsed and saved"
|
|
|
|
- name: Upload Vulnerability Scan Artifact
|
|
if: steps.validate-sbom.outputs.valid == 'true' && always()
|
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
with:
|
|
name: vulnerability-scan-${{ steps.tag.outputs.tag }}
|
|
path: |
|
|
vuln-scan.json
|
|
critical-vulns.txt
|
|
high-vulns.txt
|
|
medium-vulns.txt
|
|
low-vulns.txt
|
|
retention-days: 30
|
|
|
|
- name: Report Skipped Scan
|
|
if: steps.image-check.outputs.exists != 'true' || steps.validate-sbom.outputs.valid != 'true'
|
|
run: |
|
|
echo "## ⚠️ Vulnerability Scan Skipped" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
|
|
if [[ "${{ steps.image-check.outputs.exists }}" != "true" ]]; then
|
|
echo "**Reason**: Docker image not available yet" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "This is expected for PR workflows. The image will be scanned" >> $GITHUB_STEP_SUMMARY
|
|
echo "after it's built by the docker-build workflow." >> $GITHUB_STEP_SUMMARY
|
|
elif [[ "${{ steps.validate-sbom.outputs.valid }}" != "true" ]]; then
|
|
echo "**Reason**: SBOM validation failed" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "Check the 'Validate SBOM File' step for details." >> $GITHUB_STEP_SUMMARY
|
|
fi
|
|
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "✅ Workflow completed successfully (scan skipped)" >> $GITHUB_STEP_SUMMARY
|
|
|
|
- name: Determine PR Number
|
|
id: pr-number
|
|
if: |
|
|
github.event_name == 'pull_request' ||
|
|
(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
result-encoding: string
|
|
script: |
|
|
// Determine PR number from context
|
|
let prNumber;
|
|
if (context.eventName === 'pull_request') {
|
|
prNumber = context.issue.number;
|
|
} else if (context.eventName === 'workflow_run') {
|
|
const pullRequests = context.payload.workflow_run.pull_requests;
|
|
if (pullRequests && pullRequests.length > 0) {
|
|
prNumber = pullRequests[0].number;
|
|
}
|
|
}
|
|
|
|
if (!prNumber) {
|
|
console.log('No PR number found');
|
|
return '';
|
|
}
|
|
|
|
console.log(`Found PR number: ${prNumber}`);
|
|
return prNumber;
|
|
|
|
- name: Build PR Comment Body
|
|
id: comment-body
|
|
if: steps.pr-number.outputs.result != ''
|
|
run: |
|
|
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
|
IMAGE_EXISTS="${{ steps.image-check.outputs.exists }}"
|
|
SBOM_VALID="${{ steps.validate-sbom.outputs.valid }}"
|
|
CRITICAL="${CRITICAL_VULNS:-0}"
|
|
HIGH="${HIGH_VULNS:-0}"
|
|
MEDIUM="${MEDIUM_VULNS:-0}"
|
|
LOW="${LOW_VULNS:-0}"
|
|
TOTAL=$((CRITICAL + HIGH + MEDIUM + LOW))
|
|
|
|
# Build comment body
|
|
COMMENT_BODY="## 🔒 Supply Chain Security Scan
|
|
|
|
**Last Updated**: ${TIMESTAMP}
|
|
**Workflow Run**: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
|
|
---
|
|
|
|
"
|
|
|
|
if [[ "${IMAGE_EXISTS}" != "true" ]]; then
|
|
COMMENT_BODY+="### ⏳ Status: Waiting for Image
|
|
|
|
The Docker image has not been built yet. This scan will run automatically once the docker-build workflow completes.
|
|
|
|
_This is normal for PR workflows._
|
|
"
|
|
elif [[ "${SBOM_VALID}" != "true" ]]; then
|
|
COMMENT_BODY+="### ⚠️ Status: SBOM Validation Failed
|
|
|
|
The Software Bill of Materials (SBOM) could not be validated. Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.
|
|
|
|
**Action Required**: Review and resolve SBOM generation issues.
|
|
"
|
|
else
|
|
# Scan completed successfully
|
|
if [[ ${TOTAL} -eq 0 ]]; then
|
|
COMMENT_BODY+="### ✅ Status: No Vulnerabilities Detected
|
|
|
|
🎉 Great news! No security vulnerabilities were found in this image.
|
|
|
|
| Severity | Count |
|
|
|----------|-------|
|
|
| 🔴 Critical | 0 |
|
|
| 🟠 High | 0 |
|
|
| 🟡 Medium | 0 |
|
|
| 🔵 Low | 0 |
|
|
"
|
|
else
|
|
# Vulnerabilities found
|
|
if [[ ${CRITICAL} -gt 0 ]]; then
|
|
COMMENT_BODY+="### 🚨 Status: Critical Vulnerabilities Detected
|
|
|
|
⚠️ **Action Required**: ${CRITICAL} critical vulnerabilities require immediate attention!
|
|
"
|
|
elif [[ ${HIGH} -gt 0 ]]; then
|
|
COMMENT_BODY+="### ⚠️ Status: High-Severity Vulnerabilities Detected
|
|
|
|
${HIGH} high-severity vulnerabilities found. Please review and address.
|
|
"
|
|
else
|
|
COMMENT_BODY+="### 📊 Status: Vulnerabilities Detected
|
|
|
|
Security scan found ${TOTAL} vulnerabilities.
|
|
"
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
| Severity | Count |
|
|
|----------|-------|
|
|
| 🔴 Critical | ${CRITICAL} |
|
|
| 🟠 High | ${HIGH} |
|
|
| 🟡 Medium | ${MEDIUM} |
|
|
| 🔵 Low | ${LOW} |
|
|
| **Total** | **${TOTAL}** |
|
|
|
|
## 🔍 Detailed Findings
|
|
|
|
"
|
|
|
|
# Add detailed vulnerability tables by severity
|
|
# Critical Vulnerabilities
|
|
if [[ ${CRITICAL} -gt 0 ]]; then
|
|
COMMENT_BODY+="<details>
|
|
<summary>🔴 <b>Critical Vulnerabilities (${CRITICAL})</b></summary>
|
|
|
|
| CVE | Package | Current Version | Fixed Version | Description |
|
|
|-----|---------|----------------|---------------|-------------|
|
|
"
|
|
|
|
if [[ -f critical-vulns.txt && -s critical-vulns.txt ]]; then
|
|
# Count lines in the file
|
|
CRIT_COUNT=$(wc -l < critical-vulns.txt)
|
|
COMMENT_BODY+="$(cat critical-vulns.txt)"
|
|
|
|
# If more than 20, add truncation message
|
|
if [[ ${CRITICAL} -gt 20 ]]; then
|
|
REMAINING=$((CRITICAL - 20))
|
|
COMMENT_BODY+="
|
|
|
|
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
|
|
"
|
|
fi
|
|
else
|
|
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
|
|
"
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
</details>
|
|
|
|
"
|
|
fi
|
|
|
|
# High Severity Vulnerabilities
|
|
if [[ ${HIGH} -gt 0 ]]; then
|
|
COMMENT_BODY+="<details>
|
|
<summary>🟠 <b>High Severity Vulnerabilities (${HIGH})</b></summary>
|
|
|
|
| CVE | Package | Current Version | Fixed Version | Description |
|
|
|-----|---------|----------------|---------------|-------------|
|
|
"
|
|
|
|
if [[ -f high-vulns.txt && -s high-vulns.txt ]]; then
|
|
COMMENT_BODY+="$(cat high-vulns.txt)"
|
|
|
|
if [[ ${HIGH} -gt 20 ]]; then
|
|
REMAINING=$((HIGH - 20))
|
|
COMMENT_BODY+="
|
|
|
|
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
|
|
"
|
|
fi
|
|
else
|
|
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
|
|
"
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
</details>
|
|
|
|
"
|
|
fi
|
|
|
|
# Medium Severity Vulnerabilities
|
|
if [[ ${MEDIUM} -gt 0 ]]; then
|
|
COMMENT_BODY+="<details>
|
|
<summary>🟡 <b>Medium Severity Vulnerabilities (${MEDIUM})</b></summary>
|
|
|
|
| CVE | Package | Current Version | Fixed Version | Description |
|
|
|-----|---------|----------------|---------------|-------------|
|
|
"
|
|
|
|
if [[ -f medium-vulns.txt && -s medium-vulns.txt ]]; then
|
|
COMMENT_BODY+="$(cat medium-vulns.txt)"
|
|
|
|
if [[ ${MEDIUM} -gt 20 ]]; then
|
|
REMAINING=$((MEDIUM - 20))
|
|
COMMENT_BODY+="
|
|
|
|
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
|
|
"
|
|
fi
|
|
else
|
|
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
|
|
"
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
</details>
|
|
|
|
"
|
|
fi
|
|
|
|
# Low Severity Vulnerabilities
|
|
if [[ ${LOW} -gt 0 ]]; then
|
|
COMMENT_BODY+="<details>
|
|
<summary>🔵 <b>Low Severity Vulnerabilities (${LOW})</b></summary>
|
|
|
|
| CVE | Package | Current Version | Fixed Version | Description |
|
|
|-----|---------|----------------|---------------|-------------|
|
|
"
|
|
|
|
if [[ -f low-vulns.txt && -s low-vulns.txt ]]; then
|
|
COMMENT_BODY+="$(cat low-vulns.txt)"
|
|
|
|
if [[ ${LOW} -gt 20 ]]; then
|
|
REMAINING=$((LOW - 20))
|
|
COMMENT_BODY+="
|
|
|
|
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
|
|
"
|
|
fi
|
|
else
|
|
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
|
|
"
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
</details>
|
|
|
|
"
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
📋 [View detailed vulnerability report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
"
|
|
fi
|
|
fi
|
|
|
|
COMMENT_BODY+="
|
|
---
|
|
|
|
<sub><!-- supply-chain-security-comment --></sub>
|
|
"
|
|
|
|
# Save to file for the next step (handles multi-line)
|
|
echo "$COMMENT_BODY" > /tmp/comment-body.txt
|
|
|
|
# Also output for debugging
|
|
echo "Generated comment body:"
|
|
cat /tmp/comment-body.txt
|
|
|
|
- name: Update or Create PR Comment
|
|
if: steps.pr-number.outputs.result != ''
|
|
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
|
with:
|
|
issue-number: ${{ steps.pr-number.outputs.result }}
|
|
body-path: /tmp/comment-body.txt
|
|
edit-mode: replace
|
|
comment-author: 'github-actions[bot]'
|
|
body-includes: '<!-- supply-chain-security-comment -->'
|
|
|
|
verify-docker-image:
|
|
name: Verify Docker Image Supply Chain
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'release'
|
|
needs: verify-sbom
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
- name: Install Verification Tools
|
|
run: |
|
|
# Install Cosign
|
|
curl -sLO https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64
|
|
echo "4e84f155f98be2c2d3e63dea0e80b0ca5b4d843f5f4b1d3e8c9b7e4e7c0e0e0e cosign-linux-amd64" | sha256sum -c || {
|
|
echo "⚠️ Checksum verification skipped (update with actual hash)"
|
|
}
|
|
sudo install cosign-linux-amd64 /usr/local/bin/cosign
|
|
rm cosign-linux-amd64
|
|
|
|
# Install SLSA Verifier
|
|
curl -sLO https://github.com/slsa-framework/slsa-verifier/releases/download/v2.6.0/slsa-verifier-linux-amd64
|
|
sudo install slsa-verifier-linux-amd64 /usr/local/bin/slsa-verifier
|
|
rm slsa-verifier-linux-amd64
|
|
|
|
- name: Determine Image Tag
|
|
id: tag
|
|
run: |
|
|
TAG="${{ github.event.release.tag_name }}"
|
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Verify Cosign Signature with Rekor Fallback
|
|
env:
|
|
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
run: |
|
|
echo "Verifying Cosign signature for ${IMAGE}..."
|
|
|
|
# Try with Rekor
|
|
if cosign verify ${IMAGE} \
|
|
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
|
|
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" 2>&1; then
|
|
echo "✅ Cosign signature verified (with Rekor)"
|
|
else
|
|
echo "⚠️ Rekor verification failed, trying offline verification..."
|
|
|
|
# Fallback: verify without Rekor
|
|
if cosign verify ${IMAGE} \
|
|
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
|
|
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
|
|
--insecure-ignore-tlog 2>&1; then
|
|
echo "✅ Cosign signature verified (offline mode)"
|
|
echo "::warning::Verified without Rekor - transparency log unavailable"
|
|
else
|
|
echo "❌ Signature verification failed"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
- name: Verify Docker Hub Image Signature
|
|
if: steps.image-check.outputs.exists == 'true'
|
|
continue-on-error: true
|
|
run: |
|
|
echo "Verifying Docker Hub image signature..."
|
|
cosign verify docker.io/wikid82/charon:${{ steps.tag.outputs.tag }} \
|
|
--certificate-identity-regexp="https://github.com/Wikid82/Charon" \
|
|
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" && \
|
|
echo "✅ Docker Hub signature verified" || \
|
|
echo "⚠️ Docker Hub signature verification failed (image may not exist or not signed)"
|
|
|
|
- name: Verify SLSA Provenance
|
|
env:
|
|
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
echo "Verifying SLSA provenance for ${IMAGE}..."
|
|
|
|
# This will be enabled once provenance generation is added
|
|
echo "⚠️ SLSA provenance verification not yet implemented"
|
|
echo "Will be enabled after Phase 3 workflow updates"
|
|
|
|
- name: Create Verification Report
|
|
if: always()
|
|
run: |
|
|
cat << EOF > verification-report.md
|
|
# Supply Chain Verification Report
|
|
|
|
**Image**: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
**Date**: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
|
**Workflow**: ${{ github.workflow }}
|
|
**Run**: ${{ github.run_id }}
|
|
|
|
## Results
|
|
|
|
- **SBOM Verification**: ${{ needs.verify-sbom.result }}
|
|
- **Cosign Signature**: ${{ job.status }}
|
|
- **SLSA Provenance**: Not yet implemented (Phase 3)
|
|
|
|
## Verification Failure Recovery
|
|
|
|
If verification failed:
|
|
1. Check workflow logs for detailed error messages
|
|
2. Verify signing steps ran successfully in build workflow
|
|
3. Confirm attestations were pushed to registry
|
|
4. Check Rekor status: https://status.sigstore.dev
|
|
5. For Rekor outages, manual verification may be required
|
|
6. Re-run build if signatures/provenance are missing
|
|
EOF
|
|
|
|
cat verification-report.md >> $GITHUB_STEP_SUMMARY
|
|
|
|
verify-release-artifacts:
|
|
name: Verify Release Artifacts
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'release'
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
|
|
- name: Install Verification Tools
|
|
run: |
|
|
# Install Cosign
|
|
curl -sLO https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64
|
|
sudo install cosign-linux-amd64 /usr/local/bin/cosign
|
|
rm cosign-linux-amd64
|
|
|
|
- name: Download Release Assets
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
TAG=${{ github.event.release.tag_name }}
|
|
mkdir -p ./release-assets
|
|
gh release download ${TAG} --dir ./release-assets || {
|
|
echo "⚠️ No release assets found or download failed"
|
|
exit 0
|
|
}
|
|
|
|
- name: Verify Artifact Signatures with Fallback
|
|
continue-on-error: true
|
|
run: |
|
|
if [[ ! -d ./release-assets ]] || [[ -z "$(ls -A ./release-assets 2>/dev/null)" ]]; then
|
|
echo "⚠️ No release assets to verify"
|
|
exit 0
|
|
fi
|
|
|
|
echo "Verifying Cosign signatures for release artifacts..."
|
|
|
|
VERIFIED_COUNT=0
|
|
FAILED_COUNT=0
|
|
|
|
for artifact in ./release-assets/*; do
|
|
# Skip signature and certificate files
|
|
if [[ "$artifact" == *.sig || "$artifact" == *.pem || "$artifact" == *provenance* || "$artifact" == *.txt || "$artifact" == *.md ]]; then
|
|
continue
|
|
fi
|
|
|
|
if [[ -f "$artifact" ]]; then
|
|
echo "Verifying: $(basename $artifact)"
|
|
|
|
# Check if signature files exist
|
|
if [[ ! -f "${artifact}.sig" ]] || [[ ! -f "${artifact}.pem" ]]; then
|
|
echo "⚠️ No signature files found for $(basename $artifact)"
|
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
|
continue
|
|
fi
|
|
|
|
# Try with Rekor
|
|
if cosign verify-blob "$artifact" \
|
|
--signature "${artifact}.sig" \
|
|
--certificate "${artifact}.pem" \
|
|
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
|
|
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" 2>&1; then
|
|
echo "✅ Verified with Rekor"
|
|
VERIFIED_COUNT=$((VERIFIED_COUNT + 1))
|
|
else
|
|
echo "⚠️ Rekor unavailable, trying offline..."
|
|
if cosign verify-blob "$artifact" \
|
|
--signature "${artifact}.sig" \
|
|
--certificate "${artifact}.pem" \
|
|
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
|
|
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
|
|
--insecure-ignore-tlog 2>&1; then
|
|
echo "✅ Verified offline"
|
|
VERIFIED_COUNT=$((VERIFIED_COUNT + 1))
|
|
else
|
|
echo "❌ Verification failed"
|
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "Verification summary: ${VERIFIED_COUNT} verified, ${FAILED_COUNT} failed"
|
|
|
|
if [[ ${FAILED_COUNT} -gt 0 ]]; then
|
|
echo "⚠️ Some artifacts failed verification"
|
|
else
|
|
echo "✅ All artifacts verified successfully"
|
|
fi
|