diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index ea8a0a35..a0b9bf80 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -436,14 +436,21 @@ jobs: 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: (.cve.descriptions[] | select(.lang == "en") | .value), - title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)), + 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], @@ -516,16 +523,16 @@ jobs: - name: Transform CVEs to advisories id: transform run: | - # Read existing IDs into a jq-friendly format + # 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." - EXISTING_IDS='[]' + echo '[]' > tmp/existing_ids.json else - EXISTING_IDS=$(cat tmp/existing_ids.txt | jq -R -s 'split("\n") | map(select(length > 0))') + 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 --argjson existing "$EXISTING_IDS" ' + jq --slurpfile existing tmp/existing_ids.json ' def map_severity: if . == null then "medium" elif . >= 9.0 then "critical" @@ -668,16 +675,23 @@ jobs: | if ($from_targets | length) > 0 then $from_targets else ["openclaw", "nanoclaw", "hermes"] 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 | index($id) | not) | + 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: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)), - description: (.cve.descriptions[] | select(.lang == "en") | .value), + 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.", @@ -694,6 +708,14 @@ jobs: 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 ===" @@ -747,11 +769,11 @@ jobs: 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 --argjson rebuilt "$(cat tmp/new_advisories.json)" --arg now "$NOW" ' + jq --slurpfile rebuilt tmp/new_advisories.json --arg now "$NOW" ' .updated = $now | .advisories = ( ((.advisories // []) | map(select((.id // "") | startswith("CVE-") | not))) - + $rebuilt + + ($rebuilt[0] // []) | sort_by(.published) | reverse ) @@ -773,16 +795,16 @@ jobs: ' "$FEED_PATH" > tmp/feed_with_updates.json # Step 2: Add new advisories - jq --argjson new "$(cat tmp/new_advisories.json)" --arg now "$NOW" ' + jq --slurpfile new tmp/new_advisories.json --arg now "$NOW" ' .updated = $now | - .advisories = (.advisories + $new | sort_by(.published) | reverse) + .advisories = (.advisories + ($new[0] // []) | sort_by(.published) | reverse) ' tmp/feed_with_updates.json > tmp/updated_feed.json else - jq -n --argjson advisories "$(cat tmp/new_advisories.json)" --arg now "$NOW" '{ + 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 | sort_by(.published) | reverse) + advisories: (($advisories[0] // []) | sort_by(.published) | reverse) }' > tmp/updated_feed.json fi diff --git a/components/AdvisoryCard.tsx b/components/AdvisoryCard.tsx index e1cb6b0e..ea1ee4fe 100644 --- a/components/AdvisoryCard.tsx +++ b/components/AdvisoryCard.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { ExternalLink, Github } from 'lucide-react'; import { Advisory } from '../types'; +import { AdvisoryPlatformBadge } from './AdvisoryPlatformBadge'; interface AdvisoryCardProps { advisory: Advisory; @@ -65,6 +66,18 @@ export const AdvisoryCard: React.FC = ({ advisory, formatDate {advisory.id}

{advisory.title}

