Skip to content
Open
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
94 changes: 94 additions & 0 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends, Body, Query
from fastapi.responses import JSONResponse
from typing import Any, Optional, List, Dict, Callable
from datetime import datetime, timezone
import json
import logging
import re
Expand Down Expand Up @@ -168,6 +169,15 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str:
NotificationChannelType, TaskStatus,
ExecutionContext, WorkflowStep, ValidationMode, EvidenceLevel,
)
from .services.diff_service import compute_diff
from .schemas.diff import (
DiffFindings,
DiffSummary,
FindingSchema,
ScanDiffResponse,
ScanMeta,
SeverityChange,
)
from .config import settings
from .database import get_db
from .plugins import get_plugin_manager, init_plugins
Expand Down Expand Up @@ -286,6 +296,20 @@ def iter_raw_output_chunks(path: str, chunk_size: int = SSE_RAW_OUTPUT_CHUNK_SIZ
yield chunk


def _parse_findings(structured_json: Optional[str]) -> list[dict[str, Any]]:
"""Extract the findings list from a task row's stored structured JSON."""
if not structured_json:
return []
try:
structured = json.loads(structured_json)
except json.JSONDecodeError:
return []
if not isinstance(structured, dict):
return []
findings = structured.get("findings")
return findings if isinstance(findings, list) else []


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(
Expand Down Expand Up @@ -893,6 +917,76 @@ async def get_task_result(task_id: str, owner: str = Depends(get_current_owner))
}


@router.get("/scans/diff")
async def get_scan_diff(scan_a: str, scan_b: str) -> ScanDiffResponse:
"""Diff two completed scans of the same target. 404 if either missing, 400 if targets differ."""
if scan_a == scan_b:
raise HTTPException(
status_code=400,
detail="scan_a and scan_b must be different scan IDs",
)

db = await get_db()

task_row_a = await db.fetchone(
"SELECT id, tool_name, target, created_at, structured_json FROM tasks WHERE id = ?",
(scan_a,),
)
if not task_row_a:
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_a}")

task_row_b = await db.fetchone(
"SELECT id, tool_name, target, created_at, structured_json FROM tasks WHERE id = ?",
(scan_b,),
)
if not task_row_b:
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_b}")

if task_row_a["target"] != task_row_b["target"]:
raise HTTPException(
status_code=400,
detail="Scans must target the same host to be compared",
)

findings_a = _parse_findings(task_row_a["structured_json"])
findings_b = _parse_findings(task_row_b["structured_json"])

raw_diff = compute_diff(findings_a, findings_b)

return ScanDiffResponse(
scan_a=ScanMeta(
task_id=task_row_a["id"],
target=task_row_a["target"],
timestamp=task_row_a["created_at"] or datetime.min,
tool=task_row_a["tool_name"] or "",
),
scan_b=ScanMeta(
task_id=task_row_b["id"],
target=task_row_b["target"],
timestamp=task_row_b["created_at"] or datetime.min,
tool=task_row_b["tool_name"] or "",
),
diff=DiffFindings(
new_findings=[FindingSchema.model_validate(f) for f in raw_diff["new_findings"]],
fixed_findings=[FindingSchema.model_validate(f) for f in raw_diff["fixed_findings"]],
unchanged_findings=[FindingSchema.model_validate(f) for f in raw_diff["unchanged_findings"]],
severity_changed=[
SeverityChange(
before=FindingSchema.model_validate(sc["before"]),
after=FindingSchema.model_validate(sc["after"]),
)
for sc in raw_diff["severity_changed"]
],
),
summary=DiffSummary(
total_new=len(raw_diff["new_findings"]),
total_fixed=len(raw_diff["fixed_findings"]),
total_unchanged=len(raw_diff["unchanged_findings"]),
total_severity_changed=len(raw_diff["severity_changed"]),
),
)


@router.post("/task/{task_id}/cancel")
async def cancel_task(task_id: str, owner: str = Depends(get_current_owner)):
"""Cancel a running task"""
Expand Down
Empty file.
66 changes: 66 additions & 0 deletions backend/secuscan/schemas/diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from datetime import datetime
from typing import Any, Optional

from pydantic import BaseModel, ConfigDict, Field


class ScanMeta(BaseModel):
model_config = ConfigDict(from_attributes=True)

task_id: str
target: str
timestamp: datetime
tool: str


class FindingSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: Optional[str] = None
title: str
category: str
severity: str
target: str
description: str
remediation: Optional[str] = None
cvss: Optional[float] = None
cve: Optional[str] = None
proof: Optional[str] = None
discovered_at: Optional[datetime] = None
metadata: dict[str, Any] = Field(default_factory=dict)


class SeverityChange(BaseModel):
model_config = ConfigDict(from_attributes=True)

before: FindingSchema
after: FindingSchema


class DiffFindings(BaseModel):
model_config = ConfigDict(from_attributes=True)

new_findings: list[FindingSchema] = Field(default_factory=list)
fixed_findings: list[FindingSchema] = Field(default_factory=list)
unchanged_findings: list[FindingSchema] = Field(default_factory=list)
severity_changed: list[SeverityChange] = Field(default_factory=list)


class DiffSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)

total_new: int = 0
total_fixed: int = 0
total_unchanged: int = 0
total_severity_changed: int = 0


