import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card } from '../components/Card'; import { Badge } from '../components/Badge'; import { ScannerSkeleton } from '../components/Skeleton'; import { useConfig, wpApiFetch, externalApiFetch } from '../hooks/useApi'; import { useNotify } from '../hooks/useNotify'; import type { MonitoredPlugin, Vulnerability, VulnerabilityStatus } from '@cra-compliance/types'; interface InstalledPlugin { slug: string; name: string; version: string; author: string; } export function Scanner() { const config = useConfig(); const isPremium = config.plan !== 'free'; if (!isPremium) { return ; } return ; } function PremiumScanner() { const queryClient = useQueryClient(); const { confirm, NotifyPortal } = useNotify(); const [showAddPlugin, setShowAddPlugin] = useState(false); const [addMode, setAddMode] = useState<'installed' | 'slug'>('installed'); const [manualSlug, setManualSlug] = useState(''); const [manualName, setManualName] = useState(''); const [manualVersion, setManualVersion] = useState(''); const [selectedPlugins, setSelectedPlugins] = useState>(new Set()); const [selectedToAdd, setSelectedToAdd] = useState>(new Set()); const [scanning, setScanning] = useState>(new Set()); const [statusFilter, setStatusFilter] = useState<'all' | VulnerabilityStatus | 'open_active'>('open_active'); const [error, setError] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const [copiedSummary, setCopiedSummary] = useState(false); const [selectedVulnIds, setSelectedVulnIds] = useState>(new Set()); const toggleGroup = (key: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); return next; }); }; // ── Data fetching ──────────────────────────────────────────────────────────── const pluginsQuery = useQuery({ queryKey: ['monitored-plugins'], queryFn: async () => { const res = await externalApiFetch<{ data: MonitoredPlugin[] }>('/api/plugins'); return res.data || []; }, }); const vulnsQuery = useQuery({ queryKey: ['vulnerabilities'], queryFn: async () => { const res = await externalApiFetch<{ data: Vulnerability[] }>('/api/vulnerabilities'); return res.data || []; }, }); const installedQuery = useQuery({ queryKey: ['installed-plugins'], queryFn: async () => { const res = await wpApiFetch<{ data: InstalledPlugin[] }>('installed-plugins'); return res.data || []; }, }); // ── Mutations ──────────────────────────────────────────────────────────────── const addPluginsMutation = useMutation({ mutationFn: async (toAdd) => { await Promise.all( toAdd.map((p) => externalApiFetch('/api/plugins', { method: 'POST', body: JSON.stringify({ slug: p.slug, name: p.name, version: p.version }), }) ) ); }, onSuccess: async () => { setSelectedToAdd(new Set()); setShowAddPlugin(false); await queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }); }, onError: (err) => setError(err.message || 'Failed to add plugins'), }); const addManualPluginMutation = useMutation({ mutationFn: async () => { const slug = manualSlug.trim().toLowerCase(); if (!slug) throw new Error('Slug is required'); if (!/^[a-z0-9-]+$/.test(slug)) throw new Error('Slug must contain only lowercase letters, numbers, and hyphens'); await externalApiFetch('/api/plugins', { method: 'POST', body: JSON.stringify({ slug, name: manualName.trim() || slug, version: manualVersion.trim() || '0.0.0', }), }); }, onSuccess: async () => { setManualSlug(''); setManualName(''); setManualVersion(''); setShowAddPlugin(false); await queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }); }, onError: (err) => setError(err.message || 'Failed to add plugin'), }); const removePluginMutation = useMutation({ mutationFn: async (pluginId) => { await externalApiFetch('/api/plugins', { method: 'DELETE', body: JSON.stringify({ plugin_id: pluginId }), }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }); await queryClient.invalidateQueries({ queryKey: ['vulnerabilities'] }); }, onError: (err) => setError(err.message || 'Failed to remove plugin'), }); const updateVulnMutation = useMutation({ mutationFn: async ({ id, status }) => { await externalApiFetch('/api/vulnerabilities', { method: 'PATCH', body: JSON.stringify({ vulnerability_id: id, status }), }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['vulnerabilities'] }); await queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }); }, onError: (err) => setError(err.message || 'Failed to update status'), }); const bulkUpdateMutation = useMutation({ mutationFn: async ({ ids, status }) => { await Promise.all( ids.map((id) => externalApiFetch('/api/vulnerabilities', { method: 'PATCH', body: JSON.stringify({ vulnerability_id: id, status }), }) ) ); }, onSuccess: async () => { setSelectedVulnIds(new Set()); await queryClient.invalidateQueries({ queryKey: ['vulnerabilities'] }); await queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }); }, onError: (err) => setError(err.message || 'Failed to bulk update'), }); // ── Scan helpers ───────────────────────────────────────────────────────────── const doScan = async (pluginId: string) => { setScanning((prev) => new Set(prev).add(pluginId)); setError(null); try { await externalApiFetch('/api/scan', { method: 'POST', body: JSON.stringify({ plugin_id: pluginId }), }); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }), queryClient.invalidateQueries({ queryKey: ['vulnerabilities'] }), ]); } catch (err) { setError(err instanceof Error ? err.message : 'Scan failed'); } finally { setScanning((prev) => { const n = new Set(prev); n.delete(pluginId); return n; }); } }; const triggerBulkScan = async () => { const ids = Array.from(selectedPlugins); setScanning(new Set(ids)); setError(null); try { await Promise.all( ids.map((id) => externalApiFetch('/api/scan', { method: 'POST', body: JSON.stringify({ plugin_id: id }), }).catch(() => null) ) ); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['monitored-plugins'] }), queryClient.invalidateQueries({ queryKey: ['vulnerabilities'] }), ]); } finally { setScanning(new Set()); setSelectedPlugins(new Set()); } }; const startIncidentForVuln = async (vuln: Vulnerability) => { setError(null); try { const res = await externalApiFetch<{ data: { id: string } }>('/api/dashboard', { method: 'POST', body: JSON.stringify({ action: 'incidents_start', vulnerability_id: vuln.id, plugin_id: vuln.plugin_id, actively_exploited: vuln.severity === 'critical' || vuln.severity === 'high', }), }); // Store the new incident ID so the Incident Center auto-selects it if (res.data?.id) { sessionStorage.setItem('cra_selected_incident', res.data.id); } window.location.hash = '#/incidents'; } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start incident'); } }; const exportCsv = (vulns: Vulnerability[]) => { if (vulns.length === 0) return; const headers = ['title', 'severity', 'status', 'cve_id', 'plugin_id', 'fixed_in_version', 'discovered_at']; const rows = vulns.map((v) => headers.map((h) => { const val = v[h as keyof Vulnerability] ?? ''; const str = String(val); return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str; }).join(',') ); const csv = [headers.join(','), ...rows].join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `vulnerabilities-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); }; const copyCveSummary = (vulns: Vulnerability[]) => { if (vulns.length === 0) return; const open = vulns.filter((v) => v.status === 'open' || v.status === 'acknowledged' || v.status === 'in_progress'); if (open.length === 0) { void navigator.clipboard.writeText('No open vulnerabilities.'); return; } const lines = open.map((v) => { const plugin = pluginById.get(v.plugin_id); const cve = v.cve_id ? ` (${v.cve_id})` : ''; const fix = v.fixed_in_version ? ` - fix: v${v.fixed_in_version}` : ' - no fix yet'; const pluginName = plugin ? plugin.name : v.plugin_id; return `[${v.severity.toUpperCase()}] ${pluginName}: ${v.title}${cve}${fix}`; }); void navigator.clipboard.writeText(lines.join('\n')); }; // ── Derived state ──────────────────────────────────────────────────────────── const plugins = pluginsQuery.data || []; const allVulns = vulnsQuery.data || []; const installed = installedQuery.data || []; const monitoredSlugs = new Set(plugins.map((p) => p.slug)); const availableToAdd = installed.filter((p) => !monitoredSlugs.has(p.slug)); // Map installed versions by slug for version mismatch detection const installedVersionBySlug = new Map(installed.map((p) => [p.slug, p.version])); const pluginById = new Map(plugins.map((p) => [p.id, p])); const filteredVulns = allVulns.filter((v) => { if (statusFilter === 'open_active') return v.status === 'open' || v.status === 'acknowledged' || v.status === 'in_progress'; if (statusFilter === 'all') return true; return v.status === statusFilter; }); // Within each group: sort by severity then date desc so newest critical appears first const SEVERITY_RANK: Record = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; // Group by plugin first (preserving API order), then sort within each group const vulnsByPlugin = new Map(); for (const v of filteredVulns) { const list = vulnsByPlugin.get(v.plugin_id) || []; list.push(v); vulnsByPlugin.set(v.plugin_id, list); } for (const [, vulns] of vulnsByPlugin) { vulns.sort((a, b) => { const rankDiff = (SEVERITY_RANK[a.severity] ?? 5) - (SEVERITY_RANK[b.severity] ?? 5); if (rankDiff !== 0) return rankDiff; return new Date(b.discovered_at ?? 0).getTime() - new Date(a.discovered_at ?? 0).getTime(); }); } // Sort plugin groups by the most recently discovered vuln in each group (newest first) const sortedPluginGroups = Array.from(vulnsByPlugin.entries()).sort(([, aVulns], [, bVulns]) => { const aNewest = Math.max(...aVulns.map((v) => new Date(v.discovered_at ?? 0).getTime())); const bNewest = Math.max(...bVulns.map((v) => new Date(v.discovered_at ?? 0).getTime())); return bNewest - aNewest; }); const isLoading = pluginsQuery.isLoading || vulnsQuery.isLoading; const nextScanLabel = (() => { const now = new Date(); const next = new Date(now); next.setUTCHours(3, 0, 0, 0); if (next <= now) next.setUTCDate(next.getUTCDate() + 1); const diffMs = next.getTime() - now.getTime(); const diffH = Math.floor(diffMs / 3600000); const diffM = Math.floor((diffMs % 3600000) / 60000); return diffH > 0 ? `in ${diffH}h ${diffM}m` : `in ${diffM}m`; })(); // ── Stats ──────────────────────────────────────────────────────────────────── const criticalCount = allVulns.filter((v) => v.severity === 'critical' && v.status !== 'resolved' && v.status !== 'false_positive').length; const highCount = allVulns.filter((v) => v.severity === 'high' && v.status !== 'resolved' && v.status !== 'false_positive').length; const secureCount = plugins.filter((p) => p.status === 'secure').length; const vulnerableCount = plugins.filter((p) => p.status === 'vulnerable').length; if (isLoading) { return ; } return (

