mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-13 21:48:03 +03:00
073e771b73
* 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
282 lines
8.1 KiB
Bash
Executable File
282 lines
8.1 KiB
Bash
Executable File
#!/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"
|