#!/usr/bin/env bash ######################################################################################### #### sbom-scanner.sh — Generate SBOMs and scan for vulnerabilities with Syft + Grype #### #### Wrapper for container images, directories, and archives with CI-friendly output #### #### Requires: bash 4+, optionally jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./sbom-scanner.sh --scan nginx:latest #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" TARGET="" FORMAT="${SBOM_FORMAT:-table}" OUTPUT_FILE="${SBOM_OUTPUT_FILE:-}" MIN_SEVERITY="${SBOM_MIN_SEVERITY:-}" FAIL_ON="${SBOM_FAIL_ON:-}" IGNORE_FILE="${SBOM_IGNORE_FILE:-}" SYFT_BIN="${SYFT_PATH:-syft}" GRYPE_BIN="${GRYPE_PATH:-grype}" INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" DB_UPDATE="${DB_UPDATE:-false}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" CRIT_COUNT=0 HIGH_COUNT=0 MED_COUNT=0 LOW_COUNT=0 NEG_COUNT=0 TOTAL_VULNS=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then # shellcheck disable=SC2034 RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' # shellcheck disable=SC2034 CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else # shellcheck disable=SC2034 RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*" exit 1 } elapsed() { local end_time end_time=$(date +%s) echo "$(( end_time - START_TIME ))s" } # ── Dependency checks ──────────────────────────────────────────────── check_tool() { local tool="$1" local bin_var="$2" if ! command -v "$bin_var" &>/dev/null; then err "${tool} is not installed (looked for '${bin_var}')" err "Install with: ./$(basename "$0") --install" exit 1 fi verbose "${tool} found at $(command -v "$bin_var")" } # ── Detect target type ─────────────────────────────────────────────── detect_target_type() { local t="$1" if [[ "$t" == dir:* ]]; then echo "directory" elif [[ -f "$t" ]]; then echo "archive" else echo "container image" fi } # ── Severity level to number ───────────────────────────────────────── severity_to_num() { case "${1,,}" in critical) echo 5 ;; high) echo 4 ;; medium) echo 3 ;; low) echo 2 ;; negligible) echo 1 ;; *) echo 0 ;; esac } severity_color() { case "${1,,}" in critical) echo "$RED" ;; high) echo "$RED" ;; medium) echo "$YELLOW" ;; low) echo "$DIM" ;; negligible) echo "$DIM" ;; *) echo "$RESET" ;; esac } # ══════════════════════════════════════════════════════════════════════ # INSTALL MODE # ══════════════════════════════════════════════════════════════════════ do_install() { log "Installing Syft and Grype to ${INSTALL_DIR}..." if ! command -v curl &>/dev/null; then die "curl is required for installation" fi # Install Syft if command -v "$SYFT_BIN" &>/dev/null; then log "Syft already installed: $(command -v "$SYFT_BIN")" else log "Installing Syft..." verbose "curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b ${INSTALL_DIR}" if curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b "$INSTALL_DIR" 2>/dev/null; then echo -e " ${GREEN}✓${RESET} Syft installed to ${INSTALL_DIR}/syft" else err "Failed to install Syft" fi fi # Install Grype if command -v "$GRYPE_BIN" &>/dev/null; then log "Grype already installed: $(command -v "$GRYPE_BIN")" else log "Installing Grype..." verbose "curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b ${INSTALL_DIR}" if curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "$INSTALL_DIR" 2>/dev/null; then echo -e " ${GREEN}✓${RESET} Grype installed to ${INSTALL_DIR}/grype" else err "Failed to install Grype" fi fi log "Installation complete" } # ══════════════════════════════════════════════════════════════════════ # SBOM MODE # ══════════════════════════════════════════════════════════════════════ do_sbom() { check_tool "Syft" "$SYFT_BIN" local target_type target_type=$(detect_target_type "$TARGET") log "Generating SBOM with Syft..." log "Target: ${TARGET} (${target_type})" local syft_args=("$TARGET") case "$FORMAT" in table) syft_args+=(-o syft-table) ;; json) syft_args+=(-o syft-json) ;; cyclonedx-json) syft_args+=(-o cyclonedx-json) ;; *) syft_args+=(-o syft-table) ;; esac local output if output=$("$SYFT_BIN" "${syft_args[@]}" 2>/dev/null); then if [[ -n "$OUTPUT_FILE" ]]; then echo "$output" > "$OUTPUT_FILE" log "Output written to ${OUTPUT_FILE}" else echo "" echo "$output" fi else die "Syft failed to generate SBOM for ${TARGET}" fi # Count packages for summary if [[ "$FORMAT" == "table" ]]; then local pkg_count pkg_count=$(echo "$output" | tail -n +2 | grep -c "" || true) echo "" log "Total packages: ${pkg_count}" fi log "Generated in $(elapsed)" } # ══════════════════════════════════════════════════════════════════════ # SCAN MODE # ══════════════════════════════════════════════════════════════════════ do_scan() { check_tool "Grype" "$GRYPE_BIN" local target_type target_type=$(detect_target_type "$TARGET") log "Scanning for vulnerabilities with Grype..." log "Target: ${TARGET} (${target_type})" if [[ "$DB_UPDATE" == "true" ]]; then log "Updating vulnerability database..." "$GRYPE_BIN" db update 2>/dev/null || warn "Database update failed, using cached DB" fi local grype_args=("$TARGET" -o json) [[ -n "$IGNORE_FILE" ]] && grype_args+=(--config "$IGNORE_FILE") local raw_json if ! raw_json=$("$GRYPE_BIN" "${grype_args[@]}" 2>/dev/null); then die "Grype failed to scan ${TARGET}" fi # Parse vulnerability counts if command -v jq &>/dev/null; then CRIT_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Critical")] | length') HIGH_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "High")] | length') MED_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Medium")] | length') LOW_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Low")] | length') NEG_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Negligible")] | length') TOTAL_VULNS=$(echo "$raw_json" | jq '[.matches[]?] | length') else TOTAL_VULNS=$(echo "$raw_json" | grep -c '"vulnerability"' || true) fi local min_sev_num=0 [[ -n "$MIN_SEVERITY" ]] && min_sev_num=$(severity_to_num "$MIN_SEVERITY") case "$FORMAT" in json) if [[ -n "$OUTPUT_FILE" ]]; then echo "$raw_json" > "$OUTPUT_FILE" log "JSON output written to ${OUTPUT_FILE}" else echo "$raw_json" fi ;; prometheus) print_prometheus_metrics ;; *) print_scan_table "$raw_json" "$min_sev_num" ;; esac print_scan_summary # Check fail threshold check_fail_threshold } print_scan_table() { local raw_json="$1" local min_sev_num="$2" if ! command -v jq &>/dev/null; then warn "jq not installed — cannot format table output, showing raw" echo "$raw_json" return fi echo "" printf " ${BOLD}%-16s %-10s %-20s %-14s %s${RESET}\n" "VULNERABILITY" "SEVERITY" "PACKAGE" "VERSION" "FIXED-IN" printf " %s\n" "$(printf '%.0s─' {1..75})" echo "$raw_json" | jq -c '.matches[]?' | while IFS= read -r match; do local vuln_id severity pkg_name pkg_ver fixed_in vuln_id=$(echo "$match" | jq -r '.vulnerability.id') severity=$(echo "$match" | jq -r '.vulnerability.severity') pkg_name=$(echo "$match" | jq -r '.artifact.name') pkg_ver=$(echo "$match" | jq -r '.artifact.version') fixed_in=$(echo "$match" | jq -r '.vulnerability.fix.versions[0] // "(none)"') local sev_num sev_num=$(severity_to_num "$severity") [[ "$sev_num" -lt "$min_sev_num" ]] && continue local color color=$(severity_color "$severity") printf " %-16s ${color}%-10s${RESET} %-20s %-14s %s\n" \ "$vuln_id" "$severity" "${pkg_name:0:20}" "${pkg_ver:0:14}" "$fixed_in" done if [[ -n "$OUTPUT_FILE" ]]; then echo "$raw_json" > "$OUTPUT_FILE" log "Full JSON written to ${OUTPUT_FILE}" fi } print_scan_summary() { echo "" echo -e " ${BOLD}Summary${RESET}" echo -e " Total vulnerabilities: ${TOTAL_VULNS}" [[ "$CRIT_COUNT" -gt 0 ]] && echo -e " ${RED}Critical: ${CRIT_COUNT}${RESET}" || echo " Critical: 0" [[ "$HIGH_COUNT" -gt 0 ]] && echo -e " ${RED}High: ${HIGH_COUNT}${RESET}" || echo " High: 0" [[ "$MED_COUNT" -gt 0 ]] && echo -e " ${YELLOW}Medium: ${MED_COUNT}${RESET}" || echo " Medium: 0" echo " Low: ${LOW_COUNT}" echo " Negligible: ${NEG_COUNT}" log "Scanned in $(elapsed)" } print_prometheus_metrics() { local ts ts=$(date +%s) cat </dev/null || warn "Database update failed, using cached DB" fi # Generate SBOM log "Phase 1: Generating SBOM with Syft..." local sbom_json if ! sbom_json=$("$SYFT_BIN" "$TARGET" -o syft-json 2>/dev/null); then die "Syft failed to generate SBOM" fi local pkg_count if command -v jq &>/dev/null; then pkg_count=$(echo "$sbom_json" | jq '[.artifacts[]?] | length') else pkg_count="?" fi log "SBOM complete: ${pkg_count} packages found" # Scan with Grype using the SBOM as input log "Phase 2: Scanning for vulnerabilities with Grype..." local grype_args=(--sbom -) grype_args+=(-o json) [[ -n "$IGNORE_FILE" ]] && grype_args+=(--config "$IGNORE_FILE") local raw_json if ! raw_json=$(echo "$sbom_json" | "$GRYPE_BIN" "${grype_args[@]}" 2>/dev/null); then die "Grype scan failed" fi # Parse counts if command -v jq &>/dev/null; then CRIT_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Critical")] | length') HIGH_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "High")] | length') MED_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Medium")] | length') LOW_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Low")] | length') NEG_COUNT=$(echo "$raw_json" | jq '[.matches[]? | select(.vulnerability.severity == "Negligible")] | length') TOTAL_VULNS=$(echo "$raw_json" | jq '[.matches[]?] | length') fi local min_sev_num=0 [[ -n "$MIN_SEVERITY" ]] && min_sev_num=$(severity_to_num "$MIN_SEVERITY") case "$FORMAT" in json) if [[ -n "$OUTPUT_FILE" ]]; then echo "$raw_json" > "$OUTPUT_FILE" log "JSON output written to ${OUTPUT_FILE}" else echo "$raw_json" fi ;; prometheus) print_prometheus_metrics ;; *) print_scan_table "$raw_json" "$min_sev_num" ;; esac print_scan_summary check_fail_threshold } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat <