828 lines
32 KiB
YAML
828 lines
32 KiB
YAML
name: Supply Chain Verification
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
schedule:
|
|
- cron: '0 0 * * 1' # Mondays 00:00 UTC
|
|
workflow_run:
|
|
workflows:
|
|
- Docker Build, Publish & Test
|
|
types:
|
|
- completed
|
|
release:
|
|
types:
|
|
- published
|
|
- prereleased
|
|
|
|
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
|
|
outputs:
|
|
image_exists: ${{ steps.image-check.outputs.exists }}
|
|
# 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.event != 'pull_request' &&
|
|
(github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')))
|
|
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: 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
|
|
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
|
BRANCH="${{ github.ref_name }}"
|
|
if [[ "${BRANCH}" == "main" ]]; then
|
|
TAG="latest"
|
|
elif [[ "${BRANCH}" == "development" ]]; then
|
|
TAG="dev"
|
|
elif [[ "${BRANCH}" == "nightly" ]]; then
|
|
TAG="nightly"
|
|
else
|
|
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
|
|
|
|
# Generate SBOM using official Anchore action (auto-updated by Renovate)
|
|
- name: Generate and Verify SBOM
|
|
if: steps.image-check.outputs.exists == 'true'
|
|
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
|
|
with:
|
|
image: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
|
format: cyclonedx-json
|
|
output-file: sbom-verify.cyclonedx.json
|
|
|
|
- name: Verify SBOM Completeness
|
|
if: steps.image-check.outputs.exists == 'true'
|
|
run: |
|
|
echo "Verifying SBOM completeness..."
|
|
echo ""
|
|
|
|
# Count components
|
|
COMPONENT_COUNT=$(jq '.components | length' sbom-verify.cyclonedx.json 2>/dev/null || echo "0")
|
|
|
|
echo "SBOM components: ${COMPONENT_COUNT}"
|
|
|
|
if [[ ${COMPONENT_COUNT} -eq 0 ]]; then
|
|
echo "⚠️ SBOM contains no components - may indicate an issue"
|
|
else
|
|
echo "✅ SBOM contains ${COMPONENT_COUNT} components"
|
|
fi
|
|
|
|
- name: Upload SBOM Artifact
|
|
if: steps.image-check.outputs.exists == 'true' && always()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: sbom-${{ steps.tag.outputs.tag }}
|
|
path: sbom-verify.cyclonedx.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-verify.cyclonedx.json ]]; then
|
|
echo "❌ SBOM file does not exist"
|
|
echo "valid=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Check file is non-empty
|
|
if [[ ! -s sbom-verify.cyclonedx.json ]]; then
|
|
echo "❌ SBOM file is empty"
|
|
echo "valid=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Validate JSON structure
|
|
if ! jq empty sbom-verify.cyclonedx.json 2>/dev/null; then
|
|
echo "❌ SBOM file contains invalid JSON"
|
|
echo "SBOM content:"
|
|
cat sbom-verify.cyclonedx.json
|
|
echo "valid=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Validate CycloneDX structure
|
|
BOMFORMAT=$(jq -r '.bomFormat // "missing"' sbom-verify.cyclonedx.json)
|
|
SPECVERSION=$(jq -r '.specVersion // "missing"' sbom-verify.cyclonedx.json)
|
|
COMPONENTS=$(jq '.components // [] | length' sbom-verify.cyclonedx.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
|
|
|
|
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
|
|
|
|
# Scan for vulnerabilities using official Anchore action (auto-updated by Renovate)
|
|
- name: Scan for Vulnerabilities
|
|
if: steps.validate-sbom.outputs.valid == 'true'
|
|
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
|
|
id: scan
|
|
with:
|
|
sbom: sbom-verify.cyclonedx.json
|
|
fail-build: false
|
|
output-format: json
|
|
|
|
- name: Process Vulnerability Results
|
|
if: steps.validate-sbom.outputs.valid == 'true'
|
|
run: |
|
|
echo "Processing vulnerability results..."
|
|
|
|
# The scan-action outputs results.json and results.sarif
|
|
# Rename for consistency
|
|
if [[ -f results.json ]]; then
|
|
mv results.json vuln-scan.json
|
|
fi
|
|
if [[ -f results.sarif ]]; then
|
|
mv results.sarif vuln-scan.sarif
|
|
fi
|
|
|
|
# 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}"
|
|
echo "HIGH_VULNS=${HIGH}"
|
|
echo "MEDIUM_VULNS=${MEDIUM}"
|
|
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
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"
|
|
echo ""
|
|
|
|
if [[ "${{ steps.image-check.outputs.exists }}" != "true" ]]; then
|
|
echo "**Reason**: Docker image not available yet"
|
|
echo ""
|
|
echo "This is expected for PR workflows. The image will be scanned"
|
|
echo "after it's built by the docker-build workflow."
|
|
elif [[ "${{ steps.validate-sbom.outputs.valid }}" != "true" ]]; then
|
|
echo "**Reason**: SBOM validation failed"
|
|
echo ""
|
|
echo "Check the 'Validate SBOM File' step for details."
|
|
fi
|
|
|
|
echo ""
|
|
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.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
|
|
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: Find Existing PR Comment
|
|
id: find-comment
|
|
if: steps.pr-number.outputs.result != ''
|
|
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
|
with:
|
|
issue-number: ${{ steps.pr-number.outputs.result }}
|
|
comment-author: 'github-actions[bot]'
|
|
body-includes: '<!-- supply-chain-security-comment -->'
|
|
|
|
- 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-id: ${{ steps.find-comment.outputs.comment-id }}
|
|
|
|
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: needs.verify-sbom.outputs.image_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
|