#!/usr/bin/env bash set -euo pipefail # Scan hello-distr services for known vulnerabilities using osv-scanner. # # Usage: # ./deploy/scripts/distr-vulnerability-scan.sh # Local mode: scan lockfiles + local images # ./deploy/scripts/distr-vulnerability-scan.sh ci # CI mode: scan lockfiles + registry images # # Local mode: scans lockfiles (requirements.txt, package-lock.json) and # container images tagged :local (build with docker build -t ...:local). # Produces vulnerability-scan.json and vulnerability-scan.md. # CI mode: scans lockfiles AND registry container images for a given VERSION. # Produces vulnerability-scan.json and vulnerability-scan.md. # # All 4 services (backend, frontend, proxy, jobs) are always included in the report. # # Exit code: always 0. Vulnerabilities are informational and do not block the pipeline. REPORT_FILE="vulnerability-scan.md" JSON_FILE="vulnerability-scan.json" CONTAINER_REGISTRY="ghcr.io/distr-sh/hello-distr" ALL_SERVICES=("backend" "frontend" "proxy" "jobs") # Services that have scannable lockfiles (service -> lockfile path) declare -A SERVICE_LOCKFILES=( [backend]="backend/requirements.txt" [frontend]="frontend/package-lock.json" ) HAS_VULNS=0 run_osv_scanner() { if command -v osv-scanner &>/dev/null; then osv-scanner "$@" else echo "ERROR: osv-scanner not found. Install via: mise install" >&2 return 1 fi } # Run osv-scanner and classify the exit code. # Args: output_file scan_args... # Returns 0 if clean, 1 if vulnerabilities found. run_scan() { local output_file="$1" shift local exit_code=0 run_osv_scanner "$@" --format json --output "$output_file" || exit_code=$? if [ "$exit_code" -eq 0 ]; then echo " CLEAN: no vulnerabilities found" [ -f "$output_file" ] || echo '{"results":[]}' > "$output_file" return 0 elif [ "$exit_code" -eq 1 ]; then echo " FOUND: vulnerabilities detected (see report)" return 1 else echo " WARN: osv-scanner exited with code ${exit_code}" [ -f "$output_file" ] || echo '{"results":[]}' > "$output_file" return 0 fi } # Scan a lockfile for vulnerabilities. # Produces vulnerability-scan-source-{service}.json. scan_lockfile() { local service="$1" local lockfile="$2" local output_file="vulnerability-scan-source-${service}.json" echo "Scanning ${service} lockfile: ${lockfile}..." if [ ! -f "$lockfile" ]; then echo " SKIP: lockfile not found at ${lockfile}" return 0 fi run_scan "$output_file" scan -L "$lockfile" } # Scan a container image for vulnerabilities. # Produces vulnerability-scan-image-{service}.json. scan_image() { local service="$1" local image="$2" local output_file="vulnerability-scan-image-${service}.json" echo "Scanning ${service} container image: ${image}..." if ! docker image inspect "$image" &>/dev/null; then echo " SKIP: image not found (build with: docker build -t ${image} ./${service})" echo '{"results":[]}' > "$output_file" return 0 fi run_scan "$output_file" scan image "$image" } # Parse a scan filename into source_kind and service. # Sets: _source_kind, _service, _source_label parse_scan_filename() { local f="$1" local fname fname=$(basename "$f" .json) local scan_type="${fname#vulnerability-scan-}" # source-backend or image-frontend _source_kind="${scan_type%%-*}" # source or image _service="${scan_type#*-}" # backend or frontend if [ "$_source_kind" = "source" ]; then _source_label="${SERVICE_LOCKFILES[$_service]:-lockfile}" else _source_label="container image" fi } # jq helper: map CVSS score to severity label with emoji. # Used inline in jq queries to avoid shell awk subprocesses. JQ_SEVERITY_LABEL=' def severity_label: (if . == "" or . == null then 0 else tonumber end) as $s | if $s >= 9.0 then "CRITICAL" elif $s >= 7.0 then "HIGH" elif $s >= 4.0 then "MEDIUM" elif $s > 0 then "LOW" else "NONE" end; def severity_emoji: if . == "CRITICAL" then "🔴" elif . == "HIGH" then "🟠" elif . == "MEDIUM" then "🟡" elif . == "LOW" then "🟢" else "⚪" end; ' # Generate unified markdown report from all vulnerability-scan-*.json files. generate_report() { local mode="$1" local scan_files mapfile -t scan_files < <(find . -maxdepth 1 \( -name 'vulnerability-scan-source-*.json' -o -name 'vulnerability-scan-image-*.json' \) -type f | sort) if [ ${#scan_files[@]} -eq 0 ]; then echo "No scan results found." echo '{"results":[]}' > "$JSON_FILE" echo "# Vulnerability Scan Report" > "$REPORT_FILE" echo "" >> "$REPORT_FILE" echo "No scan results found." >> "$REPORT_FILE" return fi # Merge all scan results into a single JSON object jq -s '{ results: [.[].results[]?] }' "${scan_files[@]}" > "$JSON_FILE" { echo "# Vulnerability Scan Report" echo "" echo "**Date:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "**Mode:** ${mode}" echo "" # Scanned dependencies overview — always show both image and source counts per service # Note: counts reflect packages with findings only (osv-scanner omits clean packages from JSON output) echo "## Scanned Dependencies" echo "" echo "| Service | Image | Source |" echo "|---------|-------|--------|" for service in "${ALL_SERVICES[@]}"; do local img_count src_count local img_file="./vulnerability-scan-image-${service}.json" local src_file="./vulnerability-scan-source-${service}.json" if [ -f "$img_file" ]; then img_count=$(jq '[.results[]?.packages[]?] | length' "$img_file" 2>/dev/null || echo "0") else img_count="–" fi if [ -f "$src_file" ]; then src_count=$(jq '[.results[]?.packages[]?] | length' "$src_file" 2>/dev/null || echo "0") else src_count="–" fi echo "| ${service} | ${img_count} | ${src_count} |" done echo "" # Summary table — counts are deduplicated by vulnerability ID echo "## Summary" echo "" echo "| Service | Source | Critical | High | Medium | Low | None | Total |" echo "|---------|--------|----------|------|--------|-----|------|-------|" for f in "${scan_files[@]}"; do parse_scan_filename "$f" # Count unique vulnerabilities by severity (deduplicated by ID) local critical high medium low none total eval "$(jq -r "$JQ_SEVERITY_LABEL"' [.results[]?.packages[]?.groups[]? | { id: .ids[0], sev: (.max_severity | severity_label) }] | unique_by(.id) | group_by(.sev) | map({ key: .[0].sev, value: length }) | from_entries | "critical=\(.CRITICAL // 0) high=\(.HIGH // 0) medium=\(.MEDIUM // 0) low=\(.LOW // 0) none=\(.NONE // 0)" ' "$f" 2>/dev/null || echo "critical=0 high=0 medium=0 low=0 none=0")" total=$((critical + high + medium + low + none)) echo "| ${_service} | ${_source_label} | ${critical} | ${high} | ${medium} | ${low} | ${none} | ${total} |" done echo "" # Per-scan detail sections for f in "${scan_files[@]}"; do parse_scan_filename "$f" echo "## ${_service} (${_source_label})" echo "" local vuln_count vuln_count=$(jq '[.results[]?.packages[]?.groups[]?] | length' "$f" 2>/dev/null || echo "0") if [ "$vuln_count" -eq 0 ]; then echo "No vulnerabilities found." echo "" continue fi echo "| ID | Package | Version | Severity | Summary |" echo "|----|---------|---------|----------|---------|" # List each vulnerability group with its details, severity resolved in jq jq -r "$JQ_SEVERITY_LABEL"' .results[]?.packages[]? | .package as $pkg | .groups[]? | .max_severity as $sev | ($sev | severity_label) as $label | ($label | severity_emoji) as $emoji | .ids[0] as $id | "\($id)\t\($pkg.name)\t\($pkg.version)\t\($emoji) \($label) \($sev)\t" ' "$f" 2>/dev/null | sort -u | while IFS=$'\t' read -r id pkg_name pkg_version severity_display _; do [ -z "$id" ] && continue echo "| ${id} | ${pkg_name} | ${pkg_version} | ${severity_display} | |" done echo "" done echo "---" echo "" if [ "$HAS_VULNS" -eq 1 ]; then echo "**Result: VULNERABILITIES FOUND**" else echo "**Result: CLEAN**" fi echo "" echo "---" echo "" local osv_version osv_version=$(run_osv_scanner --version 2>/dev/null || echo "unknown") echo "*Generated with [osv-scanner](https://github.com/google/osv-scanner) ${osv_version}*" } > "$REPORT_FILE" } # --- Main --- MODE="${1:-local}" VERSION="${2:-local}" # Clean up previous scan results rm -f vulnerability-scan-source-*.json vulnerability-scan-image-*.json # Scan lockfiles for services that have them for service in "${!SERVICE_LOCKFILES[@]}"; do scan_lockfile "$service" "${SERVICE_LOCKFILES[$service]}" || HAS_VULNS=1 done # Scan container images for all services for service in "${ALL_SERVICES[@]}"; do scan_image "$service" "${CONTAINER_REGISTRY}/${service}:${VERSION}" || HAS_VULNS=1 done generate_report "$MODE" echo "" if [ "$HAS_VULNS" -eq 1 ]; then echo "Vulnerabilities found. See ${REPORT_FILE} for details." else echo "No vulnerabilities found. Report written to ${REPORT_FILE}" fi