#!/bin/bash # backfill-exploitability.sh # Adds exploitability scoring to existing advisories in feed.json that don't have it yet. # Historical maintenance utility: normal advisory generation should use # poll-nvd workflow (init/reset when rebuilding) or populate-local-feed.sh. # # Usage: ./scripts/backfill-exploitability.sh [--dry-run] [--feed PATH] # --dry-run Show what would be updated without making changes # --feed PATH Use specified feed file (default: advisories/feed.json) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # shellcheck source=./feed-utils.sh source "$SCRIPT_DIR/feed-utils.sh" # Configuration init_feed_paths "$PROJECT_ROOT" ANALYZER="$PROJECT_ROOT/utils/analyze_exploitability.py" SIGNING_PRIVATE_KEY="${CLAWSEC_FEED_SIGNING_PRIVATE_KEY_PATH:-${CLAWSEC_SIGNING_PRIVATE_KEY_PATH:-$PROJECT_ROOT/clawsec-signing-private.pem}}" SIGNING_PUBLIC_KEY="${CLAWSEC_FEED_SIGNING_PUBLIC_KEY_PATH:-${CLAWSEC_SIGNING_PUBLIC_KEY_PATH:-$PROJECT_ROOT/clawsec-signing-public.pem}}" SIGNING_PASSPHRASE="${CLAWSEC_FEED_SIGNING_PRIVATE_KEY_PASSPHRASE:-${CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE:-}}" sign_and_verify_feed_signature() { local feed_file="$1" local signature_file="$2" local tmp_dir local tmp_signature local signature_bin local passin_file tmp_dir=$(mktemp -d) tmp_signature="${signature_file}.tmp.$$" signature_bin="$tmp_dir/signature.bin" passin_file="$tmp_dir/passin.txt" if [ -n "$SIGNING_PASSPHRASE" ]; then printf '%s' "$SIGNING_PASSPHRASE" > "$passin_file" chmod 600 "$passin_file" if ! openssl pkeyutl -sign -rawin -inkey "$SIGNING_PRIVATE_KEY" -passin "file:$passin_file" -in "$feed_file" \ | openssl base64 -A > "$tmp_signature"; then rm -rf "$tmp_dir" rm -f "$tmp_signature" echo "Error: Failed to sign $feed_file" >&2 return 1 fi elif ! openssl pkeyutl -sign -rawin -inkey "$SIGNING_PRIVATE_KEY" -in "$feed_file" \ | openssl base64 -A > "$tmp_signature"; then rm -rf "$tmp_dir" rm -f "$tmp_signature" echo "Error: Failed to sign $feed_file" >&2 return 1 fi if ! openssl base64 -d -A -in "$tmp_signature" -out "$signature_bin"; then rm -rf "$tmp_dir" rm -f "$tmp_signature" echo "Error: Failed to decode generated signature for $feed_file" >&2 return 1 fi if ! openssl pkeyutl -verify -rawin -pubin -inkey "$SIGNING_PUBLIC_KEY" -sigfile "$signature_bin" -in "$feed_file" >/dev/null; then rm -rf "$tmp_dir" rm -f "$tmp_signature" echo "Error: Signature verification failed after signing $feed_file" >&2 return 1 fi mv "$tmp_signature" "$signature_file" rm -rf "$tmp_dir" echo "✓ Re-signed and verified: $signature_file" } # Parse args DRY_RUN=false REQUIRE_SIGNING=false while [[ $# -gt 0 ]]; do case $1 in --dry-run) DRY_RUN=true shift ;; --feed) FEED_PATH="$2" shift 2 ;; *) echo "Unknown option: $1" echo "Usage: $0 [--dry-run] [--feed PATH]" exit 1 ;; esac done echo "=== ClawSec Exploitability Backfill ===" echo "Feed path: $FEED_PATH" echo "Dry run: $DRY_RUN" echo "" # Verify prerequisites if [ ! -f "$FEED_PATH" ]; then echo "Error: Feed file not found: $FEED_PATH" exit 1 fi if [ ! -f "$ANALYZER" ]; then echo "Error: Analyzer script not found: $ANALYZER" exit 1 fi # Check Python availability if ! command -v python3 &> /dev/null; then echo "Error: python3 is required but not found in PATH" exit 1 fi # Verify analyzer works if ! python3 "$ANALYZER" --help &> /dev/null; then echo "Error: Analyzer script failed to run. Check Python environment." exit 1 fi # Determine whether detached signatures must be regenerated. # Runtime agents that only have public keys should run in dry-run mode. if [ "$DRY_RUN" = "false" ]; then if [ -f "${FEED_PATH}.sig" ]; then REQUIRE_SIGNING=true fi fi if [ "$REQUIRE_SIGNING" = "true" ]; then if ! command -v openssl &> /dev/null; then echo "Error: openssl is required for detached signature signing/verification" exit 1 fi if [ ! -f "$SIGNING_PRIVATE_KEY" ]; then echo "Error: Signing private key not found: $SIGNING_PRIVATE_KEY" echo "This backfill updates signed feed artifacts. Use --dry-run in public-key-only environments." exit 1 fi if [ ! -f "$SIGNING_PUBLIC_KEY" ]; then echo "Error: Signing public key not found: $SIGNING_PUBLIC_KEY" exit 1 fi fi # Create temp directory TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT echo "=== Analyzing Feed ===" # Extract advisories without exploitability_score jq '.advisories | map(select(.exploitability_score == null or .exploitability_score == ""))' \ "$FEED_PATH" > "$TEMP_DIR/missing_exploitability.json" MISSING_COUNT=$(jq 'length' "$TEMP_DIR/missing_exploitability.json") TOTAL_COUNT=$(jq '.advisories | length' "$FEED_PATH") ALREADY_DONE=$((TOTAL_COUNT - MISSING_COUNT)) echo "Total advisories: $TOTAL_COUNT" echo "Already have exploitability: $ALREADY_DONE" echo "Missing exploitability: $MISSING_COUNT" echo "" if [ "$MISSING_COUNT" -eq 0 ]; then echo "✓ All advisories already have exploitability scores!" exit 0 fi if [ "$DRY_RUN" = "true" ]; then echo "=== Dry Run - Would Update These Advisories ===" jq -r '.[] | .id' "$TEMP_DIR/missing_exploitability.json" echo "" echo "Total advisories to update: $MISSING_COUNT" exit 0 fi echo "=== Processing Advisories ===" # Process each advisory PROCESSED=0 FAILED=0 # Read original feed to preserve all metadata cp "$FEED_PATH" "$TEMP_DIR/feed_working.json" while IFS= read -r advisory; do CVE_ID=$(echo "$advisory" | jq -r '.id') echo -n "Processing $CVE_ID... " # Prepare input for analyzer ANALYZER_INPUT=$(echo "$advisory" | jq '{ cve_id: .id, cvss_score: (.cvss_score // 0.0), type: .type, description: .description, references: (.references // []) }') # Run analyzer if ANALYSIS=$(echo "$ANALYZER_INPUT" | python3 "$ANALYZER" --json --check-exploits 2>/dev/null); then # Extract exploitability fields EXPL_SCORE=$(echo "$ANALYSIS" | jq -r '.exploitability_score // "unknown"') EXPL_RATIONALE=$(echo "$ANALYSIS" | jq -r '.exploitability_rationale // "No rationale available"') # Update advisory in working feed jq --arg id "$CVE_ID" \ --arg score "$EXPL_SCORE" \ --arg rationale "$EXPL_RATIONALE" \ '(.advisories[] | select(.id == $id)) |= (. + { exploitability_score: $score, exploitability_rationale: $rationale })' "$TEMP_DIR/feed_working.json" > "$TEMP_DIR/feed_updated.json" mv "$TEMP_DIR/feed_updated.json" "$TEMP_DIR/feed_working.json" echo "✓ $EXPL_SCORE" PROCESSED=$((PROCESSED + 1)) else echo "✗ Failed" FAILED=$((FAILED + 1)) fi done < <(jq -c '.[]' "$TEMP_DIR/missing_exploitability.json") # Check if loop executed successfully if [ ! -f "$TEMP_DIR/feed_working.json" ]; then echo "Error: Feed processing failed" exit 1 fi echo "" echo "=== Processing Complete ===" echo "Processed: $PROCESSED" echo "Failed: $FAILED" echo "" # Write updated feed echo "Writing updated feed to: $FEED_PATH" cp "$TEMP_DIR/feed_working.json" "$FEED_PATH" # Update feed version and timestamp CURRENT_VERSION=$(jq -r '.version' "$FEED_PATH") UPDATED_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) jq --arg ts "$UPDATED_TS" '.updated = $ts' "$FEED_PATH" > "$TEMP_DIR/feed_final.json" mv "$TEMP_DIR/feed_final.json" "$FEED_PATH" echo "✓ Updated feed version: $CURRENT_VERSION" echo "✓ Updated timestamp: $UPDATED_TS" echo "" if [ "$REQUIRE_SIGNING" = "true" ]; then echo "" echo "=== Re-signing Advisory Feed ===" if [ -f "${FEED_PATH}.sig" ]; then if ! sign_and_verify_feed_signature "$FEED_PATH" "${FEED_PATH}.sig"; then exit 1 fi fi fi echo "" echo "=== Summary ===" echo "✓ Backfill complete!" echo "✓ $PROCESSED advisories updated with exploitability scores" if [ "$FAILED" -gt 0 ]; then echo "⚠ $FAILED advisories failed analysis (kept original data)" fi # Verify final state FINAL_MISSING=$(jq '.advisories | map(select(.exploitability_score == null or .exploitability_score == "")) | length' "$FEED_PATH") echo "✓ Advisories still missing exploitability: $FINAL_MISSING"