diff --git a/backend/secuscan/cache.py b/backend/secuscan/cache.py index a517b867..7ea520ea 100644 --- a/backend/secuscan/cache.py +++ b/backend/secuscan/cache.py @@ -2,9 +2,8 @@ In-memory cache helpers for API responses. """ -import json -from typing import Any, Optional, Dict import time +from typing import Any, Dict, Optional from .config import settings diff --git a/backend/secuscan/cli.py b/backend/secuscan/cli.py index 1a41be85..11217db4 100644 --- a/backend/secuscan/cli.py +++ b/backend/secuscan/cli.py @@ -2,25 +2,24 @@ SecuScan CLI - Command line interface for running security scans """ -import asyncio import argparse +import asyncio import json import sys -import os -from datetime import datetime from pathlib import Path -from typing import Optional, Dict, Any +from typing import Optional # Add the parent directory to sys.path to allow absolute imports sys.path.append(str(Path(__file__).resolve().parents[2])) -from backend.secuscan.executor import executor -from backend.secuscan.database import init_db, get_db from backend.secuscan.cache import init_cache from backend.secuscan.config import settings -from backend.secuscan.plugins import init_plugins, get_plugin_manager +from backend.secuscan.database import get_db, init_db +from backend.secuscan.executor import executor +from backend.secuscan.plugins import get_plugin_manager, init_plugins from backend.secuscan.reporting import reporting + async def run_scan(target: str, plugin_id: str, output_format: str, output_file: Optional[str] = None): """Initialize components and execute a scan task.""" diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index e05e573c..d82d4304 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -2,12 +2,13 @@ Configuration management for SecuScan backend """ +import base64 +import hashlib from pathlib import Path from typing import Any, List, Optional + from pydantic import field_validator from pydantic_settings import BaseSettings -import base64 -import hashlib PROJECT_ROOT = Path(__file__).resolve().parent.parent diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 8ff8775e..e5bfa524 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -2,13 +2,12 @@ SQLite database access for SecuScan. """ -import asyncio import json -import sqlite3 from pathlib import Path -from typing import Any, Optional, List, Dict +from typing import Dict, List, Optional import aiosqlite + from .config import settings diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 3b45fbbe..140a767f 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -3,29 +3,29 @@ """ import asyncio -from asyncio import subprocess -import uuid import json -import time -from pathlib import Path -from datetime import datetime -from typing import Optional, Dict, Any, List import logging import re +import time +import uuid +from asyncio import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional -from .redaction import redact from .cache import get_cache from .config import settings from .database import get_db -from .plugins import get_plugin_manager from .models import TaskStatus +from .plugins import get_plugin_manager from .ratelimit import concurrent_limiter -from .ratelimit import concurrent_limiter +from .redaction import redact # Modular Scanners from .scanners.port_scanner import PortScanner -from .scanners.web_scanner import WebScanner from .scanners.recon_scanner import ReconScanner +from .scanners.web_scanner import WebScanner +from .validation import validate_timeout MODULAR_SCANNERS = { "port_scanner": PortScanner, @@ -107,6 +107,15 @@ async def create_task( # Merge preset with user inputs (user inputs take precedence) inputs = {**preset_values, **inputs} + # Validate timeout if provided and supported + if "timeout" in inputs and plugin.timeout_config: + timeout_val = int(inputs["timeout"]) + min_t = plugin.timeout_config.get("min", 10) + max_t = plugin.timeout_config.get("max", 3600) + is_valid, err = validate_timeout(timeout_val, min_t, max_t) + if not is_valid: + raise ValueError(err) + # Store task in database db = await get_db() await db.execute( diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 08eb02c2..effd4c2d 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -3,23 +3,23 @@ """ import logging -import sys import shutil -from pathlib import Path +import sys from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles +from .cache import cache as global_cache +from .cache import init_cache from .config import settings -from .cache import init_cache, cache as global_cache -from .database import init_db, db as global_db +from .database import db as global_db +from .database import init_db from .plugins import init_plugins from .routes import router from .workflows import scheduler - # Configure logging logging.basicConfig( level=getattr(logging, settings.log_level), diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index 264363e5..ea36dd85 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -2,10 +2,11 @@ Pydantic models for API requests and responses """ -from typing import Optional, Dict, Any, List from datetime import datetime -from pydantic import BaseModel, Field from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field class SafetyLevel(str, Enum): @@ -66,6 +67,11 @@ class PluginMetadata(BaseModel): fields: List[PluginField] presets: Dict[str, Dict[str, Any]] + timeout_config: Optional[Dict[str, Any]] = Field( + default=None, + description="Configuration for timeout behavior: { 'enabled': bool, 'min': int, 'max': int, 'default': int }" + ) + output: Dict[str, Any] safety: Dict[str, Any] learning: Optional[Dict[str, Any]] = None diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index d224bd4a..413cef93 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -2,18 +2,18 @@ Plugin loader and management system """ +import hashlib +import hmac import json +import logging import os import re -from pathlib import Path -from typing import Any, Dict, Optional, List -import logging import shutil -import hashlib -import hmac +from pathlib import Path +from typing import Any, Dict, List, Optional -from .models import PluginMetadata from .config import settings +from .models import PluginMetadata logger = logging.getLogger(__name__) diff --git a/backend/secuscan/ratelimit.py b/backend/secuscan/ratelimit.py index 464e4ae4..6ad2f493 100644 --- a/backend/secuscan/ratelimit.py +++ b/backend/secuscan/ratelimit.py @@ -2,10 +2,10 @@ Rate limiting for task execution """ +import asyncio from collections import defaultdict from datetime import datetime, timedelta -from typing import Tuple, Dict, List -import asyncio +from typing import Dict, List, Tuple class RateLimiter: diff --git a/backend/secuscan/redaction.py b/backend/secuscan/redaction.py index 35ed1091..5160b109 100644 --- a/backend/secuscan/redaction.py +++ b/backend/secuscan/redaction.py @@ -15,8 +15,8 @@ finding while the secret value itself is hidden. """ -import re import logging +import re from typing import Any logger = logging.getLogger(__name__) diff --git a/backend/secuscan/reporting.py b/backend/secuscan/reporting.py index fb2e8987..a258ef01 100644 --- a/backend/secuscan/reporting.py +++ b/backend/secuscan/reporting.py @@ -4,7 +4,6 @@ import io import json import re -from .redaction import redact, redact_dict from datetime import datetime from functools import lru_cache from typing import Any, Dict, List @@ -12,6 +11,8 @@ from PIL import Image, ImageDraw from xhtml2pdf import pisa +from .redaction import redact, redact_dict + class ReportGenerator: """Handles PDF, HTML, and CSV generation for security audits.""" diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index f1d53063..3e96c9d9 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -2,19 +2,20 @@ API routes for SecuScan backend """ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request -from fastapi.responses import JSONResponse -from typing import Any, Optional, List, Dict, Callable +import asyncio import json import logging import re -import os import shutil import uuid -import asyncio from pathlib import Path +from typing import Any, Dict, List, Optional from urllib.parse import urlparse +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response +from fastapi.responses import JSONResponse + + def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: """Helper to parse stringified JSON fields from SQLite.""" parsed = [] @@ -62,23 +63,23 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: logger = logging.getLogger(__name__) +from sse_starlette.sse import EventSourceResponse + from .cache import get_cache -from .models import ( - TaskCreateRequest, TaskResponse, TaskResult, - PluginListResponse, ErrorResponse -) from .config import settings from .database import get_db -from .plugins import get_plugin_manager, init_plugins from .executor import executor -from .ratelimit import rate_limiter, concurrent_limiter -from .validation import validate_target, validate_task_start_payload +from .models import ( + PluginListResponse, + TaskCreateRequest, +) +from .plugins import get_plugin_manager, init_plugins +from .ratelimit import concurrent_limiter, rate_limiter from .reporting import reporting +from .validation import validate_target, validate_task_start_payload from .vault import VaultCrypto from .workflows import scheduler -from sse_starlette.sse import EventSourceResponse - router = APIRouter(prefix="/api/v1") diff --git a/backend/secuscan/scanners/base.py b/backend/secuscan/scanners/base.py index 121fab22..ced5b374 100644 --- a/backend/secuscan/scanners/base.py +++ b/backend/secuscan/scanners/base.py @@ -1,7 +1,7 @@ +import logging from abc import ABC, abstractmethod -from typing import Dict, Any, List, Optional from datetime import datetime -import logging +from typing import Any, Dict logger = logging.getLogger(__name__) diff --git a/backend/secuscan/scanners/port_scanner.py b/backend/secuscan/scanners/port_scanner.py index b70bbe65..eaed34cc 100644 --- a/backend/secuscan/scanners/port_scanner.py +++ b/backend/secuscan/scanners/port_scanner.py @@ -1,11 +1,9 @@ -import asyncio -import json import re -from typing import Dict, Any, List -from .base import BaseScanner +from typing import Any, Dict, List + from ..plugins import get_plugin_manager -from ..config import settings -from datetime import datetime +from .base import BaseScanner + class PortScanner(BaseScanner): """ diff --git a/backend/secuscan/scanners/recon_scanner.py b/backend/secuscan/scanners/recon_scanner.py index 1fb16c0c..5efc4c42 100644 --- a/backend/secuscan/scanners/recon_scanner.py +++ b/backend/secuscan/scanners/recon_scanner.py @@ -1,12 +1,10 @@ -import asyncio import json +import logging import re -from typing import Dict, Any, List -from .base import BaseScanner +from typing import Any, Dict, List + from ..plugins import get_plugin_manager -from ..config import settings -import logging -from datetime import datetime +from .base import BaseScanner logger = logging.getLogger(__name__) diff --git a/backend/secuscan/scanners/web_scanner.py b/backend/secuscan/scanners/web_scanner.py index 51335f0a..a77c7fb9 100644 --- a/backend/secuscan/scanners/web_scanner.py +++ b/backend/secuscan/scanners/web_scanner.py @@ -1,11 +1,9 @@ -import asyncio -import json import re -from typing import Dict, Any, List -from .base import BaseScanner +from typing import Any, Dict, List + from ..plugins import get_plugin_manager -from ..config import settings -from datetime import datetime +from .base import BaseScanner + class WebScanner(BaseScanner): """ diff --git a/backend/secuscan/validation.py b/backend/secuscan/validation.py index 495edfa3..c1bed128 100644 --- a/backend/secuscan/validation.py +++ b/backend/secuscan/validation.py @@ -2,14 +2,13 @@ Input validation and security checks """ -import re import ipaddress -from typing import Any, Dict, Tuple +import re from fnmatch import fnmatch +from typing import Any, Dict, Tuple from .config import settings - # Blocked network ranges BLOCKED_NETWORKS = [ ipaddress.ip_network("0.0.0.0/8"), # Broadcast @@ -95,6 +94,25 @@ def validate_target(target: str, safe_mode: bool = True) -> Tuple[bool, str]: return True, "" +def validate_timeout(timeout: int, min_val: int = 10, max_val: int = 3600) -> Tuple[bool, str]: + """ + Validate scan timeout value. + + Args: + timeout: Timeout in seconds + min_val: Minimum allowed timeout + max_val: Maximum allowed timeout + + Returns: + Tuple of (is_valid, error_message) + """ + if timeout < min_val: + return False, f"Timeout must be at least {min_val} seconds" + if timeout > max_val: + return False, f"Timeout cannot exceed {max_val} seconds" + return True, "" + + def validate_port(port: int) -> Tuple[bool, str]: """ Validate port number. @@ -315,4 +333,4 @@ def _check_field(key: str, value: Any) -> Tuple[bool, int, str]: f"{settings.task_start_max_field_length} characters.", ) - return True, 0, "" \ No newline at end of file + return True, 0, "" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c7fc0e0..3b874f23 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -74,6 +74,12 @@ export interface PluginSchemaResponse { fields: PluginFieldSchema[] presets: Record> safety: Record + timeout_config?: { + enabled: boolean + min: number + max: number + default: number + } } export interface TaskStartResponse { diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index cfc78824..74e7e811 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' import { getFindings } from '../api' -import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' +import { formatLocaleDate } from '../utils/date' + type Finding = { id: string severity: string @@ -13,7 +14,6 @@ type Finding = { discovered_at: string cvss?: number cve?: string - plugin_id?: string } type FindingStatus = 'new' | 'reviewed' | 'suppressed' @@ -84,22 +84,11 @@ function filterPillClasses(isActive: boolean) { : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30 hover:text-silver-bright' } -const filterLabelClass = 'text-[10px] font-black uppercase tracking-[0.2em] text-silver-bright' -const filterControlClass = - 'h-11 w-full border-2 border-silver-bright/10 bg-charcoal-dark px-3 text-xs font-mono text-silver-bright focus:border-rag-red focus:outline-none' - -type SortMode = 'severity' | 'newest' | 'oldest' | 'target' - export default function Findings() { const [findings, setFindings] = useState([]) const [loading, setLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [filterSeverity, setFilterSeverity] = useState('all') - const [filterTarget, setFilterTarget] = useState('all') - const [filterScanner, setFilterScanner] = useState('all') - const [sortMode, setSortMode] = useState('severity') - const [dateFrom, setDateFrom] = useState('') - const [dateTo, setDateTo] = useState('') const [selectedFindingId, setSelectedFindingId] = useState(null) const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) @@ -140,48 +129,11 @@ export default function Findings() { [findings, reviewState], ) - // Collect unique targets and categories so we can build filter dropdowns. - const uniqueTargets = useMemo(() => { - const seen = new Set() - for (const f of enrichedFindings) { - if (f.target) seen.add(f.target) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - - // plugin_id values serve as the "scanner/tool" filter per issue #43 - const uniqueScanners = useMemo(() => { - const seen = new Set() - for (const f of enrichedFindings) { - if (f.plugin_id) seen.add(f.plugin_id) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - const filteredFindings = useMemo(() => { const query = searchQuery.trim().toLowerCase() - // Compare dates using the *displayed* calendar day in the user's configured - // timezone, not raw UTC timestamps. This way a finding at 2026-05-13T20:00:00Z - // that shows as May 14 in IST correctly matches a From Date of 2026-05-14. - const tz = getCurrentTimeZone() - const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz }) - return enrichedFindings.filter((finding) => { const matchesSeverity = filterSeverity === 'all' || finding.severity === filterSeverity - const matchesTarget = filterTarget === 'all' || finding.target === filterTarget - const matchesScanner = filterScanner === 'all' || finding.plugin_id === filterScanner - - // Date range check — derive the calendar day in the display timezone - if (dateFrom || dateTo) { - const parsed = parseDateSafe(finding.discovered_at) - if (!parsed) return false - // en-CA locale gives us YYYY-MM-DD which matches the value - const displayDay = dateFormatter.format(parsed) - if (dateFrom && displayDay < dateFrom) return false - if (dateTo && displayDay > dateTo) return false - } - const haystack = [ finding.title, finding.target, @@ -194,43 +146,17 @@ export default function Findings() { .join(' ') .toLowerCase() - return matchesSeverity && matchesTarget && matchesScanner && haystack.includes(query) + return matchesSeverity && haystack.includes(query) }) - }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, searchQuery, dateFrom, dateTo]) - - const sortedFindings = useMemo(() => { - const items = [...filteredFindings] - switch (sortMode) { - case 'newest': - return items.sort((a, b) => { - const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 - const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 - return db - da - }) - case 'oldest': - return items.sort((a, b) => { - const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 - const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 - return da - db - }) - case 'target': - return items.sort((a, b) => - (a.target || '').localeCompare(b.target || '') - ) - case 'severity': - default: - // Keep the original severity-group ordering; groupedFindings handles it. - return items - } - }, [filteredFindings, sortMode]) + }, [enrichedFindings, filterSeverity, searchQuery]) const groupedFindings = useMemo( () => severityOrder.map((severity) => ({ severity, - items: sortedFindings.filter((finding) => finding.severity === severity), + items: filteredFindings.filter((finding) => finding.severity === severity), })), - [sortedFindings], + [filteredFindings], ) const selectedFinding = @@ -266,29 +192,6 @@ export default function Findings() { [enrichedFindings, filteredFindings, countsBySeverity], ) - // Derives a flat list of active filter chips from non-default filter state. - const activeFilters = useMemo(() => { - const chips: { key: string; label: string }[] = [] - if (searchQuery.trim()) chips.push({ key: 'search', label: `Search: "${searchQuery.trim()}"` }) - if (filterTarget !== 'all') chips.push({ key: 'target', label: `Target: ${filterTarget}` }) - if (filterScanner !== 'all') chips.push({ key: 'scanner', label: `Scanner: ${filterScanner}` }) - if (sortMode !== 'severity') chips.push({ key: 'sort', label: `Sort: ${sortMode}` }) - if (dateFrom) chips.push({ key: 'from', label: `From: ${dateFrom}` }) - if (dateTo) chips.push({ key: 'to', label: `To: ${dateTo}` }) - return chips - }, [searchQuery, filterTarget, filterScanner, sortMode, dateFrom, dateTo]) - - - function resetAllFilters() { - setFilterSeverity('all') - setFilterTarget('all') - setFilterScanner('all') - setSortMode('severity') - setDateFrom('') - setDateTo('') - setSearchQuery('') - } - function updateFindingStatus(id: string, status: FindingStatus) { setReviewState((current) => ({ ...current, [id]: status })) } @@ -316,70 +219,6 @@ export default function Findings() { } } - function renderFindingRow(finding: Finding & { severity: string; status: FindingStatus }) { - const isSelected = selectedFinding?.id === finding.id - const cfg = severityConfig[finding.severity] - - return ( - - ) - } - return (
@@ -416,162 +255,63 @@ export default function Findings() {
-
-
-
-
- - -
- setSearchQuery(event.target.value)} - placeholder="Title, target, CVE, remediation..." - className={`${filterControlClass} px-4 pr-12 placeholder:text-silver/20`} - /> - - {searchQuery.trim() && ( - - )} -
-
- -
- - {severityOrder.map((severity) => ( +
+
+
+
+ + setSearchQuery(event.target.value)} + placeholder="Title, target, CVE, remediation..." + className="w-full border-2 border-silver-bright/10 bg-charcoal-dark px-4 py-3 text-xs font-mono text-silver-bright placeholder:text-silver/20 focus:border-rag-red focus:outline-none" + /> +
+ +
+ +
- ))} -
-
- -
-
-
- - -
- -
- - -
- -
- - -
- -
- - setDateFrom(e.target.value)} - className={`${filterControlClass} [color-scheme:dark]`} - /> -
- -
- - setDateTo(e.target.value)} - className={`${filterControlClass} [color-scheme:dark]`} - /> + {['critical', 'high', 'medium'].map((severity) => ( + + ))}
+
- +
+ {severityOrder.map((severity) => ( + + ))}
- {/* ── Active filter summary strip ──────────────────────────────────────── - Hidden when all filters are at their default values. */} - {activeFilters.length > 0 && ( -
- - Active Filters - - {activeFilters.map(({ key, label }) => ( - - {label} - - ))} -
- )} -
{loading ? ( @@ -583,7 +323,7 @@ export default function Findings() {

No Findings Match

Adjust filters to reopen the queue.

- ) : sortMode === 'severity' ? ( + ) : ( groupedFindings.map(({ severity, items }) => { if (items.length === 0) return null @@ -602,28 +342,73 @@ export default function Findings() {
- {items.map((finding) => renderFindingRow(finding))} + {items.map((finding) => { + const isSelected = selectedFinding?.id === finding.id + const config = severityConfig[finding.severity] + + return ( + + ) + })}
) }) - ) : ( -
-
-
- -
-

- {sortMode === 'newest' ? 'Newest First' : sortMode === 'oldest' ? 'Oldest First' : 'By Target'} -

-

{sortedFindings.length} visible in queue

-
-
-
-
- {sortedFindings.map((finding) => renderFindingRow(finding))} -
-
)} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 047fffec..74a57e09 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -5,9 +5,9 @@ import { HugeiconsIcon } from '@hugeicons/react' import { Analytics02Icon, Archive02Icon, - Download01Icon, File01Icon, KnightShieldIcon, + Pdf02Icon, Radar02Icon, Refresh01Icon, ScanEyeIcon, @@ -15,8 +15,8 @@ import { UserShield02Icon, } from '@hugeicons/core-free-icons' import { getDashboardSummary, getReports, API_BASE } from '../api' -import { formatDateLong, isWithinDateRange, type DateRange } from '../utils/date' -import { usePreferredExportFormat } from '../hooks/usePreferredExportFormat' +import { formatDateLong } from '../utils/date' +import { getPreference, setPreference } from '../utils/preferences' type Report = { id: string @@ -30,28 +30,24 @@ type Report = { pages: number } -type ReportStatus = 'all' | 'ready' | 'generating' | 'failed' - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { staggerChildren: 0.05 }, - }, + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.05 } + } } const itemVariants = { - hidden: { opacity: 0, scale: 0.95, y: 20 }, - visible: { - opacity: 1, - scale: 1, - y: 0, - transition: { duration: 0.4 }, - }, + hidden: { opacity: 0, scale: 0.95, y: 20 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { duration: 0.4 } + } } -const exportFormats = ['pdf', 'html', 'csv' , 'sarif'] as const - function ReportIcon({ icon, size = 20, @@ -64,45 +60,87 @@ function ReportIcon({ return } +export function isWithinDateRange(dateString: string, range: string): boolean { + if (range === 'all') return true + if (!dateString) return false + + try { + const date = new Date(dateString) + if (isNaN(date.getTime())) return false + + const now = new Date() + const diffMs = now.getTime() - date.getTime() + + if (diffMs < 0) return false // Future dates are invalid + + const diffHours = diffMs / (1000 * 60 * 60) + const diffDays = diffMs / (1000 * 60 * 60 * 24) + + switch (range) { + case '24h': + return diffHours <= 24 + case '7d': + return diffDays <= 7 + case '30d': + return diffDays <= 30 + default: + return false + } + } catch { + return false + } +} + export default function Reports() { const navigate = useNavigate() const [reports, setReports] = useState([]) const [summary, setSummary] = useState({ total_findings: 0, total_assets: 0, critical_findings: 0, high_findings: 0, total_attack_surface: 0 }) - const [selectedType, setSelectedType] = useState('all') - const [selectedStatus, setSelectedStatus] = useState('all') - const [selectedDateRange, setSelectedDateRange] = useState('all') - const [loading, setLoading] = useState(true) + const [selectedType, setSelectedType] = useState(() => getPreference('reports-type-filter', 'all')) + const [selectedStatus, setSelectedStatus] = useState(() => getPreference('reports-status-filter', 'all')) + const [selectedDateRange, setSelectedDateRange] = useState(() => getPreference('reports-date-filter', 'all')) const [error, setError] = useState(null) - const { preferred, savePreference } = usePreferredExportFormat() const fetchReports = () => { - setLoading(true) setError(null) Promise.all([getReports(), getDashboardSummary()]) .then(([reportData, summaryData]: any) => { setReports(reportData.reports || []) setSummary(summaryData || {}) }) - .catch(() => { + .catch((err) => { + console.error('Error fetching reports:', err) setError('Failed to fetch reports') }) - .finally(() => { - setLoading(false) - }) } useEffect(() => { fetchReports() }, []) - const filteredReports = reports.filter((report) => - (selectedType === 'all' || report.type === selectedType) && - (selectedStatus === 'all' || report.status === selectedStatus) && - isWithinDateRange(report.generated_at, selectedDateRange) - ) + // Save preferences when filters change + useEffect(() => { + setPreference('reports-type-filter', selectedType) + }, [selectedType]) + + useEffect(() => { + setPreference('reports-status-filter', selectedStatus) + }, [selectedStatus]) + + useEffect(() => { + setPreference('reports-date-filter', selectedDateRange) + }, [selectedDateRange]) + + const filteredReports = reports + .filter((report) => selectedType === 'all' || report.type === selectedType) + .filter((report) => selectedStatus === 'all' || report.status === selectedStatus) + .filter((report) => isWithinDateRange(report.generated_at, selectedDateRange)) + + // Check if any report with status 'ready' exists + const hasReadyReport = reports.some(report => report.status === 'ready') return (
+ {/* Neo-Brutalist Header */}
@@ -117,297 +155,293 @@ export default function Reports() {

-
- -
+
+ + +
- {/* Loading State */} - {loading && ( -
-
-
-

- Retrieving Archive Data... -

-
- )} - - {/* Error State */} - {!loading && error && ( -
-
-

Archive_Retrieval_Failed

-

{error}

-
- -
- )} - - {!loading && !error && ( - <> - {/* Metrics Row */} -
- {[ + {/* Metrics Row */} +
+ {[ { label: 'Archived_Briefings', val: reports.length, color: 'bg-rag-blue', unit: 'FILES' }, { label: 'Surface_Nodes', val: summary.total_assets || 0, color: 'bg-rag-green', unit: 'NODES' }, { label: 'Aggregate_Anomalies', val: summary.total_findings || 0, color: 'bg-rag-red', unit: 'TRIGGERS' }, { label: 'Archive_Volume', val: '12.4', color: 'bg-rag-amber', unit: 'GB' }, - ].map((m, i) => ( + ].map((m, i) => (
-
- {m.label} -
-
- {m.val} - {m.unit} -
+
+ {m.label} + +
+
+ {m.val} + {m.unit} +
- ))} -
+ ))} +
-
- {/* Filtration Sidebar */} - - {/* Ledger Section */} -
+ {/* Ledger Section */} +
+ {error && ( +
+

Archive_Retrieval_Failed

+

{error}

+ +
+ )}
-

Historical_Ledger

-
- {filteredReports.length} ENTRIES_LOCATED +

Historical_Ledger

+
+ {filteredReports.length} ENTRIES_LOCATED
- - {filteredReports.map((report) => ( - - {/* Status Top Bar */} -
- -
-
- - {report.type}_TYPE - -
- -
-

- {report.name} -

-
-
- -
-
- Findings - {report.findings.toString().padStart(3, '0')} -
-
- Assets - {report.assets.toString().padStart(3, '0')} -
-
- Pages - {report.pages.toString().padStart(3, '0')} -
-
+ + {filteredReports.length === 0 && reports.length > 0 ? ( +
+ filter_alt_off +
+

+ Archive Isolated +

+

+ No reports match the current filter. Adjust classification to view additional dossiers. +

+
+
+ ) : ( + filteredReports.map((report) => ( + + {/* Status Top Bar */} +
-
-
-

TIMESTAMP

-

{formatDateLong(report.generated_at)}

-
-
- - {[...exportFormats].sort((a, b) => - a === preferred ? -1 : b === preferred ? 1 : 0 - ).map((format) => ( - - ))} -
-
-
+
+
+ + {report.type}_TYPE + + +
- {/* Background Hover Icon */} -
-
-
-
- - ))} - - {filteredReports.length === 0 && ( -
-
- )} - +
+

+ {report.name} +

+
+
+ +
+
+ Findings + {report.findings.toString().padStart(3, '0')} +
+
+ Assets + {report.assets.toString().padStart(3, '0')} +
+
+ Pages + {report.pages.toString().padStart(3, '0')} +
+
+ +
+
+

TIMESTAMP

+

{formatDateLong(report.generated_at)}

+
+
+ + + + +
+
+
+ + {/* Background Hover Icon */} +
+
+ +
+
+
+ )) + )} + + {reports.length === 0 && !error && ( +
+ +
+

Retrieving Archive Data

+

System buffer awaiting briefing generation protocols

+
+
+ )} +
-
-
- - )} + +
{/* Tactical Footer */}
-
-
- RESTRICTED_ACCESS_ENCLAVE // SYSTEM_ARCHIVE_DAEMON // {new Date().getFullYear()} -
-
- {[1, 2, 3, 4, 5, 6, 7, 8].map(i =>
)} -
+
+
+ RESTRICTED_ACCESS_ENCLAVE // SYSTEM_ARCHIVE_DAEMON // {new Date().getFullYear()} +
+
+ {[1,2,3,4,5,6,7,8].map(i =>
)} +
) diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 48a07f0b..6328a061 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -1,625 +1,477 @@ -import React, { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { motion, AnimatePresence } from "framer-motion"; -import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks } from "../api"; -import { routePath } from "../routes"; -import { - parseDateSafe, - formatLocaleDate, - formatLocaleTime, -} from "../utils/date"; -import Pagination from "../components/Pagination"; +import React, { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks } from '../api' +import { routePath } from '../routes' +import { parseDateSafe, formatLocaleDate, formatLocaleTime } from '../utils/date' interface Task { - task_id: string; - plugin_id: string; - tool: string; - target: string; - status: "queued" | "running" | "completed" | "failed" | "cancelled"; - created_at: string; - started_at?: string; - completed_at?: string; - duration_seconds?: number; - inputs?: any; - preset?: string; - queue_position?: number; - pending_count?: number; + task_id: string + plugin_id: string + tool: string + target: string + status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' + created_at: string + started_at?: string + completed_at?: string + duration_seconds?: number + inputs?: any + preset?: string } const statusFilters = [ - { value: "all", label: "ALL_OPERATIONS" }, - { value: "running", label: "ACTIVE_EXECUTION" }, - { value: "completed", label: "TERMINATED_SUCCESS" }, - { value: "failed", label: "SYSTEM_FAILURE" }, - { value: "cancelled", label: "MANUAL_ABORT" }, -]; + { value: 'all', label: 'ALL_OPERATIONS' }, + { value: 'running', label: 'ACTIVE_EXECUTION' }, + { value: 'completed', label: 'TERMINATED_SUCCESS' }, + { value: 'failed', label: 'SYSTEM_FAILURE' }, + { value: 'cancelled', label: 'MANUAL_ABORT' } +] const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, - transition: { staggerChildren: 0.1 }, - }, -} as const; + transition: { staggerChildren: 0.1 } + } +} as const const itemVariants = { hidden: { opacity: 0, scale: 0.95, y: 20 }, - visible: { - opacity: 1, - scale: 1, + visible: { + opacity: 1, + scale: 1, y: 0, - transition: { type: "spring", stiffness: 200, damping: 20 } as any, - }, -} as const; + transition: { type: 'spring', stiffness: 200, damping: 20 } as any + } +} as const export default function Scans() { - const navigate = useNavigate(); - const [tasks, setTasks] = useState([]); - const [loading, setLoading] = useState(true); - const [filter, setFilter] = useState("all"); - const [expandedId, setExpandedId] = useState(null); - const [selectedIds, setSelectedIds] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const PAGE_LIMIT = 10; - - useEffect(() => { - loadTasks(); - const interval = setInterval(loadTasks, 5000); - return () => clearInterval(interval); - }, [filter, page]); - - async function loadTasks() { - try { - const params = new URLSearchParams(); - if (filter !== "all") params.set("status", filter); - params.set("page", String(page)); - params.set("per_page", String(PAGE_LIMIT)); - - const res = await fetch(`${API_BASE}/tasks?${params.toString()}`); - const data = await res.json(); - setTasks(data.tasks || []); - if (data.pagination?.total_items !== undefined) { - setTotal(data.pagination.total_items); - } - } catch (err) { - console.error("Failed to load tasks:", err); - } finally { - setLoading(false); + const navigate = useNavigate() + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState('all') + const [expandedId, setExpandedId] = useState(null) + const [selectedIds, setSelectedIds] = useState([]) + + useEffect(() => { + loadTasks() + const interval = setInterval(loadTasks, 5000) + return () => clearInterval(interval) + }, [filter]) + + async function loadTasks() { + try { + const url = filter === 'all' + ? `${API_BASE}/tasks` + : `${API_BASE}/tasks?status=${filter}` + + const res = await fetch(url) + const data = await res.json() + setTasks(data.tasks || []) + } catch (err) { + console.error('Failed to load tasks:', err) + } finally { + setLoading(false) + } } - } - function handleFilterChange(value: string) { - setFilter(value); - setPage(1); - } - async function handleRescan(task: Task) { - try { - const res = await fetch(`${API_BASE}/task/start`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - plugin_id: task.plugin_id, - inputs: task.inputs || {}, - consent_granted: true, - preset: task.preset, - }), - }); - const data = await res.json(); - if (data.task_id) { - navigate(routePath.task(data.task_id)); - } - } catch (err) { - console.error("Rescan failed:", err); + async function handleRescan(task: Task) { + try { + const res = await fetch(`${API_BASE}/task/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + plugin_id: task.plugin_id, + inputs: task.inputs || {}, + consent_granted: true, + preset: task.preset + }) + }) + const data = await res.json() + if (data.task_id) { + navigate(routePath.task(data.task_id)) + } + } catch (err) { + console.error('Rescan failed:', err) + } } - } - async function handleTaskDelete(taskId: string) { - if ( - !window.confirm( - "Are you sure you want to delete this scan record? This will also remove associated findings and reports.", - ) - ) { - return; + async function handleTaskDelete(taskId: string) { + if (!window.confirm('Are you sure you want to delete this scan record? This will also remove associated findings and reports.')) { + return + } + + try { + await deleteTask(taskId) + setTasks(prev => prev.filter(t => t.task_id !== taskId)) + if (expandedId === taskId) setExpandedId(null) + } catch (err) { + console.error('Failed to delete task:', err) + alert('Failed to delete task. It might still be running.') + } } - try { - await deleteTask(taskId); - setTasks((prev) => prev.filter((t) => t.task_id !== taskId)); - if (expandedId === taskId) setExpandedId(null); - } catch (err) { - console.error("Failed to delete task:", err); - alert("Failed to delete task. It might still be running."); + async function handleClearAll() { + if (!window.confirm('CRITICAL: Are you sure you want to PURGE ALL RECORDS? This will wipe all scan history, findings, assets, and reports. This action is irreversible.')) { + return + } + + try { + await clearAllTasks() + setTasks([]) + setSelectedIds([]) + setExpandedId(null) + } catch (err) { + console.error('Failed to clear history:', err) + alert('Failed to clear history. Ensure no tasks are currently running.') + } } - } - async function handleClearAll() { - if ( - !window.confirm( - "CRITICAL: Are you sure you want to PURGE ALL RECORDS? This will wipe all scan history, findings, assets, and reports. This action is irreversible.", - ) - ) { - return; + async function handleBulkDelete() { + if (selectedIds.length === 0) return + if (!window.confirm(`Are you sure you want to delete ${selectedIds.length} selected scan records?`)) { + return + } + + try { + await bulkDeleteTasks(selectedIds) + setTasks(prev => prev.filter(t => !selectedIds.includes(t.task_id))) + setSelectedIds([]) + } catch (err) { + console.error('Bulk delete failed:', err) + alert('Failed to delete some tasks. Ensure they are not currently running.') + } } - try { - await clearAllTasks(); - setTasks([]); - setSelectedIds([]); - setExpandedId(null); - } catch (err) { - console.error("Failed to clear history:", err); - alert("Failed to clear history. Ensure no tasks are currently running."); + function toggleSelection(taskId: string, e: React.MouseEvent) { + e.stopPropagation() + setSelectedIds(prev => + prev.includes(taskId) + ? prev.filter(id => id !== taskId) + : [...prev, taskId] + ) } - } - async function handleBulkDelete() { - if (selectedIds.length === 0) return; - if ( - !window.confirm( - `Are you sure you want to delete ${selectedIds.length} selected scan records?`, - ) - ) { - return; + function toggleSelectAll() { + if (selectedIds.length === tasks.length) { + setSelectedIds([]) + } else { + setSelectedIds(tasks.map(t => t.task_id)) + } } - try { - await bulkDeleteTasks(selectedIds); - setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.task_id))); - setSelectedIds([]); - } catch (err) { - console.error("Bulk delete failed:", err); - alert( - "Failed to delete some tasks. Ensure they are not currently running.", - ); + function formatDuration(seconds?: number) { + if (!seconds) return null + if (seconds < 60) return `${Math.round(seconds)}s` + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + return `${Math.round(seconds / 3600)}h` } - } - function toggleSelection(taskId: string, e: React.MouseEvent) { - e.stopPropagation(); - setSelectedIds((prev) => - prev.includes(taskId) - ? prev.filter((id) => id !== taskId) - : [...prev, taskId], - ); - } - - function toggleSelectAll() { - if (selectedIds.length === tasks.length) { - setSelectedIds([]); - } else { - setSelectedIds(tasks.map((t) => t.task_id)); - } - } - - function formatDuration(seconds?: number) { - if (!seconds) return null; - if (seconds < 60) return `${Math.round(seconds)}s`; - if (seconds < 3600) return `${Math.round(seconds / 60)}m`; - return `${Math.round(seconds / 3600)}h`; - } - - return ( -
- {/* Neo-Brutalist Header */} -
-
-
- Operational_Registry_v10.1 -
-

- Operational{" "} - - Registry - -

-

- Total_Registry_Keys: {total} // SYSTEM_STATUS:{" "} - {loading ? "SYNCING..." : "SYNCED"} - -

-
+ return ( +
+ + {/* Neo-Brutalist Header */} +
+
+
+ Operational_Registry_v10.1 +
+

+ Operational Registry +

+

+ Total_Registry_Keys: {tasks.length} // SYSTEM_STATUS: {loading ? 'SYNCING...' : 'SYNCED'} + +

+
-
-
- - Integrity_Check - - - OPSEC_CLEARANCE_L5 - -
-
-
- - {/* Filtration Block */} -
-
- -
- {statusFilters.map((f) => ( - - ))} -
-
- {tasks.length > 0 && ( - - )} -
- Isolation_Protocol_Active //{" "} - v4_stable -
-
-
- - {/* Timeline Operations Feed */} -
- {/* Vertical Timeline Cable */} -
- - - {tasks.length > 0 ? ( - - {tasks.map((task) => { - const createDate = parseDateSafe(task.created_at); - const startDate = task.started_at - ? parseDateSafe(task.started_at) - : null; - const endDate = task.completed_at - ? parseDateSafe(task.completed_at) - : null; - - return ( - - {/* Timeline Node */} - - -
- setExpandedId( - expandedId === task.task_id ? null : task.task_id, - ) - } +
+
+ Integrity_Check + OPSEC_CLEARANCE_L5 +
+
+
+ + {/* Filtration Block */} +
+
+ +
+ {statusFilters.map(f => ( + + ))} +
+
+ {tasks.length > 0 && ( + + )} +
+ Isolation_Protocol_Active // v4_stable +
+
+
+ + {/* Timeline Operations Feed */} +
+ {/* Vertical Timeline Cable */} +
+ + + {tasks.length > 0 ? ( + + {tasks.map((task) => { + const createDate = parseDateSafe(task.created_at); + const startDate = task.started_at ? parseDateSafe(task.started_at) : null; + const endDate = task.completed_at ? parseDateSafe(task.completed_at) : null; + + return ( + + {/* Timeline Node */} + + +
setExpandedId(expandedId === task.task_id ? null : task.task_id)} + > +
+
+
+
toggleSelection(task.task_id, e)} + className={`w-10 h-10 border-4 border-black flex items-center justify-center transition-all ${ + selectedIds.includes(task.task_id) + ? 'bg-rag-blue text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-1 -translate-y-1' + : 'bg-charcoal-dark text-silver/10 hover:border-rag-blue/40' + }`} + > + + {selectedIds.includes(task.task_id) ? 'check' : 'add'} + +
+ + {task.status} + + + OP_ID_{task.task_id.split('-')[0].toUpperCase()} + +
+ +
+

+ {task.tool} +

+

+ target + {task.target} +

+
+
+ +
+
+

Historical_Execution

+

+ {formatLocaleDate(createDate)} // {formatLocaleTime(createDate)} +

+
+ {task.duration_seconds && ( +
+

{formatDuration(task.duration_seconds)?.toUpperCase()}

+
+ )} +
+
+ + {/* Expandable Details Block */} + + {expandedId === task.task_id && ( + +
+
+
+ Signal_Metadata +
+
+

PLUGIN: {task.plugin_id}

+

SESSION: ENCRYPTED_VTX

+
+
+ +
+
+ Time_Matrix +
+
+
+ In_Lock + {startDate ? formatLocaleTime(startDate) : 'PENDING'} +
+
+ Release + {endDate ? formatLocaleTime(endDate) : 'N/A'} +
+
+
+ +
+ {(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && ( + + )} + {(task.status === 'completed' || task.status === 'failed') && ( + + )} + +
+
+
+ )} +
+
+
+ ); + })} +
+ ) : ( +
+ inventory_2 +
+

Archive Isolated

+

No historical signal streams available for current selection

- )}
-
- - {/* Expandable Details Block */} - - {expandedId === task.task_id && ( - -
-
-
- {" "} - Signal_Metadata -
-
-

- PLUGIN:{" "} - - {task.plugin_id} - -

-

- SESSION:{" "} - - ENCRYPTED_VTX - -

-
-
- -
-
- {" "} - Time_Matrix -
-
-
- - In_Lock - - - {startDate - ? formatLocaleTime(startDate) - : "PENDING"} - -
-
- - Release - - - {endDate - ? formatLocaleTime(endDate) - : "N/A"} - -
+ )} + +
+ + {/* Floating Bulk Action Bar */} + + {selectedIds.length > 0 && ( + +
+
+
{selectedIds.length}
+
+

Records_Selected_For_Pruning

+

Bulk_Action_Protocol_v4_Active

-
- -
- {(task.status === "completed" || - task.status === "failed" || - task.status === "cancelled") && ( - - )} - {(task.status === "completed" || - task.status === "failed") && ( - - )} -
+
+ + -
-
- )} -
-
- - ); - })} - - ) : ( -
- - inventory_2 - -
-

- Archive Isolated -

-

- No historical signal streams available for current selection -

-
-
- )} - - {total > PAGE_LIMIT && ( - setPage((p) => p - 1)} - onNext={() => setPage((p) => p + 1)} - /> - )} - - - {/* Floating Bulk Action Bar */} - - {selectedIds.length > 0 && ( - -
-
-
- {selectedIds.length} +
+ + )} + + + {/* Restricted Footer */} +
+
+ S + SECUSCAN ARCHIVE INTEGRITY PROTOCOL v10.1
-
-

- Records_Selected_For_Pruning -

-

- Bulk_Action_Protocol_v4_Active -

+
+ {[1,2,3,4,5,6,7,8,9,10,11,12].map(i =>
)}
-
-
- - -
-
- - )} - - - {/* Restricted Footer */} -
-
- - S - - SECUSCAN ARCHIVE INTEGRITY PROTOCOL v10.1 -
-
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((i) => ( -
- ))} +
- - - ); + ) } diff --git a/frontend/src/pages/ToolConfig.tsx b/frontend/src/pages/ToolConfig.tsx index 81bf096c..1ccb2827 100644 --- a/frontend/src/pages/ToolConfig.tsx +++ b/frontend/src/pages/ToolConfig.tsx @@ -145,11 +145,16 @@ export default function ToolConfig() { return } + const taskInputs = { ...inputs } + if (schema.timeout_config?.enabled && taskInputs.timeout === undefined) { + taskInputs.timeout = schema.timeout_config.default + } + try { setSubmitting(true) const task = await startTask( plugin.id, - inputs, + taskInputs, plugin.requires_consent ? consentGranted : true, selectedPreset || undefined, ) @@ -391,6 +396,31 @@ export default function ToolConfig() { })} + + {schema.timeout_config?.enabled && ( +
+
+

Execution_Timeout

+ {((inputs.timeout as number) || schema.timeout_config.default)}s +
+
+ setInputs(prev => ({ ...prev, timeout: parseInt(e.target.value) }))} + className="w-full h-2 bg-black rounded-none appearance-none cursor-pointer accent-rag-blue" + /> +
+ Min: {schema.timeout_config.min || 10}s + Max: {schema.timeout_config.max || 300}s +
+
+
+ )} +