name: Poll NVD CVEs

on:
  schedule:
    # Run daily at 06:00 UTC
    - cron: '0 6 * * *'
  workflow_dispatch:
    inputs:
      force_full_scan:
        description: 'Ignore feed state and rebuild CVE advisories from full NVD history'
        required: false
        default: 'false'
        type: boolean

permissions: read-all

concurrency:
  group: poll-nvd-cves
  cancel-in-progress: false

env:
  FEED_PATH: advisories/feed.json
  FEED_SIG_PATH: advisories/feed.json.sig
  SKILL_FEED_PATH: skills/clawsec-feed/advisories/feed.json
  SKILL_FEED_SIG_PATH: skills/clawsec-feed/advisories/feed.json.sig

jobs:
  poll-and-update:
    runs-on: ubuntu-latest
    permissions:
      actions: write
      contents: write
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 1

      - name: Get last poll date from feed
        id: last_poll
        run: |
          if [ -f "$FEED_PATH" ]; then
            LAST_UPDATED=$(jq -r '.updated // empty' "$FEED_PATH")
            if [ -n "$LAST_UPDATED" ] && [ "${{ inputs.force_full_scan }}" != "true" ]; then
              echo "last_date=$LAST_UPDATED" >> $GITHUB_OUTPUT
              echo "Found last updated: $LAST_UPDATED"
            else
              # Default to 120 days ago if no date found or force scan
              LAST_UPDATED=$(date -u -d '120 days ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -v-120d +%Y-%m-%dT%H:%M:%S.000Z)
              echo "last_date=$LAST_UPDATED" >> $GITHUB_OUTPUT
              echo "Using default date: $LAST_UPDATED"
            fi
          else
            LAST_UPDATED=$(date -u -d '120 days ago' +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -v-120d +%Y-%m-%dT%H:%M:%S.000Z)
            echo "last_date=$LAST_UPDATED" >> $GITHUB_OUTPUT
            echo "No feed found, using default: $LAST_UPDATED"
          fi

      - name: Set date window
        id: dates
        run: |
          START_DATE="${{ steps.last_poll.outputs.last_date }}"
          END_DATE=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
          
          # Convert to epoch for comparison
          START_EPOCH=$(date -d "$START_DATE" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${START_DATE%.*}" +%s)
          END_EPOCH=$(date -d "$END_DATE" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${END_DATE%.*}" +%s)
          
          # Ensure start date is before end date (NVD returns 404 if start > end)
          if [ "$START_EPOCH" -ge "$END_EPOCH" ]; then
            echo "Warning: Start date ($START_DATE) is not before end date ($END_DATE)"
            echo "Adjusting start date to 24 hours before end date"
            START_EPOCH=$((END_EPOCH - 86400))
            START_DATE=$(date -u -d "@$START_EPOCH" +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -r "$START_EPOCH" +%Y-%m-%dT%H:%M:%S.000Z)
          fi
          
          echo "start_date=$START_DATE" >> $GITHUB_OUTPUT
          echo "end_date=$END_DATE" >> $GITHUB_OUTPUT
          echo "Polling window: $START_DATE to $END_DATE"

      - name: Fetch CVEs from NVD
        id: fetch
        run: |
          set -euo pipefail
          source scripts/feed-utils.sh
          mkdir -p tmp
          FORCE_FULL_SCAN="${{ inputs.force_full_scan }}"
          NVD_QUERY_SPECS="$(nvd_query_specs)"
          
          START_DATE="${{ steps.dates.outputs.start_date }}"
          END_DATE="${{ steps.dates.outputs.end_date }}"
          
          # URL encode the dates
          START_ENC=$(echo "$START_DATE" | sed 's/:/%3A/g')
          END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
          
          echo "=== Fetching CVEs from NVD ==="

          FAILED_QUERIES=()
          
          while IFS='|' read -r QUERY_KIND QUERY_VALUE; do
            [ -n "$QUERY_KIND" ] || continue

            QUERY_SLUG="$(nvd_query_slug "$QUERY_KIND" "$QUERY_VALUE")"
            echo "Fetching $QUERY_KIND query: $QUERY_VALUE"

            keyword_ok=false
            last_http_code=""

            if [ "$FORCE_FULL_SCAN" = "true" ]; then
              echo "Full scan mode enabled: paginating complete NVD history for '$QUERY_KIND:$QUERY_VALUE'"
              echo '{"vulnerabilities":[]}' > "tmp/nvd_${QUERY_SLUG}.json"
              START_INDEX=0
              RESULTS_PER_PAGE=2000

              while true; do
                URL="$(nvd_build_url "$QUERY_KIND" "$QUERY_VALUE" "&startIndex=${START_INDEX}&resultsPerPage=${RESULTS_PER_PAGE}")"
                PAGE_FILE="tmp/nvd_${QUERY_SLUG}_${START_INDEX}.json"
                echo "URL: $URL"

                page_ok=false
                for i in 1 2 3; do
                  HTTP_CODE=$(curl -sS -w "%{http_code}" -o "$PAGE_FILE" "$URL" || true)
                  if [ -z "$HTTP_CODE" ]; then
                    HTTP_CODE="000"
                  fi
                  last_http_code="$HTTP_CODE"
                  if [ "$HTTP_CODE" = "200" ]; then
                    if jq -e . "$PAGE_FILE" >/dev/null 2>&1; then
                      page_ok=true
                      break
                    fi
                    echo "Invalid JSON for $QUERY_KIND:$QUERY_VALUE page $START_INDEX, retry $i..."
                    sleep 5
                  elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
                    echo "Rate limited, waiting 30s before retry $i..."
                    sleep 30
                  else
                    echo "HTTP $HTTP_CODE for $QUERY_KIND:$QUERY_VALUE page $START_INDEX, retry $i..."
                    sleep 5
                  fi
                done

                if [ "$page_ok" != "true" ]; then
                  break
                fi

                jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
                  "tmp/nvd_${QUERY_SLUG}.json" "$PAGE_FILE" > "tmp/nvd_${QUERY_SLUG}_merged.json"
                mv "tmp/nvd_${QUERY_SLUG}_merged.json" "tmp/nvd_${QUERY_SLUG}.json"

                PAGE_COUNT=$(jq '.vulnerabilities | length' "$PAGE_FILE")
                TOTAL_RESULTS=$(jq '.totalResults // 0' "$PAGE_FILE")
                echo "Fetched $PAGE_COUNT results at startIndex=$START_INDEX (totalResults=$TOTAL_RESULTS)"

                START_INDEX=$((START_INDEX + RESULTS_PER_PAGE))
                if [ "$START_INDEX" -ge "$TOTAL_RESULTS" ] || [ "$PAGE_COUNT" -eq 0 ]; then
                  keyword_ok=true
                  break
                fi

                # NVD recommends 6 second delay between requests
                sleep 6
              done
            else
              URL="$(nvd_build_url "$QUERY_KIND" "$QUERY_VALUE" "&lastModStartDate=${START_ENC}&lastModEndDate=${END_ENC}")"
              echo "URL: $URL"

              # Fetch with retry logic
              for i in 1 2 3; do
                HTTP_CODE=$(curl -sS -w "%{http_code}" -o "tmp/nvd_${QUERY_SLUG}.json" "$URL" || true)
                if [ -z "$HTTP_CODE" ]; then
                  HTTP_CODE="000"
                fi
                last_http_code="$HTTP_CODE"
                if [ "$HTTP_CODE" = "200" ]; then
                  if jq -e . "tmp/nvd_${QUERY_SLUG}.json" >/dev/null 2>&1; then
                    echo "Success for $QUERY_KIND:$QUERY_VALUE"
                    keyword_ok=true
                    break
                  fi
                  echo "Invalid JSON for $QUERY_KIND:$QUERY_VALUE, retry $i..."
                  sleep 5
                elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "429" ]; then
                  echo "Rate limited, waiting 30s before retry $i..."
                  sleep 30
                else
                  echo "HTTP $HTTP_CODE for $QUERY_KIND:$QUERY_VALUE, retry $i..."
                  sleep 5
                fi
              done
            fi

            if [ "$keyword_ok" != "true" ]; then
              echo "::error::Failed to fetch valid NVD response for '$QUERY_KIND:$QUERY_VALUE' (last HTTP code: ${last_http_code:-unknown})."
              FAILED_QUERIES+=("${QUERY_KIND}:${QUERY_VALUE}")
            fi
            
            # NVD recommends 6 second delay between requests
            sleep 6
          done <<< "$NVD_QUERY_SPECS"
          
          if [ "${#FAILED_QUERIES[@]}" -gt 0 ]; then
            echo "::error::NVD fetch failed for query spec(s): ${FAILED_QUERIES[*]}"
            exit 1
          fi

          echo "=== Fetch complete ==="
          ls -la tmp/

      - name: Merge and filter CVEs
        id: process
        run: |
          source scripts/feed-utils.sh
          NVD_QUERY_SPECS="$(nvd_query_specs)"
          KEYWORDS_PATTERN="$(nvd_keyword_pattern)"
          GITHUB_PATTERN="$(nvd_github_ref_pattern)"
          CPE_PATTERN="$(nvd_cpe_pattern)"
          # Export concise project keyword groups for PR body + workflow summary steps
          KEYWORDS="$(nvd_summary_keywords)"
          echo "KEYWORDS=$KEYWORDS" >> "$GITHUB_ENV"

          # Combine all fetched CVEs
          echo '{"vulnerabilities":[]}' > tmp/combined.json
          
          while IFS='|' read -r QUERY_KIND QUERY_VALUE; do
            [ -n "$QUERY_KIND" ] || continue
            QUERY_SLUG="$(nvd_query_slug "$QUERY_KIND" "$QUERY_VALUE")"
            FILE="tmp/nvd_${QUERY_SLUG}.json"
            if [ -f "$FILE" ] && [ -s "$FILE" ]; then
              # Check if file has vulnerabilities array
              if jq -e '.vulnerabilities' "$FILE" > /dev/null 2>&1; then
                COUNT=$(jq '.vulnerabilities | length' "$FILE")
                echo "Found $COUNT CVEs for keyword search"
                
                # Merge into combined
                jq -s '.[0].vulnerabilities += .[1].vulnerabilities | .[0]' \
                  tmp/combined.json "$FILE" > tmp/combined_new.json
                mv tmp/combined_new.json tmp/combined.json
              fi
            fi
          done <<< "$NVD_QUERY_SPECS"
          
          # Deduplicate by CVE ID
          jq '.vulnerabilities | unique_by(.cve.id)' tmp/combined.json > tmp/unique_cves.json
          TOTAL=$(jq 'length' tmp/unique_cves.json)
          echo "Total unique CVEs from NVD: $TOTAL"
          
          # Post-filter: keep only CVEs where description contains keywords OR references contain github pattern
          jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_PATTERN" --arg cpe "$CPE_PATTERN" '
            [.[] | select(
              # Check if any description contains keywords (case insensitive)
              (.cve.descriptions[]? | select(.lang == "en") | .value | test($kw; "i"))
              or
              # Check if any reference URL contains the github pattern
              (.cve.references[]? | .url | test($gh; "i"))
              or
              # Check if any CPE criteria contain the Hermes product identifier
              ([.cve.configurations[]? | .. | objects | .criteria? | strings | test($cpe; "i")] | any)
            )]
          ' tmp/unique_cves.json > tmp/filtered_cves.json
          
          FILTERED=$(jq 'length' tmp/filtered_cves.json)
          echo "Filtered CVEs (matching criteria): $FILTERED"
          echo "filtered_count=$FILTERED" >> $GITHUB_OUTPUT

      - name: Get existing advisories
        id: existing
        run: |
          if [ -f "$FEED_PATH" ]; then
            jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u > tmp/existing_ids.txt
            # Also extract full existing advisories for update comparison
            jq '.advisories // []' "$FEED_PATH" > tmp/existing_advisories.json
          else
            touch tmp/existing_ids.txt
            echo '[]' > tmp/existing_advisories.json
          fi

          EXISTING_COUNT=$(wc -l < tmp/existing_ids.txt | tr -d ' ')
          echo "Existing advisories: $EXISTING_COUNT"
          cat tmp/existing_ids.txt

      - name: Check for updates to existing advisories
        id: updates
        run: |
          if [ "${{ inputs.force_full_scan }}" = "true" ]; then
            echo "Full scan mode enabled: skipping delta update detection."
            echo '[]' > tmp/updated_advisories.json
            echo "Advisories to update: 0"
            echo "update_count=0" >> $GITHUB_OUTPUT
            exit 0
          fi

          # Compare existing CVE advisories against NVD data for changes
          # Only check advisories that start with "CVE-" (NVD-sourced)

          jq '
            def map_severity:
              if . == null then "medium"
              elif . >= 9.0 then "critical"
              elif . >= 7.0 then "high"
              elif . >= 4.0 then "medium"
              else "low"
              end;

            def get_cvss_score:
              .cve.metrics.cvssMetricV31[0]?.cvssData.baseScore //
              .cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
              .cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
              null;

            def nvd_category_raw:
              (
                [.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
                | unique
                | map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
                | .[0]
              );

            def cwe_id:
              (
                nvd_category_raw
                | if . == null then null
                  else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
                  end
              );

            def cwe_name_map($id):
              ({
                "20": "improper_input_validation",
                "22": "path_traversal",
                "77": "command_injection",
                "78": "os_command_injection",
                "79": "cross_site_scripting",
                "89": "sql_injection",
                "94": "code_injection",
                "119": "memory_buffer_bounds_violation",
                "120": "classic_buffer_overflow",
                "125": "out_of_bounds_read",
                "134": "format_string_vulnerability",
                "200": "exposure_of_sensitive_information",
                "250": "execution_with_unnecessary_privileges",
                "269": "improper_privilege_management",
                "284": "improper_access_control",
                "285": "improper_authorization",
                "287": "improper_authentication",
                "295": "improper_certificate_validation",
                "306": "missing_authentication_for_critical_function",
                "319": "cleartext_transmission_of_sensitive_information",
                "326": "inadequate_encryption_strength",
                "327": "risky_cryptographic_algorithm",
                "352": "cross_site_request_forgery",
                "362": "race_condition",
                "400": "uncontrolled_resource_consumption",
                "416": "use_after_free",
                "434": "unrestricted_file_upload",
                "502": "deserialization_of_untrusted_data",
                "601": "open_redirect",
                "611": "xml_external_entity_injection",
                "639": "insecure_direct_object_reference",
                "668": "exposure_of_resource_to_wrong_sphere",
                "669": "incorrect_resource_transfer_between_spheres",
                "732": "incorrect_permission_assignment",
                "787": "out_of_bounds_write",
                "798": "hard_coded_credentials",
                "862": "missing_authorization",
                "863": "incorrect_authorization",
                "918": "server_side_request_forgery",
                "922": "insecure_storage_of_sensitive_information"
              }[$id]);

            def nvd_category_name:
              (
                cwe_id as $id
                | if $id == null then "unspecified_weakness"
                  else (cwe_name_map($id) // ("unknown_cwe_" + $id))
                  end
              );

            def cpe_criteria:
              (
                [.cve.configurations[]? | .. | objects | .criteria? | strings | select(startswith("cpe:2.3:"))]
                | unique
              );

            def detection_blob:
              (
                [
                  (.cve.descriptions[]? | select(.lang == "en") | .value),
                  (.cve.references[]?.url // empty),
                  (.cve.configurations[]? | .. | objects | .criteria? // empty)
                ]
                | map(strings | ascii_downcase)
                | join(" ")
              );

            def inferred_targets:
              (
                detection_blob as $blob
                | (
                    (if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
                    + (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
                    + (if ($blob | test("github\\.com/softwarepub/hermes|github\\.com/nousresearch/hermes-agent|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|\\bhermes-agent\\b|software publication with rich metadata")) then ["hermes@*"] else [] end)
                    + (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end)
                  )
              );

            def matched_targets:
              (
                (cpe_criteria + inferred_targets)
                | unique
                | .[0:5]
              );

            def platforms_from_targets($targets):
              (
                [
                  (if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end),
                  (if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end),
                  (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end),
                  (if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end)
                ]
              );

            def normalized_affected:
              (
                matched_targets
                | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end
              );

            def normalized_platforms:
              (
                inferred_targets as $inferred
                | platforms_from_targets($inferred) as $from_inferred
                | if ($from_inferred | length) > 0 then $from_inferred
                  else
                    matched_targets as $targets
                    | platforms_from_targets($targets) as $from_targets
                    | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end
                  end
              );

            def preferred_description:
              (
                (.cve.descriptions[]? | select(.lang == "en") | .value)
                // .cve.descriptions[0]?.value
                // "No description provided by NVD."
              );

            [.[] | {
              id: .cve.id,
              severity: (get_cvss_score | map_severity),
              type: nvd_category_name,
              nvd_category_id: nvd_category_raw,
              cvss_score: get_cvss_score,
              description: preferred_description,
              title: (preferred_description | .[0:100] + (if length > 100 then "..." else "" end)),
              affected: normalized_affected,
              platforms: normalized_platforms,
              references: [.cve.references[]?.url // empty] | unique | .[0:3],
              exploitability_score: null,
              exploitability_rationale: null
            }]
          ' tmp/filtered_cves.json > tmp/nvd_current_state.json

          # Find updates: existing CVE advisories where NVD data differs
          jq -n --slurpfile existing tmp/existing_advisories.json --slurpfile nvd tmp/nvd_current_state.json '
            # Get only CVE-prefixed existing advisories
            ($existing[0] | map(select(.id | startswith("CVE-")))) as $cve_advisories |

            # For each NVD entry, check if it exists and has changes
            [
              $nvd[0][] |
              . as $nvd_entry |
              ($cve_advisories | map(select(.id == $nvd_entry.id)) | first) as $existing_entry |
              if $existing_entry then
                # Compare key fields
                if ($existing_entry.severity != $nvd_entry.severity) or
                   ($existing_entry.type != $nvd_entry.type) or
                   ($existing_entry.nvd_category_id != $nvd_entry.nvd_category_id) or
                   ($existing_entry.cvss_score != $nvd_entry.cvss_score) or
                   ($existing_entry.affected != $nvd_entry.affected) or
                   ($existing_entry.platforms != $nvd_entry.platforms) or
                   ($existing_entry.description != $nvd_entry.description) then
                  {
                    id: $nvd_entry.id,
                    changes: (
                      []
                      + (if $existing_entry.severity != $nvd_entry.severity then ["severity: \($existing_entry.severity) → \($nvd_entry.severity)"] else [] end)
                      + (if $existing_entry.type != $nvd_entry.type then ["type: \($existing_entry.type // "null") → \($nvd_entry.type // "null")"] else [] end)
                      + (if $existing_entry.nvd_category_id != $nvd_entry.nvd_category_id then ["nvd_category_id: \($existing_entry.nvd_category_id // "null") → \($nvd_entry.nvd_category_id // "null")"] else [] end)
                      + (if $existing_entry.cvss_score != $nvd_entry.cvss_score then ["cvss_score: \($existing_entry.cvss_score // "null") → \($nvd_entry.cvss_score // "null")"] else [] end)
                      + (if $existing_entry.affected != $nvd_entry.affected then ["affected targets updated"] else [] end)
                      + (if $existing_entry.platforms != $nvd_entry.platforms then ["platforms updated"] else [] end)
                      + (if $existing_entry.description != $nvd_entry.description then ["description updated"] else [] end)
                    ),
                    updated_fields: {
                      severity: $nvd_entry.severity,
                      type: $nvd_entry.type,
                      nvd_category_id: $nvd_entry.nvd_category_id,
                      cvss_score: $nvd_entry.cvss_score,
                      affected: $nvd_entry.affected,
                      platforms: $nvd_entry.platforms,
                      description: $nvd_entry.description,
                      title: $nvd_entry.title,
                      references: $nvd_entry.references
                    }
                  }
                else
                  empty
                end
              else
                empty
              end
            ]
          ' > tmp/updated_advisories.json

          UPDATE_COUNT=$(jq 'length' tmp/updated_advisories.json)
          echo "Advisories to update: $UPDATE_COUNT"
          echo "update_count=$UPDATE_COUNT" >> $GITHUB_OUTPUT

          if [ "$UPDATE_COUNT" -gt 0 ]; then
            echo "=== Updated advisories ==="
            jq -r '.[] | "- \(.id): \(.changes | join(", "))"' tmp/updated_advisories.json
          fi

      - name: Transform CVEs to advisories
        id: transform
        run: |
          # Read existing IDs into a jq-friendly file for jq (avoids huge CLI args on full scans).
          if [ "${{ inputs.force_full_scan }}" = "true" ]; then
            echo "Full scan mode enabled: rebuilding CVE advisories from scratch."
            echo '[]' > tmp/existing_ids.json
          else
            jq -R -s 'split("\n") | map(select(length > 0))' < tmp/existing_ids.txt > tmp/existing_ids.json
          fi
          
          # Transform NVD CVEs to our advisory format
          jq --slurpfile existing tmp/existing_ids.json '
            def map_severity:
              if . == null then "medium"
              elif . >= 9.0 then "critical"
              elif . >= 7.0 then "high"
              elif . >= 4.0 then "medium"
              else "low"
              end;
            
            def get_cvss_score:
              .cve.metrics.cvssMetricV31[0]?.cvssData.baseScore //
              .cve.metrics.cvssMetricV30[0]?.cvssData.baseScore //
              .cve.metrics.cvssMetricV2[0]?.cvssData.baseScore //
              null;

            def nvd_category_raw:
              (
                [.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)]
                | unique
                | map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other"))
                | .[0]
              );

            def cwe_id:
              (
                nvd_category_raw
                | if . == null then null
                  else (try (capture("^CWE-(?<id>[0-9]+)$").id) catch null)
                  end
              );

            def cwe_name_map($id):
              ({
                "20": "improper_input_validation",
                "22": "path_traversal",
                "77": "command_injection",
                "78": "os_command_injection",
                "79": "cross_site_scripting",
                "89": "sql_injection",
                "94": "code_injection",
                "119": "memory_buffer_bounds_violation",
                "120": "classic_buffer_overflow",
                "125": "out_of_bounds_read",
                "134": "format_string_vulnerability",
                "200": "exposure_of_sensitive_information",
                "250": "execution_with_unnecessary_privileges",
                "269": "improper_privilege_management",
                "284": "improper_access_control",
                "285": "improper_authorization",
                "287": "improper_authentication",
                "295": "improper_certificate_validation",
                "306": "missing_authentication_for_critical_function",
                "319": "cleartext_transmission_of_sensitive_information",
                "326": "inadequate_encryption_strength",
                "327": "risky_cryptographic_algorithm",
                "352": "cross_site_request_forgery",
                "362": "race_condition",
                "400": "uncontrolled_resource_consumption",
                "416": "use_after_free",
                "434": "unrestricted_file_upload",
                "502": "deserialization_of_untrusted_data",
                "601": "open_redirect",
                "611": "xml_external_entity_injection",
                "639": "insecure_direct_object_reference",
                "668": "exposure_of_resource_to_wrong_sphere",
                "669": "incorrect_resource_transfer_between_spheres",
                "732": "incorrect_permission_assignment",
                "787": "out_of_bounds_write",
                "798": "hard_coded_credentials",
                "862": "missing_authorization",
                "863": "incorrect_authorization",
                "918": "server_side_request_forgery",
                "922": "insecure_storage_of_sensitive_information"
              }[$id]);

            def nvd_category_name:
              (
                cwe_id as $id
                | if $id == null then "unspecified_weakness"
                  else (cwe_name_map($id) // ("unknown_cwe_" + $id))
                  end
              );

            def cpe_criteria:
              (
                [.cve.configurations[]? | .. | objects | .criteria? | strings | select(startswith("cpe:2.3:"))]
                | unique
              );

            def detection_blob:
              (
                [
                  (.cve.descriptions[]? | select(.lang == "en") | .value),
                  (.cve.references[]?.url // empty),
                  (.cve.configurations[]? | .. | objects | .criteria? // empty)
                ]
                | map(strings | ascii_downcase)
                | join(" ")
              );

            def inferred_targets:
              (
                detection_blob as $blob
                | (
                    (if ($blob | test("github\\.com/openclaw/openclaw|\\bopenclaw\\b|\\bclawdbot\\b|\\bmoltbot\\b")) then ["openclaw@*"] else [] end)
                    + (if ($blob | test("github\\.com/qwibitai/nanoclaw|\\bnanoclaw\\b|whatsapp-bot|\\bbaileys\\b")) then ["nanoclaw@*"] else [] end)
                    + (if ($blob | test("github\\.com/softwarepub/hermes|github\\.com/nousresearch/hermes-agent|cpe:2\\.3:a:software-metadata\\.pub:hermes|\\bhermes workflow\\b|\\bhermes-agent\\b|software publication with rich metadata")) then ["hermes@*"] else [] end)
                    + (if ($blob | test("github\\.com/[^/]+/picoclaw|\\bpicoclaw\\b|cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)")) then ["picoclaw@*"] else [] end)
                  )
              );

            def matched_targets:
              (
                (cpe_criteria + inferred_targets)
                | unique
                | .[0:5]
              );

            def platforms_from_targets($targets):
              (
                [
                  (if ($targets | map(strings | ascii_downcase | select(startswith("openclaw@") or test("^cpe:2\\.3:[aho]:openclaw:openclaw(?::|$)"))) | length > 0) then "openclaw" else empty end),
                  (if ($targets | map(strings | ascii_downcase | select(startswith("nanoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:nanoclaw(?::|$)"))) | length > 0) then "nanoclaw" else empty end),
                  (if ($targets | map(strings | ascii_downcase | select(startswith("hermes@") or test("^cpe:2\\.3:[aho]:software-metadata\\.pub:hermes(?::|$)"))) | length > 0) then "hermes" else empty end),
                  (if ($targets | map(strings | ascii_downcase | select(startswith("picoclaw@") or test("^cpe:2\\.3:[aho]:[^:]*:picoclaw(?::|$)"))) | length > 0) then "picoclaw" else empty end)
                ]
              );

            def normalized_affected:
              (
                matched_targets
                | if length == 0 then ["openclaw@*", "nanoclaw@*", "hermes@*", "picoclaw@*"] else . end
              );

            def normalized_platforms:
              (
                inferred_targets as $inferred
                | platforms_from_targets($inferred) as $from_inferred
                | if ($from_inferred | length) > 0 then $from_inferred
                  else
                    matched_targets as $targets
                    | platforms_from_targets($targets) as $from_targets
                    | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes", "picoclaw"] end
                  end
              );

            def preferred_description:
              (
                (.cve.descriptions[]? | select(.lang == "en") | .value)
                // .cve.descriptions[0]?.value
                // "No description provided by NVD."
              );
            
            [.[] |
              select(.cve.id as $id | (($existing[0] // []) | index($id) | not)) |
              {
                id: .cve.id,
                severity: (get_cvss_score | map_severity),
                type: nvd_category_name,
                nvd_category_id: nvd_category_raw,
                title: (preferred_description | .[0:100] + (if length > 100 then "..." else "" end)),
                description: preferred_description,
                affected: normalized_affected,
                platforms: normalized_platforms,
                action: "Review and update affected components. See NVD for remediation details.",
                published: .cve.published,
                references: [.cve.references[]?.url // empty] | unique | .[0:3],
                cvss_score: get_cvss_score,
                nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id),
                exploitability_score: null,
                exploitability_rationale: null
              }
            ]
          ' tmp/filtered_cves.json > tmp/new_advisories.json
          
          NEW_COUNT=$(jq 'length' tmp/new_advisories.json)
          echo "New advisories to add: $NEW_COUNT"
          echo "new_count=$NEW_COUNT" >> $GITHUB_OUTPUT

          if [ "${{ inputs.force_full_scan }}" = "true" ]; then
            FILTERED_COUNT="${{ steps.process.outputs.filtered_count }}"
            if [ "$NEW_COUNT" -ne "$FILTERED_COUNT" ]; then
              echo "::error::Full scan transform mismatch: filtered CVEs=$FILTERED_COUNT transformed advisories=$NEW_COUNT"
              exit 1
            fi
          fi
          
          if [ "$NEW_COUNT" -gt 0 ]; then
            echo "=== New advisories ==="
            jq '.[].id' tmp/new_advisories.json
          fi

      - name: Set up Python for exploitability analysis
        if: steps.transform.outputs.new_count != '0'
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: '3.10'

      - name: Analyze exploitability for new advisories
        if: steps.transform.outputs.new_count != '0'
        run: |
          set -euo pipefail

          echo "=== Analyzing exploitability for new advisories ==="

          # Extract CVSS vectors from filtered CVEs to merge with advisories
          jq '
            [.[] | {
              id: .cve.id,
              cvss_vector: (
                .cve.metrics.cvssMetricV31[0]?.cvssData.vectorString //
                .cve.metrics.cvssMetricV30[0]?.cvssData.vectorString //
                .cve.metrics.cvssMetricV2[0]?.vectorString //
                ""
              )
            }] | map({(.id): .cvss_vector}) | add
          ' tmp/filtered_cves.json > tmp/cvss_vectors.json

          scripts/ci/enrich_exploitability.sh \
            --mode batch \
            --input tmp/new_advisories.json \
            --output tmp/new_advisories.json \
            --cvss-vectors tmp/cvss_vectors.json

          echo "=== Exploitability analysis complete ==="

          # Show summary of exploitability scores
          echo "Exploitability score distribution:"
          jq -r '.[] | "\(.id): \(.exploitability_score // "unknown")"' tmp/new_advisories.json | \
            awk -F': ' '{scores[$2]++} END {for (s in scores) print "  " s ": " scores[s]}'

      - name: Update feed.json
        if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
        run: |
          NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
          FORCE_FULL_SCAN="${{ inputs.force_full_scan }}"

          if [ -f "$FEED_PATH" ] && [ "$FORCE_FULL_SCAN" = "true" ]; then
            # Full scan mode: replace all CVE advisories with rebuilt set and keep non-CVE entries.
            jq --slurpfile rebuilt tmp/new_advisories.json --arg now "$NOW" '
              .updated = $now |
              .advisories = (
                ((.advisories // []) | map(select((.id // "") | startswith("CVE-") | not)))
                + ($rebuilt[0] // [])
                | sort_by(.published)
                | reverse
              )
            ' "$FEED_PATH" > tmp/updated_feed.json
          elif [ -f "$FEED_PATH" ]; then
            # Step 1: Apply updates to existing advisories
            jq --slurpfile updates tmp/updated_advisories.json '
              .advisories = [
                .advisories[] |
                . as $adv |
                ($updates[0] | map(select(.id == $adv.id)) | first) as $update |
                if $update then
                  # Merge updated fields
                  ($adv * $update.updated_fields)
                else
                  $adv
                end
              ]
            ' "$FEED_PATH" > tmp/feed_with_updates.json

            # Step 2: Add new advisories
            jq --slurpfile new tmp/new_advisories.json --arg now "$NOW" '
              .updated = $now |
              .advisories = (.advisories + ($new[0] // []) | sort_by(.published) | reverse)
            ' tmp/feed_with_updates.json > tmp/updated_feed.json
          else
            jq -n --slurpfile advisories tmp/new_advisories.json --arg now "$NOW" '{
              version: "1.0.0",
              updated: $now,
              description: "Community-driven security advisory feed for ClawSec",
              advisories: (($advisories[0] // []) | sort_by(.published) | reverse)
            }' > tmp/updated_feed.json
          fi

          # Validate JSON
          if jq empty tmp/updated_feed.json 2>/dev/null; then
            echo "Feed JSON is valid"
            mv tmp/updated_feed.json "$FEED_PATH"

            # Also update the skill feed
            mkdir -p "$(dirname "$SKILL_FEED_PATH")"
            cp "$FEED_PATH" "$SKILL_FEED_PATH"

            echo "=== Updated feeds ==="
            echo "Main feed: $FEED_PATH"
            echo "Skill feed: $SKILL_FEED_PATH"
            jq '.advisories | length' "$FEED_PATH"
          else
            echo "Error: Generated invalid JSON"
            exit 1
          fi

      - name: Guard dependency manifests from NVD updates
        if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
        run: |
          set -euo pipefail

          BLOCKED_FILES=()
          for file in package.json package-lock.json npm-shrinkwrap.json; do
            if ! git diff --quiet -- "$file"; then
              BLOCKED_FILES+=("$file")
            fi
          done

          if [ "${#BLOCKED_FILES[@]}" -gt 0 ]; then
            echo "::error::NVD workflow must not modify dependency manifests: ${BLOCKED_FILES[*]}"
            git --no-pager diff -- "${BLOCKED_FILES[@]}" || true
            exit 1
          fi

      - name: Sign advisory feed and verify
        if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
        uses: ./.github/actions/sign-and-verify
        with:
          private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
          private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
          input_file: ${{ env.FEED_PATH }}
          signature_file: ${{ env.FEED_SIG_PATH }}
          verify_files: |
            ${{ env.FEED_PATH }}
            ${{ env.SKILL_FEED_PATH }}

      - name: Sync advisory signature to skill feed
        if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
        run: cp "$FEED_SIG_PATH" "$SKILL_FEED_SIG_PATH"

      - name: Clean workspace for PR
        if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
        run: |
          # Reset any unintended changes, keep only feed files
          git checkout -- .github/ 2>/dev/null || true
          git clean -fd .github/ 2>/dev/null || true

      - name: Upsert NVD advisory PR
        if: steps.transform.outputs.new_count != '0' || steps.updates.outputs.update_count != '0'
        id: upsert-pr
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail

          BRANCH_PREFIX="automated/nvd-cve-update"
          PR_COMMENT="Superseded by newer automated NVD advisory update."
          TITLE="chore: CVE advisories - ${{ steps.transform.outputs.new_count }} new, ${{ steps.updates.outputs.update_count }} updated"
          COMMIT_SUBJECT="$TITLE"
          COMMIT_BODY=$'Automated update from NVD CVE feed.\nKeywords: ${{ env.KEYWORDS }}\nPoll window: ${{ steps.dates.outputs.start_date }} to ${{ steps.dates.outputs.end_date }}'

          if [ "${{ inputs.force_full_scan }}" = "true" ]; then
            MODE="full-rebuild (ignore feed state)"
          else
            MODE="delta (incremental)"
          fi

          BODY_FILE="$(mktemp)"
          cat > "$BODY_FILE" <<EOF
          ## Summary
          Automated update from NVD CVE feed.

          - **Mode:** ${MODE}
          - **New advisories:** ${{ steps.transform.outputs.new_count }}
          - **Updated advisories:** ${{ steps.updates.outputs.update_count }}
          - **Poll window:** ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }}
          - **Keywords:** ${{ env.KEYWORDS }}

          ---
          *This PR was automatically generated by the NVD CVE polling workflow.*
          EOF

          PR_LIST_JSON="$(
            gh api --paginate "repos/${{ github.repository }}/pulls?state=open&base=main&per_page=100" \
              --jq '.[] | {number, headRefName: .head.ref, url: .html_url, updatedAt: .updated_at}' \
            | jq -s '.'
          )"

          mapfile -t MATCHING_OPEN_PRS < <(
            echo "$PR_LIST_JSON" | jq -r --arg prefix "$BRANCH_PREFIX" '
              map(select(.headRefName | startswith($prefix)))
              | sort_by(.updatedAt)
              | reverse
              | .[]
              | @base64
            '
          )

          TARGET_BRANCH="$BRANCH_PREFIX"
          TARGET_PR_NUMBER=""
          TARGET_PR_URL=""

          if [ "${#MATCHING_OPEN_PRS[@]}" -gt 0 ]; then
            PRIMARY_JSON="$(echo "${MATCHING_OPEN_PRS[0]}" | base64 --decode)"
            TARGET_BRANCH="$(echo "$PRIMARY_JSON" | jq -r '.headRefName')"
            TARGET_PR_NUMBER="$(echo "$PRIMARY_JSON" | jq -r '.number')"
            TARGET_PR_URL="$(echo "$PRIMARY_JSON" | jq -r '.url')"

            if [ "${#MATCHING_OPEN_PRS[@]}" -gt 1 ]; then
              echo "Found multiple open NVD advisory PRs. Closing duplicates."
              for encoded_pr in "${MATCHING_OPEN_PRS[@]:1}"; do
                pr_json="$(echo "$encoded_pr" | base64 --decode)"
                pr_number="$(echo "$pr_json" | jq -r '.number')"
                gh pr close "$pr_number" --delete-branch --comment "$PR_COMMENT"
              done
            fi
          fi

          echo "Using target branch: $TARGET_BRANCH"

          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

          git fetch origin main
          git checkout -B "$TARGET_BRANCH" origin/main

          git add "$FEED_PATH" "$FEED_SIG_PATH" "$SKILL_FEED_PATH" "$SKILL_FEED_SIG_PATH"
          if git diff --cached --quiet; then
            echo "::error::Expected advisory feed changes but none were staged."
            exit 1
          fi

          git commit -m "$COMMIT_SUBJECT" -m "$COMMIT_BODY"
          git push --force origin "$TARGET_BRANCH"

          if [ -n "$TARGET_PR_NUMBER" ]; then
            gh pr edit "$TARGET_PR_NUMBER" --title "$TITLE" --body-file "$BODY_FILE"
          else
            TARGET_PR_URL="$(gh pr create --base main --head "$TARGET_BRANCH" --title "$TITLE" --body-file "$BODY_FILE")"
            TARGET_PR_NUMBER="$(basename "$TARGET_PR_URL")"
          fi

          if [ -z "$TARGET_PR_URL" ]; then
            TARGET_PR_URL="$(gh pr view "$TARGET_PR_NUMBER" --json url --jq '.url')"
          fi

          echo "pull-request-number=$TARGET_PR_NUMBER" >> "$GITHUB_OUTPUT"
          echo "pull-request-url=$TARGET_PR_URL" >> "$GITHUB_OUTPUT"
          echo "pull-request-branch=$TARGET_BRANCH" >> "$GITHUB_OUTPUT"

      - name: Run CodeQL on generated PR branch
        if: steps.upsert-pr.outputs.pull-request-number != ''
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail

          BRANCH="${{ steps.upsert-pr.outputs.pull-request-branch }}"
          if [ -z "$BRANCH" ]; then
            echo "::error::Missing pull-request-branch output from upsert-pr step"
            exit 1
          fi

          echo "Dispatching CodeQL for branch: $BRANCH"
          gh workflow run codeql.yml --ref "$BRANCH"

          RUN_ID=""
          for _ in $(seq 1 30); do
            RUN_ID=$(gh run list \
              --workflow "CodeQL" \
              --branch "$BRANCH" \
              --event workflow_dispatch \
              --json databaseId,createdAt \
              --jq 'sort_by(.createdAt) | last | .databaseId // empty')
            if [ -n "$RUN_ID" ]; then
              break
            fi
            sleep 5
          done

          if [ -z "$RUN_ID" ]; then
            echo "::error::Unable to locate dispatched CodeQL run for branch $BRANCH"
            exit 1
          fi

          echo "Waiting for CodeQL run id: $RUN_ID"
          gh run watch "$RUN_ID" --exit-status

      - name: Summary
        run: |
          if [ "${{ inputs.force_full_scan }}" = "true" ]; then
            MODE="full-rebuild (ignore feed state)"
          else
            MODE="delta (incremental)"
          fi

          echo "## NVD CVE Poll Summary" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
          echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
          echo "| Mode | $MODE |" >> $GITHUB_STEP_SUMMARY
          echo "| Poll Window | ${{ steps.dates.outputs.start_date }} → ${{ steps.dates.outputs.end_date }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Keywords | $KEYWORDS |" >> $GITHUB_STEP_SUMMARY
          echo "| CVEs Found (filtered) | ${{ steps.process.outputs.filtered_count }} |" >> $GITHUB_STEP_SUMMARY
          echo "| New Advisories | ${{ steps.transform.outputs.new_count }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Updated Advisories | ${{ steps.updates.outputs.update_count }} |" >> $GITHUB_STEP_SUMMARY

          if [ "${{ steps.transform.outputs.new_count }}" != "0" ]; then
            echo "" >> $GITHUB_STEP_SUMMARY
            echo "### New Advisories" >> $GITHUB_STEP_SUMMARY
            echo "" >> $GITHUB_STEP_SUMMARY
            jq -r '.[] | "- **\(.id)** (\(.severity)): \(.title)"' tmp/new_advisories.json >> $GITHUB_STEP_SUMMARY
          fi

          if [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
            echo "" >> $GITHUB_STEP_SUMMARY
            echo "### Updated Advisories" >> $GITHUB_STEP_SUMMARY
            echo "" >> $GITHUB_STEP_SUMMARY
            jq -r '.[] | "- **\(.id)**: \(.changes | join(", "))"' tmp/updated_advisories.json >> $GITHUB_STEP_SUMMARY
          fi

          if [ "${{ steps.transform.outputs.new_count }}" != "0" ] || [ "${{ steps.updates.outputs.update_count }}" != "0" ]; then
            echo "" >> $GITHUB_STEP_SUMMARY
            echo "🔀 Upserted PR: ${{ steps.upsert-pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
          else
            echo "" >> $GITHUB_STEP_SUMMARY
            echo "✅ No new or updated CVEs found." >> $GITHUB_STEP_SUMMARY
          fi
