#!/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 [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