diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a97f397f..b1a04307f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,60 @@ jobs: steps: - uses: actions/checkout@v4 with: +<<<<<<< HEAD + python-version: "3.11" + - name: Install backend system dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config + - name: Install backend development dependencies + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + - name: Run backend lint baseline + run: ruff check backend testing/backend + + backend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install backend system dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + - name: Run backend tests + run: pytest testing/backend -q + + frontend-checks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + run: npm ci + - name: Run frontend TypeScript typecheck + run: npm run typecheck + - name: Note TypeScript typecheck in job summary + if: always() + run: | + echo "Frontend TypeScript typecheck: npm run typecheck" >> "$GITHUB_STEP_SUMMARY" + - name: Run frontend quality gate + run: npm run quality + - name: Run unit tests + run: npm run test + - name: Build frontend + run: npm run build +======= fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -29,6 +83,7 @@ jobs: env: GITHUB_EVENT_NAME: ${{ github.event_name }} run: python3 scripts/select_tests.py +>>>>>>> upstream/main formatting-hygiene: needs: detect-changes diff --git a/0 b/0 new file mode 100644 index 000000000..e69de29bb diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index e780a0395..35da64e13 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -4,4 +4,7 @@ httpx>=0.28.1 ruff>=0.15.12 pytest-asyncio>=0.24.0 anyio>=4.0.0 +<<<<<<< HEAD +======= trio>=0.27.0 +>>>>>>> upstream/main diff --git a/backend/secuscan/cli.py b/backend/secuscan/cli.py index 34ce0a598..e32bbba2a 100644 --- a/backend/secuscan/cli.py +++ b/backend/secuscan/cli.py @@ -48,10 +48,16 @@ async def run_scan(target: str, plugin_id: str, output_format: str, output_file: return 1 # Create task +<<<<<<< HEAD + inputs = {"target": target} + try: + task_id = await executor.create_task(plugin_id, inputs, consent_granted=True) +======= safe_mode = bool(settings.safe_mode_default) inputs = {"target": target, "safe_mode": safe_mode} try: task_id = await executor.create_task(plugin_id, inputs, safe_mode=safe_mode, consent_granted=True) +>>>>>>> upstream/main except Exception as e: print(f"Error creating task: {e}") return 1 diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index 505d8e04f..26d81ba0c 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -91,10 +91,13 @@ class Settings(BaseSettings): task_start_max_field_length: int = 1_000 # max chars per string input value task_start_max_array_length: int = 50 # max items in any list/multiselect input +<<<<<<< HEAD +======= # Parser sandbox limits parser_sandbox_timeout_seconds: int = 30 parser_sandbox_max_output_bytes: int = 8 * 1024 * 1024 # 8 MB +>>>>>>> upstream/main # Logging log_level: str = "INFO" log_file: str = str(PROJECT_ROOT / "logs" / "secuscan.log") @@ -151,4 +154,4 @@ def ensure_directories(self) -> None: # Global settings instance -settings = Settings() +settings = Settings() \ No newline at end of file diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 3dda63ebc..f160af178 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -18,6 +18,11 @@ from .config import settings from .database import get_db from .plugins import get_plugin_manager +<<<<<<< HEAD +from .models import TaskStatus +from .ratelimit import concurrent_limiter +from .ratelimit import concurrent_limiter +======= from .models import TaskStatus, ScanPhase from .ratelimit import concurrent_limiter from .risk_scoring import compute_risk_score, compute_risk_factors @@ -58,6 +63,7 @@ def _validate_risk_fields(finding: dict) -> None: ae = finding.get("asset_exposure") if ae is not None and ae.lower() not in ("critical", "high", "medium", "low"): raise ValueError(f"asset_exposure must be one of critical/high/medium/low, got {ae}") +>>>>>>> upstream/main # Modular Scanners from .scanners.port_scanner import PortScanner @@ -465,6 +471,8 @@ async def execute_task(self, task_id: str): await self._invalidate_cached_views() raise # let asyncio complete the cancellation +<<<<<<< HEAD +======= except CapabilityDeniedError as e: logger.warning("Task %s blocked by capability policy: %s", task_id, e) duration = (time.time() - start_time) if "start_time" in locals() else 0 @@ -499,6 +507,7 @@ async def execute_task(self, task_id: str): task_id=task_id, ) +>>>>>>> upstream/main except Exception as e: logger.error(f"Task {task_id} failed: {e}", exc_info=True) @@ -741,6 +750,10 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: "exit_code": task_row["exit_code"], "error_message": task_row["error_message"], "preset": task_row["preset"], +<<<<<<< HEAD + "inputs": json.loads(task_row["inputs_json"] or "{}"), +======= +>>>>>>> upstream/main "queue_position": queue_position, "pending_count": pending_count, } diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index 8e158ccbb..d4300b9f0 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -211,6 +211,8 @@ def compute_plugin_digest(metadata_file: Path, parser_file: Path) -> str: return hashlib.sha256(f"{metadata_digest}:{parser_digest}".encode("utf-8")).hexdigest() +<<<<<<< HEAD +======= def verify_parser_at_exec_time( self, plugin: PluginMetadata, plugin_dir: Path ) -> bool: @@ -260,6 +262,7 @@ def verify_parser_at_exec_time( return True +>>>>>>> upstream/main def get_plugin(self, plugin_id: str) -> Optional[PluginMetadata]: """Get plugin by ID""" return self.plugins.get(plugin_id) diff --git a/backend/secuscan/redaction.py b/backend/secuscan/redaction.py index 155949487..5b22674ee 100644 --- a/backend/secuscan/redaction.py +++ b/backend/secuscan/redaction.py @@ -202,6 +202,8 @@ def redact_dict(data: dict[str, Any]) -> dict[str, Any]: return result +<<<<<<< HEAD +======= # Keys whose values are unconditionally redacted in task inputs regardless of # value format. Matched case-insensitively against the full key name. _SENSITIVE_INPUT_KEYS: frozenset[str] = frozenset({ @@ -258,6 +260,7 @@ def redact_inputs(inputs: dict[str, Any]) -> dict[str, Any]: return result +>>>>>>> upstream/main # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 1fba3ee2c..0a7f3b677 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -2,7 +2,12 @@ API routes for SecuScan backend """ +<<<<<<< HEAD +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Body +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request +======= from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends, Body, Query +>>>>>>> upstream/main from fastapi.responses import JSONResponse from typing import Any, Optional, List, Dict, Callable import json @@ -115,6 +120,10 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: from .database import get_db from .plugins import get_plugin_manager, init_plugins from .executor import executor +<<<<<<< HEAD +from .ratelimit import rate_limiter, concurrent_limiter +from .validation import validate_target, validate_task_start_payload +======= from .redaction import redact_inputs from .ratelimit import ( rate_limiter, concurrent_limiter, @@ -123,6 +132,7 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: resolve_client_identity, ) from .validation import validate_target, validate_task_start_payload, validate_url +>>>>>>> upstream/main from .reporting import reporting from .vault import VaultCrypto from .workflows import scheduler @@ -194,6 +204,8 @@ async def invalidate_view_cache(): await cache.delete_prefix(prefix) +<<<<<<< HEAD +======= def iter_raw_output_chunks(path: str, chunk_size: int = SSE_RAW_OUTPUT_CHUNK_SIZE): """Yield raw output in bounded chunks for completed-task SSE replay.""" with open(path, "r", encoding="utf-8", errors="replace") as output_file: @@ -204,6 +216,7 @@ def iter_raw_output_chunks(path: str, chunk_size: int = SSE_RAW_OUTPUT_CHUNK_SIZ yield chunk +>>>>>>> upstream/main def _report_generation_error_response(task_id: str, report_format: str) -> JSONResponse: logger.exception("Report generation failed for task_id=%s format=%s", task_id, report_format) return JSONResponse( @@ -253,7 +266,11 @@ async def get_plugins_summary(): category_counts: Dict[str, int] = {} for plugin in plugins: +<<<<<<< HEAD + category = getattr(plugin, "category", "unknown") +======= category = plugin.get("category", "unknown") +>>>>>>> upstream/main category_counts[category] = ( category_counts.get(category, 0) + 1 @@ -403,10 +420,13 @@ async def start_task( # Slot is held — schedule execution. # execute_task releases the slot in its finally block on every exit path. +<<<<<<< HEAD +======= # # Use BackgroundTasks so the response can be sent without waiting in real # ASGI servers, while tests using TestClient still execute the task to keep # contract tests deterministic. +>>>>>>> upstream/main background_tasks.add_task(executor.execute_task, task_id) await invalidate_view_cache() @@ -595,7 +615,11 @@ async def download_pdf_report(task_id: str): ) +<<<<<<< HEAD +@router.get("/task/{task_id}/report/sarif") +======= @router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) +>>>>>>> upstream/main async def download_sarif_report(task_id: str): """Download task results as a SARIF report.""" db = await get_db() @@ -740,6 +764,10 @@ async def build(): db = await get_db() # Get data +<<<<<<< HEAD + raw_findings = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") + findings = parse_json_fields(raw_findings, ["metadata_json"]) +======= # Push severity aggregation to DB — avoids full table scan in Python severity_rows = await db.fetchall( """ @@ -749,6 +777,7 @@ async def build(): """ ) severity_counts = {row["severity"]: row["cnt"] for row in severity_rows} +>>>>>>> upstream/main task_stats = await db.fetchone( """ @@ -919,8 +948,13 @@ def build_page_url(page_num): "per_page": per_page, "total_pages": total_pages, "total_items": total, +<<<<<<< HEAD + "next": build_page_url(next_page), # ← NEW + "previous": build_page_url(prev_page) # ← NEW +======= "next": build_page_url(next_page), "previous": build_page_url(prev_page) +>>>>>>> upstream/main } } @@ -938,6 +972,17 @@ async def delete_task_records(task_ids: List[str]): db = await get_db() +<<<<<<< HEAD + # Get raw output paths for file cleanup + placeholders = ",".join(["?"] * len(task_ids)) + task_rows = await db.fetchall(f"SELECT raw_output_path FROM tasks WHERE id IN ({placeholders})", tuple(task_ids)) + + # Delete associated data + await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(task_ids)) + await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(task_ids)) + await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(task_ids)) + await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(task_ids)) +======= # Collect all raw_output_paths across chunks for file cleanup all_task_rows = [] for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): @@ -957,6 +1002,7 @@ async def delete_task_records(task_ids: List[str]): await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk)) await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) +>>>>>>> upstream/main # Cleanup files on disk for row in all_task_rows: @@ -988,6 +1034,16 @@ async def delete_task(task_id: str): @router.delete("/tasks/bulk") +<<<<<<< HEAD +async def bulk_delete_tasks(task_ids: List[str] = Body(...)): + """Delete multiple tasks at once""" + db = await get_db() + + if not task_ids: + return {"deleted_count": 0, "success": True} + + # Check if any tasks are running +======= async def bulk_delete_tasks(request: BulkDeleteRequest): """Delete multiple tasks at once (max 500 IDs per request)""" task_ids = request.root # RootModel exposes data via .root @@ -998,6 +1054,7 @@ async def bulk_delete_tasks(request: BulkDeleteRequest): return {"deleted_count": 0, "success": True} # Check running tasks — safe: len(task_ids) <= 500 guaranteed by Pydantic +>>>>>>> upstream/main placeholders = ",".join(["?"] * len(task_ids)) running_tasks = await db.fetchone( f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", @@ -1013,6 +1070,19 @@ async def bulk_delete_tasks(request: BulkDeleteRequest): await delete_task_records(task_ids) await invalidate_view_cache() +<<<<<<< HEAD + return { + "deleted_count": len(task_ids), + "success": True + } + if running_tasks: + raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") + + await delete_task_records(task_ids) + await invalidate_view_cache() + +======= +>>>>>>> upstream/main return { "deleted_count": len(task_ids), "success": True @@ -1445,6 +1515,8 @@ async def get_finding_details(finding_id: str): except json.JSONDecodeError: metadata = {} +<<<<<<< HEAD +======= risk_factors = [] if finding_row.get("risk_factors_json"): try: @@ -1452,6 +1524,7 @@ async def get_finding_details(finding_id: str): except (json.JSONDecodeError, TypeError): risk_factors = [] +>>>>>>> upstream/main return { "id": finding_row["id"], "task_id": finding_row["task_id"], @@ -1528,4 +1601,4 @@ async def get_assets(): # For now, we use unique targets as assets rows = await db.fetchall("SELECT DISTINCT target FROM tasks UNION SELECT DISTINCT target FROM findings") assets = [{"id": str(uuid.uuid4()), "name": row["target"]} for row in rows] - return {"assets": assets} + return {"assets": assets} \ No newline at end of file diff --git a/backend/secuscan/validation.py b/backend/secuscan/validation.py index 0a96c5031..5a06c23e2 100644 --- a/backend/secuscan/validation.py +++ b/backend/secuscan/validation.py @@ -4,10 +4,14 @@ import re import ipaddress +<<<<<<< HEAD +from typing import Any, Dict, Tuple +======= import socket import time from urllib.parse import urlparse from typing import Any, Dict, Tuple, Optional +>>>>>>> upstream/main from fnmatch import fnmatch from .config import settings @@ -214,6 +218,16 @@ def validate_target(target: str, safe_mode: bool = True) -> Tuple[bool, str]: # Handle URLs hostname_to_validate = target +<<<<<<< HEAD + if target.startswith(("http://", "https://")): + # Extract host:port or host (handle IPv6 literals in brackets) + host_part = target.split("://", 1)[1].split("/", 1)[0] + if host_part.startswith("["): + # IPv6 literal like [::1]:8080 or [::1] for ipv6 + hostname_to_validate = host_part.split("]")[0][1:] + else: + hostname_to_validate = host_part.split(":", 1)[0] +======= parsed_host = _parse_url_hostname(target) if parsed_host is not None: hostname_to_validate = parsed_host @@ -224,6 +238,7 @@ def validate_target(target: str, safe_mode: bool = True) -> Tuple[bool, str]: return validate_target(str(net), safe_mode=safe_mode) except ValueError: pass +>>>>>>> upstream/main # Validate hostname format (RFC 1123) if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$', hostname_to_validate): @@ -500,4 +515,8 @@ def _check_field(key: str, value: Any) -> Tuple[bool, int, str]: f"{settings.task_start_max_field_length} characters.", ) +<<<<<<< HEAD return True, 0, "" +======= + return True, 0, "" +>>>>>>> upstream/main diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 94960ccdb..a10e94505 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,6 +7,11 @@ COPY . . RUN npm run build # Production stage +<<<<<<< HEAD +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +======= FROM nginx:1.27-alpine RUN apk upgrade --no-cache libcrypto3 libssl3 @@ -27,4 +32,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:8080/ || exit 1 +>>>>>>> upstream/main CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d3274828..1a49cc556 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,13 +10,20 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' +<<<<<<< HEAD +======= import ApiKeySetupScreen from './components/ApiKeySetupScreen' +>>>>>>> upstream/main import { ThemeProvider } from './components/ThemeContext' import { ToastProvider } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' import { routes } from './routes' +<<<<<<< HEAD +import ReportComparison from './pages/ReportComparison' +======= import { AUTH_REQUIRED_EVENT, getStoredApiKey } from './api' +>>>>>>> upstream/main export function AppRoutes() { return ( @@ -30,6 +37,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b3cb9fcff..26d5856d7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -233,7 +233,11 @@ export interface WorkflowStep { export interface Workflow { id: string name: string +<<<<<<< HEAD + schedule_interval: string +======= schedule_seconds: number | null +>>>>>>> upstream/main enabled: boolean steps: WorkflowStep[] last_run_at?: string | null @@ -243,18 +247,34 @@ export interface Workflow { export interface WorkflowCreatePayload { name: string +<<<<<<< HEAD + schedule_interval: string +======= schedule_seconds?: number | null +>>>>>>> upstream/main enabled: boolean steps: WorkflowStep[] } export interface WorkflowUpdatePayload { name?: string +<<<<<<< HEAD + schedule_interval?: string +======= schedule_seconds?: number | null +>>>>>>> upstream/main enabled?: boolean steps?: WorkflowStep[] } +<<<<<<< HEAD +export function getWorkflows(): Promise { + return request('/workflows') +} + +export function createWorkflow(data: WorkflowCreatePayload): Promise { + return request('/workflows', { +======= interface WorkflowListResponse { workflows: unknown[] total: number @@ -303,10 +323,24 @@ export async function getWorkflows(): Promise { export async function createWorkflow(data: WorkflowCreatePayload): Promise { const workflow = await request('/workflows', { +>>>>>>> upstream/main method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) +<<<<<<< HEAD +} + +export function runWorkflow(workflowId: string): Promise<{ queued_task_ids: string[] }> { + return request<{ queued_task_ids: string[] }>(`/workflows/${workflowId}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) +} + +export function updateWorkflow(workflowId: string, data: WorkflowUpdatePayload): Promise { + return request(`/workflows/${workflowId}`, { +======= return normalizeWorkflow(workflow) } @@ -326,15 +360,23 @@ export async function runWorkflow(workflowId: string): Promise<{ queued_task_ids export async function updateWorkflow(workflowId: string, data: WorkflowUpdatePayload): Promise { const workflow = await request(`/workflows/${workflowId}`, { +>>>>>>> upstream/main method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) +<<<<<<< HEAD +======= return normalizeWorkflow(workflow) +>>>>>>> upstream/main } export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean }> { return request<{ deleted: boolean }>(`/workflows/${workflowId}`, { method: 'DELETE', }) +<<<<<<< HEAD +} +======= } +>>>>>>> upstream/main diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b8..6286654d1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -14,8 +14,13 @@ interface NavItemProps { const NavItem = ({ to, icon, label, isExpanded, highlight = false }: NavItemProps) => { return ( +<<<<<<< HEAD + >>>>>> upstream/main end onClick={(e) => e.stopPropagation()} className={({ isActive }) => ` diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 3fa376b2b..f360b63d5 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' import { getFindings } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' +<<<<<<< HEAD +======= import SavedViewsPanel from '../components/SavedViewsPanel' import { useSavedViews, FilterPreset } from '../hooks/useSavedViews' type RiskFactor = { @@ -14,6 +16,7 @@ type RiskFactor = { detail: string } +>>>>>>> upstream/main type Finding = { id: string severity: string @@ -26,11 +29,14 @@ type Finding = { cvss?: number cve?: string plugin_id?: string +<<<<<<< HEAD +======= risk_score?: number risk_factors?: RiskFactor[] exploitability?: number confidence?: number asset_exposure?: string +>>>>>>> upstream/main } type FindingStatus = 'new' | 'reviewed' | 'suppressed' @@ -580,6 +586,15 @@ export default function Findings() { +<<<<<<< HEAD + +=======
+>>>>>>> upstream/main diff --git a/frontend/src/pages/ReportComparison.tsx b/frontend/src/pages/ReportComparison.tsx new file mode 100644 index 000000000..180c1b3ee --- /dev/null +++ b/frontend/src/pages/ReportComparison.tsx @@ -0,0 +1,432 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Analytics02Icon, + ArrowRight01Icon, + Cancel01Icon, + CheckmarkCircle01Icon, + GitCompareIcon, + Radar02Icon, + Refresh01Icon, + ShieldCheckIcon, + WarningDiamondIcon, +} from '@hugeicons/core-free-icons' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Finding { + id: string + title: string + severity?: 'critical' | 'high' | 'medium' | 'low' | 'info' + description?: string +} + +export interface ScanReport { + id: string + name: string + generated_at: string + findings: Finding[] +} + +export interface ComparisonResult { + newFindings: Finding[] + fixedFindings: Finding[] + unchangedFindings: Finding[] + severityChanges: Array<{ + finding: Finding + oldSeverity?: string + newSeverity?: string + }> +} + +// ─── Comparison logic ───────────────────────────────────────────────────────── +/** + * Compares two scan reports deterministically using finding `id` as the key. + * - New → in reportB but not reportA + * - Fixed → in reportA but not reportB + * - Unchanged → in both with the same severity + * - Severity change → in both but severity differs + */ +export function compareReports( + reportA: ScanReport, + reportB: ScanReport, +): ComparisonResult { + const mapA = new Map(reportA.findings.map((f) => [f.id, f])) + const mapB = new Map(reportB.findings.map((f) => [f.id, f])) + + const newFindings: Finding[] = [] + const fixedFindings: Finding[] = [] + const unchangedFindings: Finding[] = [] + const severityChanges: ComparisonResult['severityChanges'] = [] + + for (const [id, finding] of mapB) { + if (!mapA.has(id)) { + newFindings.push(finding) + } else { + const old = mapA.get(id)! + if (old.severity !== finding.severity) { + severityChanges.push({ finding, oldSeverity: old.severity, newSeverity: finding.severity }) + } else { + unchangedFindings.push(finding) + } + } + } + + for (const [id, finding] of mapA) { + if (!mapB.has(id)) fixedFindings.push(finding) + } + + return { newFindings, fixedFindings, unchangedFindings, severityChanges } +} + +// ─── Mock data (swap for real API call later) ───────────────────────────────── + +const MOCK_REPORTS: ScanReport[] = [ + { + id: 'r1', + name: 'Scan_Alpha — Jan 2025', + generated_at: '2025-01-15T10:00:00Z', + findings: [ + { id: 'f1', title: 'SQL Injection in /login', severity: 'critical', description: 'Unsanitised user input passed directly to query.' }, + { id: 'f2', title: 'Outdated TLS 1.0 Accepted', severity: 'medium', description: 'Server still accepts TLS 1.0 connections.' }, + { id: 'f3', title: 'Missing CSP Header', severity: 'low', description: 'No Content-Security-Policy header returned.' }, + ], + }, + { + id: 'r2', + name: 'Scan_Beta — Feb 2025', + generated_at: '2025-02-20T10:00:00Z', + findings: [ + { id: 'f2', title: 'Outdated TLS 1.0 Accepted', severity: 'high', description: 'Severity escalated after re-assessment.' }, + { id: 'f3', title: 'Missing CSP Header', severity: 'low', description: 'Still not remediated.' }, + { id: 'f4', title: 'Open Redirect on /logout', severity: 'medium', description: 'Redirect destination unvalidated.' }, + ], + }, + { + id: 'r3', + name: 'Scan_Gamma — Mar 2025', + generated_at: '2025-03-10T10:00:00Z', + findings: [ + { id: 'f3', title: 'Missing CSP Header', severity: 'low', description: 'Still present.' }, + { id: 'f5', title: 'Reflected XSS in Search', severity: 'high', description: 'New reflected XSS detected in search input.' }, + ], + }, +] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function Icon({ icon, size = 20, className = '' }: { icon: any; size?: number; className?: string }) { + return +} + +const severityStyles: Record = { + critical: 'bg-rag-red text-black', + high: 'bg-orange-500 text-black', + medium: 'bg-rag-amber text-black', + low: 'bg-rag-green text-black', + info: 'bg-rag-blue text-black', + unknown: 'bg-silver/20 text-black', +} + +function SeverityBadge({ severity }: { severity?: string }) { + const key = severity ?? 'unknown' + return ( + + {key} + + ) +} + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.06 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35 } }, +} + +// ─── Finding row ────────────────────────────────────────────────────────────── + +function FindingRow({ finding }: { finding: Finding }) { + return ( + +
+ + {finding.title} + + +
+ {finding.description && ( +

+ {finding.description} +

+ )} +
+ ) +} + +// ─── Comparison section ─────────────────────────────────────────────────────── + +function ComparisonSection({ + title, + icon, + findings, + accentClass, + emptyMsg, +}: { + title: string + icon: any + findings: Finding[] + accentClass: string + emptyMsg: string +}) { + return ( +
+
+ +

