Exploitability Context for CVE Advisories (#89)

* feat(advisories): add exploitability context for CVE advisories

* fix(ci): align exploitability workflow with signing model

* docs(skills): add patch release changelog entries

* chore(clawsec-feed): bump version to 0.0.5

* chore(clawsec-suite): bump version to 0.1.4

* fix(clawsec-nanoclaw): align exploitability handling and nanoclaw integration

* chore(clawsec-nanoclaw): bump version to 0.0.2

* refactor(scripts): share feed path and mirror sync helpers

* refactor(utils): unify cvss vector parsing flow

* refactor(clawsec-nanoclaw): centralize advisory risk evaluation

* docs(exploitability): refresh release metadata dates

* fix(review): align feed signing and advisory dedupe

* chore(clawsec-feed): bump version to 0.0.6

* chore(clawsec-nanoclaw): bump version to 0.0.3

* fix(backfill): limit signing to target feed only

* fix(review): keep skill runtime verify-only and dedupe matching

* chore(clawsec-nanoclaw): bump version to 0.0.4

* chore(skills): align versions with published tags

* feat(feed): enrich local population with exploitability analysis

* docs(exploitability): mark backfill as historical flow
This commit is contained in:
davida-ps
2026-03-01 18:43:24 +02:00
committed by GitHub
parent 382db82483
commit 073e771b73
26 changed files with 2015 additions and 197 deletions
+281
View File
@@ -0,0 +1,281 @@
#!/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"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# feed-utils.sh
# Shared advisory feed path and sync helpers for local/maintenance scripts.
init_feed_paths() {
local project_root="$1"
: "${FEED_PATH:=$project_root/advisories/feed.json}"
: "${SKILL_FEED_PATH:=$project_root/skills/clawsec-feed/advisories/feed.json}"
: "${PUBLIC_FEED_PATH:=$project_root/public/advisories/feed.json}"
}
sync_feed_to_mirrors() {
local source_feed="$1"
local mode="${2:-create}"
local target
for target in "$SKILL_FEED_PATH" "$PUBLIC_FEED_PATH"; do
case "$mode" in
create)
mkdir -p "$(dirname "$target")"
cp "$source_feed" "$target"
echo "✓ Updated: $target"
;;
existing-only)
if [ -f "$target" ]; then
cp "$source_feed" "$target"
echo "✓ Updated: $target"
fi
;;
*)
echo "Error: unsupported mirror sync mode: $mode" >&2
return 1
;;
esac
done
}
+111 -18
View File
@@ -11,13 +11,14 @@ 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 - same as pipeline
FEED_PATH="$PROJECT_ROOT/advisories/feed.json"
SKILL_FEED_PATH="$PROJECT_ROOT/skills/clawsec-feed/advisories/feed.json"
PUBLIC_FEED_PATH="$PROJECT_ROOT/public/advisories/feed.json"
init_feed_paths "$PROJECT_ROOT"
KEYWORDS="OpenClaw clawdbot Moltbot NanoClaw WhatsApp-bot baileys"
GITHUB_REF_PATTERN="github.com/openclaw/openclaw github.com/qwibitai/NanoClaw"
ANALYZER="$PROJECT_ROOT/utils/analyze_exploitability.py"
# Parse args
DAYS_BACK=120
@@ -46,6 +47,22 @@ echo "Days back: $DAYS_BACK"
echo "Force mode: $FORCE"
echo ""
# Verify exploitability analyzer prerequisites
if ! command -v python3 &> /dev/null; then
echo "Error: python3 is required but not found in PATH"
exit 1
fi
if [ ! -f "$ANALYZER" ]; then
echo "Error: Exploitability analyzer not found: $ANALYZER"
exit 1
fi
if ! python3 "$ANALYZER" --help &> /dev/null; then
echo "Error: Exploitability analyzer failed to run. Check Python environment."
exit 1
fi
# Create temp directory
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
@@ -62,7 +79,7 @@ fi
if [ -z "${START_DATE:-}" ]; then
# macOS vs Linux date compatibility
if date -v-1d > /dev/null 2>&1; then
START_DATE=$(date -u -v-${DAYS_BACK}d +%Y-%m-%dT%H:%M:%S.000Z)
START_DATE=$(date -u -v-"${DAYS_BACK}"d +%Y-%m-%dT%H:%M:%S.000Z)
else
START_DATE=$(date -u -d "${DAYS_BACK} days ago" +%Y-%m-%dT%H:%M:%S.000Z)
fi
@@ -74,8 +91,8 @@ echo "End date: $END_DATE"
echo ""
# URL encode dates
START_ENC=$(echo "$START_DATE" | sed 's/:/%3A/g')
END_ENC=$(echo "$END_DATE" | sed 's/:/%3A/g')
START_ENC=${START_DATE//:/%3A}
END_ENC=${END_DATE//:/%3A}
echo "=== Fetching CVEs from NVD ==="
@@ -267,7 +284,7 @@ jq --argjson existing "$EXISTING_JSON" '
| if length == 0 then ["openclaw@*", "nanoclaw@*"] else . end
);
[.[] |
[.[] |
select(.cve.id as $id | $existing | index($id) | not) |
{
id: .cve.id,
@@ -281,7 +298,9 @@ jq --argjson existing "$EXISTING_JSON" '
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)
nvd_url: ("https://nvd.nist.gov/vuln/detail/" + .cve.id),
exploitability_score: null,
exploitability_rationale: null
}
]
' "$TEMP_DIR/filtered_cves.json" > "$TEMP_DIR/new_advisories.json"
@@ -296,6 +315,87 @@ if [ "$NEW_COUNT" -eq 0 ]; then
exit 0
fi
echo ""
echo "=== Analyzing Exploitability ==="
# Build CVSS vector lookup for enriched analysis inputs.
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
' "$TEMP_DIR/filtered_cves.json" > "$TEMP_DIR/cvss_vectors.json"
ANALYZED_COUNT=0
FAILED_ANALYSIS=0
while IFS= read -r advisory; do
CVE_ID=$(echo "$advisory" | jq -r '.id')
CVSS_SCORE=$(echo "$advisory" | jq -r '.cvss_score // 0')
CVSS_VECTOR=$(jq -r --arg id "$CVE_ID" '.[$id] // ""' "$TEMP_DIR/cvss_vectors.json")
VULN_TYPE=$(echo "$advisory" | jq -r '.type // ""')
DESCRIPTION=$(echo "$advisory" | jq -r '.description // ""')
REFERENCES=$(echo "$advisory" | jq -c '.references // []')
INPUT_JSON=$(jq -n \
--arg cve_id "$CVE_ID" \
--argjson cvss_score "$CVSS_SCORE" \
--arg cvss_vector "$CVSS_VECTOR" \
--arg type "$VULN_TYPE" \
--arg description "$DESCRIPTION" \
--argjson references "$REFERENCES" \
'{
cve_id: $cve_id,
cvss_score: $cvss_score,
cvss_vector: $cvss_vector,
type: $type,
description: $description,
references: $references
}')
if ANALYSIS=$(echo "$INPUT_JSON" | python3 "$ANALYZER" --json --check-exploits 2>/dev/null); then
echo "$ANALYSIS" > "$TEMP_DIR/exploitability_${CVE_ID}.json"
SCORE=$(echo "$ANALYSIS" | jq -r '.exploitability_score // "unknown"')
echo "$CVE_ID -> $SCORE"
ANALYZED_COUNT=$((ANALYZED_COUNT + 1))
else
echo "$CVE_ID analysis failed; keeping null exploitability fields"
FAILED_ANALYSIS=$((FAILED_ANALYSIS + 1))
fi
done < <(jq -c '.[]' "$TEMP_DIR/new_advisories.json")
if ls "$TEMP_DIR"/exploitability_*.json >/dev/null 2>&1; then
jq -s '.' "$TEMP_DIR"/exploitability_*.json > "$TEMP_DIR/exploitability_analyses.json"
else
echo '[]' > "$TEMP_DIR/exploitability_analyses.json"
fi
jq --slurpfile analyses "$TEMP_DIR/exploitability_analyses.json" '
map(
. as $advisory |
($analyses[0] | map(select(.cve_id == $advisory.id)) | first) as $analysis |
if $analysis then
$advisory + {
exploitability_score: $analysis.exploitability_score,
exploitability_rationale: $analysis.exploitability_rationale,
attack_vector_analysis: $analysis.attack_vector_analysis,
exploit_detection: $analysis.exploit_detection
}
else
$advisory
end
)
' "$TEMP_DIR/new_advisories.json" > "$TEMP_DIR/new_advisories_enriched.json"
mv "$TEMP_DIR/new_advisories_enriched.json" "$TEMP_DIR/new_advisories.json"
echo "Exploitability analysis complete: $ANALYZED_COUNT analyzed, $FAILED_ANALYSIS failed"
echo ""
echo "=== New Advisories ==="
jq -r '.[] | " \(.id) [\(.severity)] - \(.title)"' "$TEMP_DIR/new_advisories.json"
@@ -338,16 +438,9 @@ if jq empty "$TEMP_DIR/updated_feed.json" 2>/dev/null; then
# Update main feed
cp "$TEMP_DIR/updated_feed.json" "$FEED_PATH"
echo "✓ Updated: $FEED_PATH"
# Update skill feed
mkdir -p "$(dirname "$SKILL_FEED_PATH")"
cp "$FEED_PATH" "$SKILL_FEED_PATH"
echo "✓ Updated: $SKILL_FEED_PATH"
# Update public feed for local dev
mkdir -p "$(dirname "$PUBLIC_FEED_PATH")"
cp "$FEED_PATH" "$PUBLIC_FEED_PATH"
echo "✓ Updated: $PUBLIC_FEED_PATH"
# Sync feed mirrors for local skill/public consumers.
sync_feed_to_mirrors "$FEED_PATH" "create"
echo ""
TOTAL_ADVISORIES=$(jq '.advisories | length' "$FEED_PATH")
+3 -3
View File
@@ -76,13 +76,13 @@ fi
# ESLint
echo -e "\n${YELLOW}Running ESLint...${NC}"
if $FIX_MODE; then
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --fix; then
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --ignore-pattern '.auto-claude/**' --fix; then
check_pass "ESLint (with auto-fix)"
else
check_fail "ESLint found unfixable issues"
fi
else
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --max-warnings 0; then
if npx eslint . --ext .ts,.tsx,.js,.jsx,.mjs --ignore-pattern '.auto-claude/**' --max-warnings 0; then
check_pass "ESLint"
else
check_fail "ESLint found issues (run with --fix to auto-fix)"
@@ -190,7 +190,7 @@ print_header "Security"
# Trivy FS Scan
if command -v trivy &> /dev/null; then
echo -e "\n${YELLOW}Running Trivy filesystem scan...${NC}"
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed; then
if trivy fs . --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed --skip-dirs .auto-claude --skip-files clawsec-signing-private.pem; then
check_pass "Trivy filesystem scan"
else
check_fail "Trivy found CRITICAL/HIGH vulnerabilities"