Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ NUMVERIFY_API_KEY=

LEAK_LOOKUP_API_KEY=

HIBP_API_KEY=

IPINFO_API_KEY=

VIRUSTOTAL_API_KEY=
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down
37 changes: 24 additions & 13 deletions frontend/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<View>('idle');
const [sidebarOpen, setSidebarOpen] = useState(false);
Expand All @@ -22,7 +40,7 @@ export function App() {
const [scanMeta, setScanMeta] = useState<(ScanMeta & { results: ScanResultsType }) | null>(null);
const [progressLog, setProgressLog] = useState<string[]>([]);
const [scanTarget, setScanTarget] = useState('');
const [moduleStatuses, setModuleStatuses] = useState<Record<string, 'running' | 'ok' | 'error'>>({});
const [moduleStatuses, setModuleStatuses] = useState<Record<string, LiveModuleStatus>>({});
const [totalModules, setTotalModules] = useState(0);
const [compareIds, setCompareIds] = useState<[string, string] | null>(null);
const wsRef = useRef<WebSocket | null>(null);
Expand Down Expand Up @@ -89,18 +107,15 @@ 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;
});
setModuleStatuses(prev => {
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;
});
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 18 additions & 10 deletions frontend/src/components/views/ScanProgress.tsx
Original file line number Diff line number Diff line change
@@ -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<string, 'running' | 'ok' | 'error'>;
moduleStatuses?: Record<string, LiveModuleStatus>;
totalModules?: number;
}

const STATUS_STYLE: Record<LiveModuleStatus, { cls: string; Icon: typeof Check; spin?: boolean }> = {
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;

Expand Down Expand Up @@ -40,13 +49,12 @@ export function ScanProgress({ log, target, moduleStatuses = {}, totalModules =
{entries.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{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 (
<span key={mod} className={`inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded border ${cls}`}>
<Icon size={10} className={status === 'running' ? 'spin' : ''} />
<span key={mod} title={label} className={`inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded border ${style.cls}`}>
<Icon size={10} className={style.spin ? 'spin' : ''} />
{mod}
</span>
);
Expand Down
Loading