+ {title} +

+ + {findings.length} + +
+ {findings.length === 0 ? ( +

+ {emptyMsg} +

+ ) : ( + + {findings.map((f) => )} + + )} +
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function ReportComparison() { + const reports = MOCK_REPORTS // TODO: replace with getReports() API call + + const [baseId, setBaseId] = useState('') + const [newerId, setNewerId] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState('') + + function handleCompare() { + setError('') + setResult(null) + if (!baseId || !newerId) { setError('Select both reports before comparing.'); return } + if (baseId === newerId) { setError('Select two different reports.'); return } + const rA = reports.find((r) => r.id === baseId) + const rB = reports.find((r) => r.id === newerId) + if (!rA || !rB) { setError('Could not load one or both reports.'); return } + setResult(compareReports(rA, rB)) + } + + const selectedA = reports.find((r) => r.id === baseId) + const selectedB = reports.find((r) => r.id === newerId) + + const selectClass = + 'w-full bg-charcoal-dark border-4 border-black px-4 py-3 text-[11px] font-black text-silver-bright uppercase tracking-widest italic shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:outline-none focus:border-rag-blue' + + return ( +
+ + {/* Header */} +
+
+
+ Delta_Engine v1.0 +
+

+ Report{' '} + + Delta + +

+

+ COMPARE_SCAN_RUNS // TRACK_REMEDIATION // DETECT_REGRESSION +

+
+
+ + {/* Selector Panel */} +
+

+ Select_Reports_For_Comparison +

+ +
+ {/* Baseline selector */} +
+ + +
+ + {/* Newer selector */} +
+ + +
+
+ + + + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} +
+ + {/* Results */} + + {result && selectedA && selectedB && ( + + {/* Summary bar */} +
+ {[ + { label: 'New', val: result.newFindings.length, color: 'bg-rag-red' }, + { label: 'Fixed', val: result.fixedFindings.length, color: 'bg-rag-green' }, + { label: 'Unchanged', val: result.unchangedFindings.length, color: 'bg-silver/20' }, + { label: 'Severity_Changed', val: result.severityChanges.length, color: 'bg-rag-amber' }, + ].map((m) => ( +
+ {m.label} + {m.val} +
+ ))} +
+ + {/* Comparing label */} +
+ {selectedA.name} + + {selectedB.name} +
+ + {/* Four sections */} +
+ + + + + {/* Severity changes — special layout */} +
+
+ +

+ Severity Changes +

+ + {result.severityChanges.length} + +
+ {result.severityChanges.length === 0 ? ( +

+ No severity changes detected. +

+ ) : ( + + {result.severityChanges.map(({ finding, oldSeverity, newSeverity }) => ( + + + {finding.title} + +
+ + + +
+
+ ))} +
+ )} +
+
+
+ )} +
+ + {/* Footer */} +
+
+
+ DELTA_ANALYSIS_DAEMON // REPORT_DIFF_ENGINE // {new Date().getFullYear()} +
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( +
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 7464dbde5..62b64b48d 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -16,6 +16,10 @@ import { } from '@hugeicons/core-free-icons' import { getDashboardSummary, getReports, API_BASE } from '../api' import { formatDateLong, isWithinDateRange, type DateRange } from '../utils/date' +<<<<<<< HEAD +import { usePreferredExportFormat } from '../hooks/usePreferredExportFormat' +======= +>>>>>>> upstream/main type Report = { id: string @@ -72,10 +76,14 @@ export default function Reports() { const [selectedDateRange, setSelectedDateRange] = useState('all') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) +<<<<<<< HEAD + const { preferred, savePreference } = usePreferredExportFormat() +======= const [preferredFormat, setPreferredFormat] = useState(null) const latestReadyReport = [...reports] .filter((report) => report.status === 'ready') .sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime())[0] +>>>>>>> upstream/main const fetchReports = () => { setLoading(true) @@ -122,6 +130,13 @@ export default function Reports() {
+ + +<<<<<<< HEAD + {[...exportFormats].sort((a, b) => + a === preferred ? -1 : b === preferred ? 1 : 0 + ).map((format) => ( + + ))} +======= {(() => { const ordered = preferredFormat ? [preferredFormat, ...exportFormats.filter((f) => f !== preferredFormat)] @@ -390,6 +429,7 @@ export default function Reports() { )) })()} +>>>>>>> upstream/main
diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 0e93cd903..5c294dbde 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -1,4 +1,8 @@ +<<<<<<< HEAD +import React, { useState, useEffect } from "react"; +======= import React, { useState, useEffect, useRef } from "react"; +>>>>>>> upstream/main import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks } from "../api"; @@ -8,7 +12,10 @@ import { formatLocaleDate, formatLocaleTime, } from "../utils/date"; +<<<<<<< HEAD +======= import { ConfirmModal } from "../components/ConfirmModal"; +>>>>>>> upstream/main import Pagination from "../components/Pagination"; interface Task { @@ -17,7 +24,10 @@ interface Task { tool: string; target: string; status: "queued" | "running" | "completed" | "failed" | "cancelled"; +<<<<<<< HEAD +======= scan_phase?: string; +>>>>>>> upstream/main created_at: string; started_at?: string; completed_at?: string; @@ -65,6 +75,15 @@ export default function Scans() { const [total, setTotal] = useState(0); const PAGE_LIMIT = 10; +<<<<<<< HEAD + useEffect(() => { + loadTasks(); + const interval = setInterval(loadTasks, 5000); + return () => clearInterval(interval); + }, [filter, page]); + + async function loadTasks() { +======= // Modal state for confirm dialogs const [modalState, setModalState] = useState<{ isOpen: boolean; @@ -131,12 +150,17 @@ export default function Scans() { const controller = new AbortController(); abortRef.current = controller; +>>>>>>> upstream/main try { const params = new URLSearchParams(); if (filter !== "all") params.set("status", filter); params.set("page", String(page)); params.set("per_page", String(PAGE_LIMIT)); +<<<<<<< HEAD + const res = await fetch(`${API_BASE}/tasks?${params.toString()}`); + const data = await res.json(); +======= const res = await fetch(`${API_BASE}/tasks?${params.toString()}`, { signal: controller.signal, }); @@ -146,11 +170,19 @@ export default function Scans() { const data = await res.json(); if (requestSeq !== requestSeqRef.current) return; +>>>>>>> upstream/main setTasks(data.tasks || []); if (data.pagination?.total_items !== undefined) { setTotal(data.pagination.total_items); } } catch (err) { +<<<<<<< HEAD + console.error("Failed to load tasks:", err); + } finally { + setLoading(false); + } + } +======= if (err instanceof DOMException && err.name === "AbortError") return; console.error("Failed to load tasks:", err); } finally { @@ -161,6 +193,7 @@ export default function Scans() { } } +>>>>>>> upstream/main function handleFilterChange(value: string) { setFilter(value); setPage(1); @@ -188,6 +221,44 @@ export default function Scans() { } async function handleTaskDelete(taskId: string) { +<<<<<<< HEAD + 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."); + } + } + + 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."); + } +======= setModalState({ isOpen: true, title: "Delete Scan Record", @@ -228,10 +299,19 @@ export default function Scans() { } }, }); +>>>>>>> upstream/main } async function handleBulkDelete() { if (selectedIds.length === 0) return; +<<<<<<< HEAD + if ( + !window.confirm( + `Are you sure you want to delete ${selectedIds.length} selected scan records?`, + ) + ) { + return; +======= setModalState({ isOpen: true, title: "Bulk Delete Records", @@ -261,6 +341,37 @@ export default function Scans() { ); } + function toggleSelectAll() { + if (selectedIds.length === tasks.length) { + setSelectedIds([]); + } else { + setSelectedIds(tasks.map((t) => t.task_id)); +>>>>>>> upstream/main + } + } + +<<<<<<< HEAD + 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 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([]); @@ -409,6 +520,148 @@ export default function Scans() { }`} > +======= + 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"} + +

+
+ +
+
+ + 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 */} + + +>>>>>>> upstream/main

+<<<<<<< HEAD +======= {task.status === 'running' && task.scan_phase && (

PHASE: {task.scan_phase.replace(/_/g, ' ')}

)} +>>>>>>> upstream/main

SESSION:{" "} @@ -702,6 +958,8 @@ export default function Scans() { ))}

+<<<<<<< HEAD +======= {/* Confirm Modal */} setModalState(prev => ({ ...prev, isOpen: false }))} type={modalState.type} /> +>>>>>>> upstream/main
); } diff --git a/frontend/src/pages/TaskDetails.tsx b/frontend/src/pages/TaskDetails.tsx index 012f00ac2..a35a5835a 100644 --- a/frontend/src/pages/TaskDetails.tsx +++ b/frontend/src/pages/TaskDetails.tsx @@ -1,4 +1,7 @@ +<<<<<<< HEAD +======= import CopyToClipboard from '../components/CopyToClipboard'; +>>>>>>> upstream/main import React, { useState, useEffect, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' @@ -47,6 +50,8 @@ interface Task { preset?: string queue_position?: number pending_count?: number +<<<<<<< HEAD +======= } interface RiskFactor { @@ -57,6 +62,7 @@ interface RiskFactor { weight: number contribution: number detail: string +>>>>>>> upstream/main } interface Finding { @@ -644,6 +650,7 @@ export default function TaskDetails() { + const DetailCard = ({ label, value, subValue }: { label: string, value: string, subValue?: string }) => (
diff --git a/frontend/src/pages/ToolConfig.tsx b/frontend/src/pages/ToolConfig.tsx index 24bde3098..0312e3e68 100644 --- a/frontend/src/pages/ToolConfig.tsx +++ b/frontend/src/pages/ToolConfig.tsx @@ -146,10 +146,13 @@ export default function ToolConfig() { const handleStartScan = async () => { if (!plugin || !schema || submitting) return if (hasValidationErrors) { +<<<<<<< HEAD +======= const firstInvalidField = schema.fields.find((field) => validationErrors[field.id]) if (firstInvalidField) { fieldRefs.current[firstInvalidField.id]?.focus() } +>>>>>>> upstream/main addToast('Fix highlighted scan parameters before starting the scan.', 'error') return } @@ -282,17 +285,24 @@ export default function ToolConfig() { ? 'border-rag-red focus:border-rag-red' : 'border-black focus:border-rag-blue' const fieldId = `field-${field.id}` +<<<<<<< HEAD + const errorId = `error-${field.id}` +======= const labelId = `label-${field.id}` const helpId = `help-${field.id}` const errorId = `error-${field.id}` const describedBy = [field.help ? helpId : null, isInvalid ? errorId : null].filter(Boolean).join(' ') || undefined +>>>>>>> upstream/main return (