diff --git a/.env.example b/.env.example index e930e1a..6332e55 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ NUMVERIFY_API_KEY= LEAK_LOOKUP_API_KEY= +HIBP_API_KEY= + IPINFO_API_KEY= VIRUSTOTAL_API_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index dd87df3..2ce713b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [Unreleased] + +### Added +- **Standard module status enum** (`ok` / `skipped` / `rate_limited` / `error`) in `modules/module_status.py`, with `classify()`, `reason_for()`, and `annotate()` helpers (#61). +- **Graceful degradation for key-dependent modules** — Shodan, VirusTotal, AbuseIPDB, Censys, Leak-Lookup/HIBP, and Telegram now report `skipped` when an API key is absent and `rate_limited` on HTTP 429, instead of failing with a hard error (#61). +- **Per-module status badges in the dashboard** — live progress chips and result cards now render the module status (skipped/rate-limited shown with their reason), and the scan engine propagates the status over WebSocket (#61). +- **`HIBP_API_KEY` config option** — the HIBP breach lookup now reads a real key from the environment and skips up-front when it is absent, instead of always hitting an unauthenticated 401 (#61). + +### Changed +- The scan engine (`web/app.py`) derives each module's status from its result, persists it for the results view, and only caches genuinely successful (`ok`) results so a missing key is not frozen in cache once configured (#61). + +--- + ## [2.3.0] — 2026-06-03 ### Added diff --git a/config.py b/config.py index 6850408..f40cd1d 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,7 @@ NUMVERIFY_API_KEY = os.getenv("NUMVERIFY_API_KEY", "") LEAK_LOOKUP_API_KEY = os.getenv("LEAK_LOOKUP_API_KEY", "") +HIBP_API_KEY = os.getenv("HIBP_API_KEY", "") IPINFO_API_KEY = os.getenv("IPINFO_API_KEY", "") VIRUSTOTAL_API_KEY = os.getenv("VIRUSTOTAL_API_KEY", "") diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 965c83a..9989c95 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -9,10 +9,28 @@ import { ResultsSkeleton } from './ResultsSkeleton'; import { ToolPanels } from './tools/ToolPanels'; import { ScanComparison } from './views/ScanComparison'; import { startScan, getWsUrl, getScan } from '@/lib/api'; -import type { ScanType, ScanStatus, ToolMode, ScanResults as ScanResultsType, ScanMeta } from '@/lib/types'; +import type { ScanType, ScanStatus, ToolMode, ScanResults as ScanResultsType, ScanMeta, LiveModuleStatus } from '@/lib/types'; type View = 'idle' | 'tool' | 'scanning' | 'results' | 'compare'; +const LIVE_STATUSES: readonly LiveModuleStatus[] = ['ok', 'skipped', 'rate_limited', 'error', 'running']; + +function toLiveStatus(status: unknown): LiveModuleStatus { + return typeof status === 'string' && (LIVE_STATUSES as readonly string[]).includes(status) + ? (status as LiveModuleStatus) + : 'ok'; +} + +function moduleDoneLine(msg: { module: string; status?: string; reason?: string; error?: string }): string { + const detail = msg.reason || msg.error; + switch (msg.status) { + case 'skipped': return `⊘ ${msg.module}: skipped${detail ? ` — ${detail}` : ''}`; + case 'rate_limited': return `⏳ ${msg.module}: rate limited${detail ? ` — ${detail}` : ''}`; + case 'error': return `✗ ${msg.module}: ${detail || 'error'}`; + default: return `✓ ${msg.module}`; + } +} + export function App() { const [view, setView] = useState('idle'); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -22,7 +40,7 @@ export function App() { const [scanMeta, setScanMeta] = useState<(ScanMeta & { results: ScanResultsType }) | null>(null); const [progressLog, setProgressLog] = useState([]); const [scanTarget, setScanTarget] = useState(''); - const [moduleStatuses, setModuleStatuses] = useState>({}); + const [moduleStatuses, setModuleStatuses] = useState>({}); const [totalModules, setTotalModules] = useState(0); const [compareIds, setCompareIds] = useState<[string, string] | null>(null); const wsRef = useRef(null); @@ -89,10 +107,7 @@ export function App() { const lines = [...prev]; for (const msg of newMsgs) { if (msg.type === 'module_start') lines.push(`→ ${msg.module}`); - else if (msg.type === 'module_done') { - if (msg.status === 'error') lines.push(`✗ ${msg.module}: ${msg.error || 'error'}`); - else lines.push(`✓ ${msg.module}`); - } + else if (msg.type === 'module_done') lines.push(moduleDoneLine(msg)); } return lines; }); @@ -100,7 +115,7 @@ export function App() { const next = { ...prev }; for (const msg of newMsgs) { if (msg.type === 'module_start') next[msg.module] = 'running'; - else if (msg.type === 'module_done') next[msg.module] = msg.status === 'error' ? 'error' : 'ok'; + else if (msg.type === 'module_done') next[msg.module] = toLiveStatus(msg.status); } return next; }); @@ -145,12 +160,8 @@ export function App() { setModuleStatuses(prev => ({ ...prev, [msg.module]: 'running' })); } else if (msg.type === 'module_done') { - setModuleStatuses(prev => ({ ...prev, [msg.module]: msg.status === 'error' ? 'error' : 'ok' })); - if (msg.status === 'error') { - setProgressLog(prev => [...prev, `✗ ${msg.module}: ${msg.error || 'error'}`]); - } else { - setProgressLog(prev => [...prev, `✓ ${msg.module}`]); - } + setModuleStatuses(prev => ({ ...prev, [msg.module]: toLiveStatus(msg.status) })); + setProgressLog(prev => [...prev, moduleDoneLine(msg)]); } else if (msg.type === '_done') { done = true; diff --git a/frontend/src/components/views/ScanProgress.tsx b/frontend/src/components/views/ScanProgress.tsx index 5ce1d2f..184f071 100644 --- a/frontend/src/components/views/ScanProgress.tsx +++ b/frontend/src/components/views/ScanProgress.tsx @@ -1,18 +1,27 @@ 'use client'; -import { Terminal, Check, X, Loader2, Circle } from 'lucide-react'; +import { Terminal, Check, X, Loader2, Circle, Ban, Hourglass } from 'lucide-react'; import { useTranslations } from '@/lib/i18n'; +import type { LiveModuleStatus } from '@/lib/types'; interface Props { log: string[]; target: string; - moduleStatuses?: Record; + moduleStatuses?: Record; totalModules?: number; } +const STATUS_STYLE: Record = { + ok: { cls: 'text-green border-green/30 bg-green/5', Icon: Check }, + error: { cls: 'text-red border-red/30 bg-red/5', Icon: X }, + skipped: { cls: 'text-text-3 border-border-1 bg-surface-2', Icon: Ban }, + rate_limited: { cls: 'text-yellow border-yellow/30 bg-yellow/5', Icon: Hourglass }, + running: { cls: 'text-blue border-blue/30 bg-blue/5', Icon: Loader2, spin: true }, +}; + export function ScanProgress({ log, target, moduleStatuses = {}, totalModules = 0 }: Props) { const { t } = useTranslations(); - const entries = Object.entries(moduleStatuses); - const completed = entries.filter(([, s]) => s === 'ok' || s === 'error').length; + const entries = Object.entries(moduleStatuses) as [string, LiveModuleStatus][]; + const completed = entries.filter(([, s]) => s !== 'running').length; const cap = Math.max(totalModules, entries.length); const percent = cap > 0 ? Math.min(100, Math.round((completed / cap) * 100)) : 0; @@ -40,13 +49,12 @@ export function ScanProgress({ log, target, moduleStatuses = {}, totalModules = {entries.length > 0 && (
{entries.map(([mod, status]) => { - const cls = status === 'ok' ? 'text-green border-green/30 bg-green/5' - : status === 'error' ? 'text-red border-red/30 bg-red/5' - : 'text-blue border-blue/30 bg-blue/5'; - const Icon = status === 'ok' ? Check : status === 'error' ? X : status === 'running' ? Loader2 : Circle; + const style = STATUS_STYLE[status] ?? { cls: 'text-blue border-blue/30 bg-blue/5', Icon: Circle }; + const Icon = style.Icon; + const label = status === 'rate_limited' ? `${mod} (rate limited)` : status === 'skipped' ? `${mod} (skipped)` : mod; return ( - - + + {mod} ); diff --git a/frontend/src/components/views/ScanResults.tsx b/frontend/src/components/views/ScanResults.tsx index efed08d..6f0eb0a 100644 --- a/frontend/src/components/views/ScanResults.tsx +++ b/frontend/src/components/views/ScanResults.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; import { ExternalLink, Printer, Download, Shield, AlertTriangle, Globe, Server, Lock, User, Clock, Zap, Phone, MessageCircle, Map, GitBranch, Code, Brain, ChevronDown, ChevronUp, SendHorizontal, Mail, Copy, Eye, ShieldAlert, ArrowUp, FileSpreadsheet, FileText, Search } from 'lucide-react'; -import type { ScanResults, ScanMeta, OpsecFinding } from '@/lib/types'; +import type { ScanResults, ScanMeta, OpsecFinding, ModuleStatus, ModuleStatusFields } from '@/lib/types'; import { fetchReportBlob, generateAiSummary, sendAiChat, getMapData, getGraphData } from '@/lib/api'; import { useTranslations } from '@/lib/i18n'; @@ -224,15 +224,76 @@ function DtRow({ label, value }: { label: string; value?: string | number | null ); } -function Card({ title, children }: { title?: string; children: React.ReactNode }) { +function Card({ title, extra, children }: { title?: string; extra?: React.ReactNode; children: React.ReactNode }) { return (
- {title &&
{title}
} + {title && ( +
+ {title} + {extra} +
+ )}
{children}
); } +function modStatus(m?: (ModuleStatusFields & { error?: string | null }) | null): ModuleStatus { + if (!m) return 'ok'; + if (m.status === 'skipped' || m.status === 'rate_limited' || m.status === 'error') return m.status; + if (m.error) return 'error'; + return 'ok'; +} + +const STATUS_BADGE: Record, { label: string; color: string; hint: string }> = { + skipped: { label: 'SKIPPED', color: '#8b949e', hint: 'No API key configured' }, + rate_limited: { label: 'RATE LIMITED', color: '#d29922', hint: 'Provider rate limit reached' }, + error: { label: 'ERROR', color: '#f85149', hint: 'Module failed' }, +}; + +function ModuleStatusBadge({ status, label }: { status: ModuleStatus; label?: string }) { + if (status === 'ok') return null; + const b = STATUS_BADGE[status]; + return ( + + {label ?? b.label} + + ); +} + +function ModuleNotice({ status, reason }: { status: 'skipped' | 'rate_limited'; reason?: string }) { + const b = STATUS_BADGE[status]; + return ( +
+ {reason || b.hint} + {status === 'skipped' && ( + — add the key to .env to enable this module. + )} +
+ ); +} + +function KeyModuleCard({ title, mod, children }: { + title: string; + mod?: (ModuleStatusFields & { error?: string | null }) | null; + children: React.ReactNode; +}) { + if (!mod) return null; + const st = modStatus(mod); + if (st === 'ok') return {children}; + if (st === 'skipped' || st === 'rate_limited') { + return ( + }> + + + ); + } + return null; +} + function CopyIconButton({ onClick, label }: { onClick: () => void; label: string }) { return (