mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 05:28:02 +03:00
dfe62457fb
* fix(workflow): expand NVD Hermes coverage and keep keyword export * fix(workflow): export concise nvd summary keywords
1058 lines
46 KiB
YAML
1058 lines
46 KiB
YAML
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
|