class ScanDiffResponse(BaseModel):
"""Top-level response for GET /api/v1/scans/diff."""

model_config = ConfigDict(from_attributes=True)

scan_a: ScanMeta
scan_b: ScanMeta
diff: DiffFindings
summary: DiffSummary
Empty file.
41 changes: 41 additions & 0 deletions backend/secuscan/services/diff_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Pure diff logic for scan findings — no DB access."""

from typing import Any


def fingerprint(finding: dict[str, Any]) -> str:
"""Stable identity key for a finding."""
title = finding.get("title") or ""
category = finding.get("category") or ""
target = finding.get("target") or ""
return f"{title}\x00{category}\x00{target}"


def compute_diff(
findings_a: list[dict[str, Any]],
findings_b: list[dict[str, Any]],
) -> dict[str, Any]:
"""Return new/fixed/unchanged/severity_changed buckets for two finding lists."""
map_a: dict[str, dict[str, Any]] = {fingerprint(f): f for f in findings_a}
map_b: dict[str, dict[str, Any]] = {fingerprint(f): f for f in findings_b}
ka: set[str] = set(map_a)
kb: set[str] = set(map_b)

new_findings = [map_b[k] for k in kb - ka]
fixed_findings = [map_a[k] for k in ka - kb]
unchanged_findings = [
map_a[k] for k in ka & kb
if map_a[k].get("severity") == map_b[k].get("severity")
]
severity_changed = [
{"before": map_a[k], "after": map_b[k]}
for k in ka & kb
if map_a[k].get("severity") != map_b[k].get("severity")
]

return {
"new_findings": new_findings,
"fixed_findings": fixed_findings,
"unchanged_findings": unchanged_findings,
"severity_changed": severity_changed,
}
61 changes: 61 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,3 +688,64 @@ export function getAssetServices() {
export function getKnowledgebaseStatus() {
return request('/knowledgebase/status')
}

// --- Scan Diff ---

export interface Finding {
id?: string | null
title: string
category: string
severity: string
target: string
description: string
remediation?: string | null
cvss?: number | null
cve?: string | null
proof?: string | null
discovered_at?: string | null
metadata: Record<string, unknown>
}

export interface SeverityChangePair {
before: Finding
after: Finding
}

export interface DiffResult {
new_findings: Finding[]
fixed_findings: Finding[]
unchanged_findings: Finding[]
severity_changed: SeverityChangePair[]
}

export interface DiffSummary {
total_new: number
total_fixed: number
total_unchanged: number
total_severity_changed: number
}

export interface ScanMeta {
task_id: string
target: string
timestamp: string
tool: string
}

export interface ScanDiffResponse {
scan_a: ScanMeta
scan_b: ScanMeta
diff: DiffResult
summary: DiffSummary
}

export function getScanDiff(
scanA: string,
scanB: string,
signal?: AbortSignal,
): Promise<ScanDiffResponse> {
return request<ScanDiffResponse>(
`/scans/diff?scan_a=${encodeURIComponent(scanA)}&scan_b=${encodeURIComponent(scanB)}`,
signal ? { signal } : undefined,
)
}
72 changes: 72 additions & 0 deletions frontend/src/components/DiffFindingCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react'
import { motion } from 'framer-motion'
import type { Finding } from '../api'
import { severityConfig } from '../pages/Findings'

export interface DiffFindingCardProps {
finding: Finding
variant: 'new' | 'fixed' | 'unchanged' | 'severity-changed'
severityBefore?: string
}

const variantBorder: Record<DiffFindingCardProps['variant'], string> = {
new: 'border-rag-red/40',
fixed: 'border-rag-green/40',
unchanged: 'border-silver/10',
'severity-changed': 'border-rag-amber/40',
}

function normalizeSeverity(value: string): string {
return value in severityConfig ? value : 'info'
}

export default function DiffFindingCard({
finding,
variant,
severityBefore,
}: DiffFindingCardProps) {
const afterSev = normalizeSeverity(finding.severity)
const afterConfig = severityConfig[afterSev]

return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0.19, 1, 0.22, 1] }}
className={`bg-charcoal border-4 ${variantBorder[variant]} p-6 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]`}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1 flex-1 min-w-0">
<h4 className="text-sm font-black text-silver-bright uppercase tracking-tight leading-tight">
{finding.title}
</h4>
<p className="text-[10px] font-mono text-silver/40 uppercase tracking-widest truncate">
{finding.category} // {finding.target}
</p>
</div>

<div className="flex items-center gap-2 shrink-0">
{variant === 'severity-changed' && severityBefore && (
<>
<span
className={`px-2 py-0.5 text-[9px] font-black uppercase border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] ${severityConfig[normalizeSeverity(severityBefore)]?.chip ?? ''}`}
aria-label={`Previous severity: ${severityBefore}`}
>
{severityBefore}
</span>
<span className="text-[9px] font-mono text-silver/40" aria-hidden="true">
</span>
</>
)}
<span
className={`px-2 py-0.5 text-[9px] font-black uppercase border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] ${afterConfig.chip}`}
aria-label={`Severity: ${afterConfig.label}`}
>
{afterConfig.label}
</span>
</div>
</div>
</motion.div>
)
}
Loading
Loading