+ + {advisory.platforms && advisory.platforms.length > 0 && ( +
+ {advisory.platforms.map((platform) => ( + + ))} +
+ )} {/* External link - stop propagation to allow clicking without navigating to detail */} {isCommunityReport && advisory.github_issue_url ? ( diff --git a/components/AdvisoryPlatformBadge.tsx b/components/AdvisoryPlatformBadge.tsx new file mode 100644 index 00000000..b76ae240 --- /dev/null +++ b/components/AdvisoryPlatformBadge.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { getPlatformDescriptor } from '../utils/advisoryPlatforms'; + +interface AdvisoryPlatformBadgeProps { + platform: string; + className?: string; +} + +export const AdvisoryPlatformBadge: React.FC = ({ + platform, + className, +}) => { + const { label, classes } = getPlatformDescriptor(platform); + const badgeClasses = ['uppercase tracking-wide', classes, className] + .filter(Boolean) + .join(' '); + + return {label}; +}; diff --git a/pages/AdvisoryDetail.tsx b/pages/AdvisoryDetail.tsx index 1c2cfa64..88fe2698 100644 --- a/pages/AdvisoryDetail.tsx +++ b/pages/AdvisoryDetail.tsx @@ -1,8 +1,10 @@ import React, { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; import { ArrowLeft, ExternalLink, Shield, AlertTriangle, Github, User, Bot } from 'lucide-react'; +import { AdvisoryPlatformBadge } from '../components/AdvisoryPlatformBadge'; import { Footer } from '../components/Footer'; import { Advisory, AdvisoryFeed } from '../types'; +import { getPlatformDescriptor } from '../utils/advisoryPlatforms'; import { ADVISORY_FEED_URL, LEGACY_ADVISORY_FEED_URL, @@ -154,6 +156,13 @@ export const AdvisoryDetail: React.FC = () => { Published {formatDate(advisory.published)} + {advisory.platforms?.map((platform) => ( + + ))}

{advisory.id}

@@ -259,6 +268,12 @@ export const AdvisoryDetail: React.FC = () => {
Published
{formatDate(advisory.published)}
+ {advisory.platforms && advisory.platforms.length > 0 && ( +
+
Platforms
+
{advisory.platforms.map((platform) => getPlatformDescriptor(platform).label).join(', ')}
+
+ )} {/* Reporter info - subtle display for community reports */} {advisory.reporter && ( <> diff --git a/pages/FeedSetup.tsx b/pages/FeedSetup.tsx index 224d1de7..65942c1d 100644 --- a/pages/FeedSetup.tsx +++ b/pages/FeedSetup.tsx @@ -3,7 +3,8 @@ import { Rss, RefreshCw, Loader2, AlertTriangle, ChevronLeft, ChevronRight, Down import { Link } from 'react-router-dom'; import { Footer } from '../components/Footer'; import { AdvisoryCard } from '../components/AdvisoryCard'; -import { Advisory, AdvisoryFeed } from '../types'; +import { Advisory, AdvisoryFeed, AdvisoryPlatformFilter } from '../types'; +import { isCorePlatformSlug, normalizePlatformSlug } from '../utils/advisoryPlatforms'; import { ADVISORY_FEED_URL, LEGACY_ADVISORY_FEED_URL, @@ -12,25 +13,34 @@ import { const ITEMS_PER_PAGE = 9; +type SeverityFilter = 'all' | Advisory['severity']; +type FilterTabOption = { value: T; label: string; active: string; inactive: string }; + const SEVERITY_TABS = [ { value: 'all', label: 'All', active: 'bg-clawd-accent text-white', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' }, { value: 'critical', label: 'Critical', active: 'bg-red-500/20 text-red-400 border-2 border-red-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-red-400/50' }, { value: 'high', label: 'High', active: 'bg-orange-500/20 text-orange-400 border-2 border-orange-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-orange-400/50' }, { value: 'medium', label: 'Medium', active: 'bg-yellow-500/20 text-yellow-400 border-2 border-yellow-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-yellow-400/50' }, { value: 'low', label: 'Low', active: 'bg-blue-500/20 text-blue-400 border-2 border-blue-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-blue-400/50' }, -] as const; +] as const satisfies ReadonlyArray>; const PLATFORM_TABS = [ { value: 'all', label: 'All Platforms', active: 'bg-clawd-accent text-white', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' }, { value: 'openclaw', label: 'OpenClaw', active: 'bg-clawd-accent/20 text-clawd-accent border-2 border-clawd-accent', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-accent/50' }, { value: 'nanoclaw', label: 'NanoClaw', active: 'bg-clawd-secondary/20 text-clawd-secondary border-2 border-clawd-secondary', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-secondary/50' }, -] as const; + { value: 'hermes', label: 'Hermes', active: 'bg-emerald-500/20 text-emerald-300 border-2 border-emerald-400', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-emerald-400/50' }, + { value: 'other', label: 'Other', active: 'bg-clawd-600/40 text-gray-100 border-2 border-clawd-500', inactive: 'bg-clawd-800 text-gray-400 border border-clawd-700 hover:border-clawd-500/50' }, +] as const satisfies ReadonlyArray>; -const FilterTabs: React.FC<{ - tabs: ReadonlyArray<{ value: string; label: string; active: string; inactive: string }>; - selected: string; - onSelect: (value: string) => void; -}> = ({ tabs, selected, onSelect }) => ( +const FilterTabs = ({ + tabs, + selected, + onSelect, +}: { + tabs: ReadonlyArray>; + selected: T; + onSelect: (value: T) => void; +}) => (
{tabs.map(({ value, label, active, inactive }) => (