Vulnerability Scanner

Next scheduled scan {nextScanLabel} · 3:00 AM UTC daily

{/* Explainer: what to do with this information */}
What to do with vulnerabilities

Under the EU Cyber Resilience Act (CRA), you must monitor your plugin for known vulnerabilities and act on them. For each vulnerability found:

  1. Update or patch the plugin if a fix is available (shown as "Fix: vX.X.X"). Mark it Resolved once done.
  2. Acknowledge it if you have reviewed it and it does not affect your deployment (e.g. a feature you don't use).
  3. Start an Incident for critical/high severity vulnerabilities that are actively exploited — CRA Article 14 requires you to report these to your national CSIRT within 24 hours of awareness.

Historical vulnerabilities in older versions are shown for awareness but are less urgent if you are already on a patched version.

{/* Error banner */} {error && (
{error}
)} {/* Stats row */} {plugins.length > 0 && (
{plugins.length} Monitored
{secureCount} Secure
0 ? 'cra-scanner-stat-danger' : ''}`}> {vulnerableCount} Vulnerable
{criticalCount > 0 && (
{criticalCount} Critical
)} {highCount > 0 && (
{highCount} High
)}
)} {/* Add Plugin panel */} {showAddPlugin && ( 0 ? ( ) : undefined } >
{addMode === 'installed' ? ( availableToAdd.length === 0 ? (

All installed plugins are already monitored.

) : ( <>
setSelectedToAdd( selectedToAdd.size === availableToAdd.length ? new Set() : new Set(availableToAdd.map((p) => p.slug)) ) } /> Select all ({availableToAdd.length} available)
{availableToAdd.map((p) => (
setSelectedToAdd((prev) => { const n = new Set(prev); n.has(p.slug) ? n.delete(p.slug) : n.add(p.slug); return n; }) } />
{p.name} v{p.version} · {p.author}
))}
) ) : (

Add any WordPress.org plugin by its slug. Useful for monitoring plugins your plugin depends on, even if they are not installed on this site.

setManualSlug(e.target.value)} /> The slug is the last part of the wordpress.org/plugins/YOUR-SLUG URL.
setManualName(e.target.value)} />
setManualVersion(e.target.value)} />
)}
)} {/* Monitored Plugins */} 0 ? ` (${plugins.length})` : ''}`} actions={ selectedPlugins.size > 0 ? ( ) : ( ) } > {plugins.length === 0 ? (

No plugins being monitored yet.

) : ( <>
setSelectedPlugins( selectedPlugins.size === plugins.length ? new Set() : new Set(plugins.map((p) => p.id)) ) } /> Select all
{plugins.map((plugin) => { const pluginVulns = allVulns.filter( (v) => v.plugin_id === plugin.id && v.status !== 'resolved' && v.status !== 'false_positive' ); const isScanning = scanning.has(plugin.id); return (
setSelectedPlugins((prev) => { const n = new Set(prev); n.has(plugin.id) ? n.delete(plugin.id) : n.add(plugin.id); return n; }) } />
{plugin.name} v{plugin.version} · {plugin.slug} {(() => { const installedV = installedVersionBySlug.get(plugin.slug); if (installedV && installedV !== plugin.version) { return ( ⚠ Monitoring v{plugin.version}, installed v{installedV} ); } return null; })()}
{plugin.status === 'vulnerable' ? `${pluginVulns.length} vuln${pluginVulns.length !== 1 ? 's' : ''}` : plugin.status} {plugin.last_scanned_at ? ( Scanned {timeAgo(plugin.last_scanned_at)} ) : ( Never scanned )}
); })}
)}
{/* Vulnerabilities */} 0 ? `Vulnerabilities (${filteredVulns.length}${statusFilter !== 'all' ? ' shown' : ''})` : 'Vulnerabilities' } actions={
{filteredVulns.length > 0 && ( <> )}
} > {allVulns.length === 0 ? (

{plugins.length === 0 ? 'Add plugins to monitor, then run a scan to find vulnerabilities.' : 'No vulnerabilities found. Run a scan to check your plugins.'}

) : filteredVulns.length === 0 ? (

No vulnerabilities match the current filter.

) : ( <> {/* Bulk action bar */}
{selectedVulnIds.size > 0 && (
)}
{sortedPluginGroups.map(([pluginId, vulns]) => { const plugin = pluginById.get(pluginId); const criticalCount = vulns.filter((v) => v.severity === 'critical').length; const highCount = vulns.filter((v) => v.severity === 'high').length; const isCollapsed = collapsedGroups.has(pluginId); return (
{plugin && ( )} {!isCollapsed && vulns.map((vuln) => ( setSelectedVulnIds((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }) } onStatusChange={(status) => void updateVulnMutation.mutate({ id: vuln.id, status })} onStartIncident={() => void startIncidentForVuln(vuln)} busy={updateVulnMutation.isPending || bulkUpdateMutation.isPending} /> ))}
); })}
)}
); } interface VulnRowProps { vuln: Vulnerability; selected: boolean; onSelect: (id: string) => void; onStatusChange: (status: VulnerabilityStatus) => void; onStartIncident: () => void; busy: boolean; } function VulnRow({ vuln, selected, onSelect, onStatusChange, onStartIncident, busy }: VulnRowProps) { const severityVariant = vuln.severity === 'critical' ? 'danger' : vuln.severity === 'high' ? 'warning' : vuln.severity === 'medium' ? 'warning' : 'info'; const actionHint = vuln.fixed_in_version ? `Update to v${vuln.fixed_in_version} then mark Resolved` : vuln.severity === 'critical' || vuln.severity === 'high' ? 'No fix yet — start an incident if actively exploited; otherwise acknowledge' : 'No fix available — acknowledge if reviewed and acceptable risk'; return (
onSelect(vuln.id)} style={{ flexShrink: 0 }} /> {vuln.severity.toUpperCase()} {vuln.title}
{vuln.cve_id && ( {vuln.cve_id} )} {vuln.cvss_score != null && CVSS {vuln.cvss_score}} {vuln.fixed_in_version ? Fix: v{vuln.fixed_in_version} : No fix yet}

{actionHint}

{vuln.status !== 'resolved' && vuln.status !== 'false_positive' && ( )}
); } function ScannerUpsell() { return (

Vulnerability Scanner

Automated daily scanning of your WordPress plugins against the WPScan CVE database.

Know about vulnerabilities before your users do

The EU Cyber Resilience Act (CRA) requires you to actively monitor your plugin for known vulnerabilities and act on them promptly. Article 13 obliges you to track CVEs; Article 14 requires you to report actively-exploited vulnerabilities to your national CSIRT within 24 hours. The Scanner automates the monitoring so you are never caught off-guard.

Upgrade to unlock Scanner

Basic from $19/month

Daily CVE scans via WPScan

Every 24 hours, your monitored plugins are checked against the WPScan vulnerability database — the same source used by security researchers and hosting providers worldwide. You are notified the same day a new CVE is published that affects your plugin.

Instant email alerts on new findings

Critical and high-severity vulnerabilities trigger an immediate email alert so you can start your CRA Article 14 response clock. Your 24-hour early warning deadline starts the moment you become aware — the Scanner makes sure that moment is as early as possible.

Per-vulnerability workflow

Each finding gets its own status: Open → Acknowledged → In Progress → Resolved. Acknowledging shows regulators you reviewed it; resolving closes the loop. The full audit trail is exportable as evidence for CSIRT submissions.

One-click incident escalation

When a vulnerability is actively exploited, escalate directly to the Incident Center — your CRA Article 14 notification drafts are pre-filled with the CVE ID, CVSS score, and impact details. You go from "Scanner found it" to "CSIRT notified" in minutes.

📋

Compliance-ready CSV export

Export your full vulnerability history as a CSV for audits, insurance questionnaires, or enterprise customer due-diligence requests. Shows exactly which CVEs you found, when, and how you handled them — the paper trail CRA auditors look for.

🔍

Monitor any WordPress.org plugin

Not just your own plugin — monitor every plugin your product depends on or bundles. If a dependency has a CVE, you need to know. Add plugins by slug to track the entire supply chain that falls under your CRA responsibility.

Which CRA articles does this help with?

Article 13 Manufacturers must monitor for vulnerabilities during the expected product lifetime and address them without undue delay.
Article 14 Actively exploited vulnerabilities must be reported to ENISA/national CSIRTs within 24 hours of awareness.
Annex I Products must be delivered without known exploitable vulnerabilities and document their security properties.
Basic
$19/month
  • 5 plugins monitored
  • Daily vulnerability scans
  • Email alerts on new CVEs
  • Vulnerability status workflow
  • CSV export
  • Incident Center access
Get Basic
Most popular
Pro
$45/month
  • 20 plugins monitored
  • Daily vulnerability scans
  • Email alerts on new CVEs
  • Vulnerability status workflow
  • CSV export + evidence packs
  • Incident Center access
  • Dashboard compliance score
  • Priority support
Get Pro
); } function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; }