Files
Charon/.github/skills/security-verify-sbom-scripts/run.sh
GitHub Actions 8bcfe28709 docs: comprehensive supply chain security QA audit report
Complete security audit covering:
- CodeQL analysis (0 Critical/High issues)
- Trivy vulnerability scanning (clean)
- Shellcheck linting (2 issues fixed)
- Supply chain skill testing
- GitHub Actions workflow validation
- Regression testing

All critical checks PASSED. Ready for deployment.
2026-01-10 03:33:38 +00:00

317 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# Security Verify SBOM - Execution Script
#
# This script generates an SBOM for a Docker image or local file,
# compares it with a baseline (if provided), and scans for vulnerabilities.
set -euo pipefail
# Source helper scripts
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILLS_SCRIPTS_DIR="$(cd "${SCRIPT_DIR}/../scripts" && pwd)"
# shellcheck source=../scripts/_logging_helpers.sh
source "${SKILLS_SCRIPTS_DIR}/_logging_helpers.sh"
# shellcheck source=../scripts/_error_handling_helpers.sh
source "${SKILLS_SCRIPTS_DIR}/_error_handling_helpers.sh"
# shellcheck source=../scripts/_environment_helpers.sh
source "${SKILLS_SCRIPTS_DIR}/_environment_helpers.sh"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
# Set defaults
set_default_env "SBOM_FORMAT" "spdx-json"
set_default_env "VULN_SCAN_ENABLED" "true"
# Parse arguments
TARGET="${1:-}"
BASELINE="${2:-}"
if [[ -z "${TARGET}" ]]; then
log_error "Usage: security-verify-sbom <target> [baseline]"
log_error " target: Docker image tag or local image name (required)"
log_error " baseline: Path to baseline SBOM for comparison (optional)"
log_error ""
log_error "Examples:"
log_error " security-verify-sbom charon:local"
log_error " security-verify-sbom ghcr.io/user/charon:latest"
log_error " security-verify-sbom charon:test sbom-baseline.json"
exit 2
fi
# Validate target format (basic validation)
if [[ ! "${TARGET}" =~ ^[a-zA-Z0-9:/@._-]+$ ]]; then
log_error "Invalid target format: ${TARGET}"
log_error "Target must match pattern: [a-zA-Z0-9:/@._-]+"
exit 2
fi
# Check required tools
log_step "ENVIRONMENT" "Validating prerequisites"
if ! command -v syft >/dev/null 2>&1; then
log_error "syft is not installed"
log_error "Install from: https://github.com/anchore/syft"
log_error "Quick install: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin"
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
log_error "jq is not installed"
log_error "Install from: https://stedolan.github.io/jq/download/"
exit 2
fi
if [[ "${VULN_SCAN_ENABLED}" == "true" ]] && ! command -v grype >/dev/null 2>&1; then
log_error "grype is not installed (required for vulnerability scanning)"
log_error "Install from: https://github.com/anchore/grype"
log_error "Quick install: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin"
log_error ""
log_error "Alternatively, disable vulnerability scanning with: VULN_SCAN_ENABLED=false"
exit 2
fi
cd "${PROJECT_ROOT}"
# Generate SBOM
log_step "SBOM" "Generating SBOM for ${TARGET}"
log_info "Format: ${SBOM_FORMAT}"
SBOM_OUTPUT="sbom-generated.json"
if ! syft "${TARGET}" -o "${SBOM_FORMAT}" > "${SBOM_OUTPUT}" 2>&1; then
log_error "Failed to generate SBOM for ${TARGET}"
log_error "Ensure the image exists locally or can be pulled from a registry"
exit 1
fi
# Parse and validate SBOM
if [[ ! -f "${SBOM_OUTPUT}" ]]; then
log_error "SBOM file not generated: ${SBOM_OUTPUT}"
exit 1
fi
# Validate SBOM schema (SPDX format)
log_info "Validating SBOM schema..."
if ! jq -e '.spdxVersion' "${SBOM_OUTPUT}" >/dev/null 2>&1; then
log_error "Invalid SBOM: missing spdxVersion field"
exit 1
fi
if ! jq -e '.packages' "${SBOM_OUTPUT}" >/dev/null 2>&1; then
log_error "Invalid SBOM: missing packages array"
exit 1
fi
if ! jq -e '.name' "${SBOM_OUTPUT}" >/dev/null 2>&1; then
log_error "Invalid SBOM: missing name field"
exit 1
fi
if ! jq -e '.documentNamespace' "${SBOM_OUTPUT}" >/dev/null 2>&1; then
log_error "Invalid SBOM: missing documentNamespace field"
exit 1
fi
SPDX_VERSION=$(jq -r '.spdxVersion' "${SBOM_OUTPUT}")
log_success "SBOM schema valid (${SPDX_VERSION})"
PACKAGE_COUNT=$(jq '.packages | length' "${SBOM_OUTPUT}" 2>/dev/null || echo "0")
if [[ "${PACKAGE_COUNT}" -eq 0 ]]; then
log_warning "SBOM contains no packages - this may indicate an error"
log_warning "Target: ${TARGET}"
else
log_success "Generated SBOM contains ${PACKAGE_COUNT} packages"
fi
# Baseline comparison (if provided)
if [[ -n "${BASELINE}" ]]; then
log_step "BASELINE" "Comparing with baseline SBOM"
if [[ ! -f "${BASELINE}" ]]; then
log_error "Baseline SBOM file not found: ${BASELINE}"
exit 2
fi
BASELINE_COUNT=$(jq '.packages | length' "${BASELINE}" 2>/dev/null || echo "0")
if [[ "${BASELINE_COUNT}" -eq 0 ]]; then
log_warning "Baseline SBOM appears empty or invalid"
else
log_info "Baseline: ${BASELINE_COUNT} packages, Current: ${PACKAGE_COUNT} packages"
# Calculate delta and variance using awk for float arithmetic
DELTA=$((PACKAGE_COUNT - BASELINE_COUNT))
if [[ "${BASELINE_COUNT}" -gt 0 ]]; then
# Use awk to prevent integer overflow and get accurate percentage
VARIANCE_PCT=$(awk -v delta="${DELTA}" -v baseline="${BASELINE_COUNT}" 'BEGIN {printf "%.2f", (delta / baseline) * 100}')
VARIANCE_ABS=$(awk -v var="${VARIANCE_PCT}" 'BEGIN {print (var < 0 ? -var : var)}')
else
VARIANCE_PCT="0.00"
VARIANCE_ABS="0.00"
fi
if [[ "${DELTA}" -gt 0 ]]; then
log_info "Delta: +${DELTA} packages (${VARIANCE_PCT}% increase)"
elif [[ "${DELTA}" -lt 0 ]]; then
log_info "Delta: ${DELTA} packages (${VARIANCE_PCT}% decrease)"
else
log_info "Delta: 0 packages (no change)"
fi
# Extract package name@version tuples for semantic comparison
jq -r '.packages[] | "\(.name)@\(.versionInfo // .version // "unknown")"' "${BASELINE}" 2>/dev/null | sort > baseline-packages.txt || true
jq -r '.packages[] | "\(.name)@\(.versionInfo // .version // "unknown")"' "${SBOM_OUTPUT}" 2>/dev/null | sort > current-packages.txt || true
# Extract just names for package add/remove detection
jq -r '.packages[].name' "${BASELINE}" 2>/dev/null | sort > baseline-names.txt || true
jq -r '.packages[].name' "${SBOM_OUTPUT}" 2>/dev/null | sort > current-names.txt || true
# Find added packages
ADDED=$(comm -13 baseline-names.txt current-names.txt 2>/dev/null || echo "")
if [[ -n "${ADDED}" ]]; then
log_info "Added packages:"
echo "${ADDED}" | head -n 10 | while IFS= read -r pkg; do
VERSION=$(jq -r ".packages[] | select(.name == \"${pkg}\") | .versionInfo // .version // \"unknown\"" "${SBOM_OUTPUT}" 2>/dev/null || echo "unknown")
log_info " + ${pkg}@${VERSION}"
done
ADDED_COUNT=$(echo "${ADDED}" | wc -l)
if [[ "${ADDED_COUNT}" -gt 10 ]]; then
log_info " ... and $((ADDED_COUNT - 10)) more"
fi
else
log_info "Added packages: (none)"
fi
# Find removed packages
REMOVED=$(comm -23 baseline-names.txt current-names.txt 2>/dev/null || echo "")
if [[ -n "${REMOVED}" ]]; then
log_info "Removed packages:"
echo "${REMOVED}" | head -n 10 | while IFS= read -r pkg; do
VERSION=$(jq -r ".packages[] | select(.name == \"${pkg}\") | .versionInfo // .version // \"unknown\"" "${BASELINE}" 2>/dev/null || echo "unknown")
log_info " - ${pkg}@${VERSION}"
done
REMOVED_COUNT=$(echo "${REMOVED}" | wc -l)
if [[ "${REMOVED_COUNT}" -gt 10 ]]; then
log_info " ... and $((REMOVED_COUNT - 10)) more"
fi
else
log_info "Removed packages: (none)"
fi
# Detect version changes in existing packages
log_info "Version changes:"
CHANGED_COUNT=0
comm -12 baseline-names.txt current-names.txt 2>/dev/null | while IFS= read -r pkg; do
BASELINE_VER=$(jq -r ".packages[] | select(.name == \"${pkg}\") | .versionInfo // .version // \"unknown\"" "${BASELINE}" 2>/dev/null || echo "unknown")
CURRENT_VER=$(jq -r ".packages[] | select(.name == \"${pkg}\") | .versionInfo // .version // \"unknown\"" "${SBOM_OUTPUT}" 2>/dev/null || echo "unknown")
if [[ "${BASELINE_VER}" != "${CURRENT_VER}" ]]; then
log_info " ~ ${pkg}: ${BASELINE_VER}${CURRENT_VER}"
CHANGED_COUNT=$((CHANGED_COUNT + 1))
if [[ "${CHANGED_COUNT}" -ge 10 ]]; then
log_info " ... (showing first 10 changes)"
break
fi
fi
done
if [[ "${CHANGED_COUNT}" -eq 0 ]]; then
log_info " (none)"
fi
# Warn if variance exceeds threshold (using awk for float comparison)
EXCEEDS_THRESHOLD=$(awk -v abs="${VARIANCE_ABS}" 'BEGIN {print (abs > 5.0 ? 1 : 0)}')
if [[ "${EXCEEDS_THRESHOLD}" -eq 1 ]]; then
log_warning "Package variance (${VARIANCE_ABS}%) exceeds 5% threshold"
log_warning "Consider manual review of package changes"
fi
# Cleanup temporary files
rm -f baseline-packages.txt current-packages.txt baseline-names.txt current-names.txt
fi
fi
# Vulnerability scanning (if enabled)
HAS_CRITICAL=false
if [[ "${VULN_SCAN_ENABLED}" == "true" ]]; then
log_step "VULN" "Scanning for vulnerabilities"
VULN_OUTPUT="vuln-results.json"
# Run Grype on the SBOM
if grype "sbom:${SBOM_OUTPUT}" -o json > "${VULN_OUTPUT}" 2>&1; then
log_debug "Vulnerability scan completed successfully"
else
GRYPE_EXIT=$?
if [[ ${GRYPE_EXIT} -eq 1 ]]; then
log_debug "Grype found vulnerabilities (expected)"
else
log_warning "Grype scan encountered an error (exit code: ${GRYPE_EXIT})"
fi
fi
# Parse vulnerability counts by severity
if [[ -f "${VULN_OUTPUT}" ]]; then
CRITICAL_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "${VULN_OUTPUT}" 2>/dev/null || echo "0")
HIGH_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "${VULN_OUTPUT}" 2>/dev/null || echo "0")
MEDIUM_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' "${VULN_OUTPUT}" 2>/dev/null || echo "0")
LOW_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Low")] | length' "${VULN_OUTPUT}" 2>/dev/null || echo "0")
log_info "Found: ${CRITICAL_COUNT} Critical, ${HIGH_COUNT} High, ${MEDIUM_COUNT} Medium, ${LOW_COUNT} Low"
# Display critical vulnerabilities
if [[ "${CRITICAL_COUNT}" -gt 0 ]]; then
HAS_CRITICAL=true
log_error "Critical vulnerabilities detected:"
jq -r '.matches[] | select(.vulnerability.severity == "Critical") | " - \(.vulnerability.id) in \(.artifact.name)@\(.artifact.version) (CVSS: \(.vulnerability.cvss[0].metrics.baseScore // "N/A"))"' "${VULN_OUTPUT}" 2>/dev/null | head -n 10
if [[ "${CRITICAL_COUNT}" -gt 10 ]]; then
log_error " ... and $((CRITICAL_COUNT - 10)) more critical vulnerabilities"
fi
fi
# Display high vulnerabilities
if [[ "${HIGH_COUNT}" -gt 0 ]]; then
log_warning "High severity vulnerabilities:"
jq -r '.matches[] | select(.vulnerability.severity == "High") | " - \(.vulnerability.id) in \(.artifact.name)@\(.artifact.version) (CVSS: \(.vulnerability.cvss[0].metrics.baseScore // "N/A"))"' "${VULN_OUTPUT}" 2>/dev/null | head -n 5
if [[ "${HIGH_COUNT}" -gt 5 ]]; then
log_warning " ... and $((HIGH_COUNT - 5)) more high vulnerabilities"
fi
fi
# Display table format for summary
log_info "Running table format scan for summary..."
grype "sbom:${SBOM_OUTPUT}" -o table 2>&1 | tail -n 20 || true
else
log_warning "Vulnerability scan results not found"
fi
else
log_info "Vulnerability scanning disabled (air-gapped mode)"
fi
# Final summary
echo ""
log_step "SUMMARY" "SBOM Verification Complete"
log_info "Target: ${TARGET}"
log_info "Packages: ${PACKAGE_COUNT}"
if [[ -n "${BASELINE}" ]]; then
log_info "Baseline comparison: ${VARIANCE_PCT}% variance"
fi
if [[ "${VULN_SCAN_ENABLED}" == "true" ]]; then
log_info "Vulnerabilities: ${CRITICAL_COUNT} Critical, ${HIGH_COUNT} High, ${MEDIUM_COUNT} Medium, ${LOW_COUNT} Low"
fi
log_info "SBOM file: ${SBOM_OUTPUT}"
# Exit with appropriate code
if [[ "${HAS_CRITICAL}" == "true" ]]; then
log_error "CRITICAL vulnerabilities found - review required"
exit 1
fi
if [[ "${HIGH_COUNT:-0}" -gt 0 ]]; then
log_warning "High severity vulnerabilities found - review recommended"
fi
log_success "Verification complete"
exit 0