diff --git a/backend/alembic/versions/b007_add_remediation_fields.py b/backend/alembic/versions/b007_add_remediation_fields.py new file mode 100644 index 0000000..70f5cbe --- /dev/null +++ b/backend/alembic/versions/b007_add_remediation_fields.py @@ -0,0 +1,44 @@ +"""add_remediation_fields + +Revision ID: b007remediation +Revises: b006notifications +Create Date: 2026-02-09 11:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = 'b007remediation' +down_revision: Union[str, Sequence[str], None] = 'b006notifications' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add remediation fields to tests and test_templates.""" + # Tests — remediation fields + op.add_column('tests', sa.Column('remediation_steps', sa.Text(), nullable=True)) + op.add_column('tests', sa.Column('remediation_status', sa.String(), nullable=True)) + op.add_column('tests', sa.Column('remediation_assignee', UUID(as_uuid=True), nullable=True)) + op.create_foreign_key( + 'fk_tests_remediation_assignee', + 'tests', 'users', + ['remediation_assignee'], ['id'], + ) + + # TestTemplates — suggested_remediation + op.add_column('test_templates', sa.Column('suggested_remediation', sa.Text(), nullable=True)) + + +def downgrade() -> None: + """Remove remediation fields.""" + op.drop_column('test_templates', 'suggested_remediation') + op.drop_constraint('fk_tests_remediation_assignee', 'tests', type_='foreignkey') + op.drop_column('tests', 'remediation_assignee') + op.drop_column('tests', 'remediation_status') + op.drop_column('tests', 'remediation_steps') diff --git a/backend/app/main.py b/backend/app/main.py index 528e2f0..a31337b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from app.routers import metrics as metrics_router from app.routers import users as users_router from app.routers import audit as audit_router from app.routers import notifications as notifications_router +from app.routers import reports as reports_router from app.storage import ensure_bucket_exists from app.jobs.mitre_sync_job import start_scheduler, scheduler @@ -58,6 +59,7 @@ app.include_router(metrics_router.router, prefix="/api/v1") app.include_router(users_router.router, prefix="/api/v1") app.include_router(audit_router.router, prefix="/api/v1") app.include_router(notifications_router.router, prefix="/api/v1") +app.include_router(reports_router.router, prefix="/api/v1") @app.get("/health") diff --git a/backend/app/models/test.py b/backend/app/models/test.py index 861f855..6b8d989 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -49,9 +49,15 @@ class Test(Base): blue_validation_status = Column(String, nullable=True) # pending / approved / rejected blue_validation_notes = Column(Text, nullable=True) + # ── Remediation fields ─────────────────────────────────────────── + remediation_steps = Column(Text, nullable=True) + remediation_status = Column(String, nullable=True) # pending / in_progress / completed / not_applicable + remediation_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + # ── Relationships ─────────────────────────────────────────────── technique = relationship("Technique", back_populates="tests") evidences = relationship("Evidence", back_populates="test") creator = relationship("User", foreign_keys=[created_by]) red_validator = relationship("User", foreign_keys=[red_validated_by]) blue_validator = relationship("User", foreign_keys=[blue_validated_by]) + remediation_user = relationship("User", foreign_keys=[remediation_assignee]) diff --git a/backend/app/models/test_template.py b/backend/app/models/test_template.py index 41ef58d..601bf0e 100644 --- a/backend/app/models/test_template.py +++ b/backend/app/models/test_template.py @@ -34,6 +34,7 @@ class TestTemplate(Base): tool_suggested = Column(String, nullable=True) severity = Column(String, nullable=True) # low / medium / high / critical atomic_test_id = Column(String, nullable=True) # ID in Atomic Red Team repo + suggested_remediation = Column(Text, nullable=True) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000..9ad88ad --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,270 @@ +"""Reports endpoints — export coverage summaries and test results. + +Endpoints +--------- +GET /reports/coverage-summary — full coverage JSON report +GET /reports/coverage-csv — CSV export of coverage +GET /reports/test-results — test results report (JSON) +GET /reports/remediation-status — remediation status report (JSON) +""" + +import csv +import io +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.database import get_db +from app.dependencies.auth import get_current_user +from app.models.enums import TestState +from app.models.technique import Technique +from app.models.test import Test +from app.models.user import User + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +# --------------------------------------------------------------------------- +# GET /reports/coverage-summary +# --------------------------------------------------------------------------- + + +@router.get("/coverage-summary") +def coverage_summary( + tactic: Optional[str] = Query(None, description="Filter by tactic"), + platform: Optional[str] = Query(None, description="Filter by platform (in techniques)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Full coverage report as JSON — technique-by-technique with test counts.""" + query = db.query(Technique) + if tactic: + query = query.filter(Technique.tactic.ilike(f"%{tactic}%")) + + techniques = query.order_by(Technique.mitre_id).all() + + rows = [] + for t in techniques: + # Count tests per state for this technique + test_counts = ( + db.query(Test.state, func.count(Test.id)) + .filter(Test.technique_id == t.id) + .group_by(Test.state) + .all() + ) + counts = {str(state): count for state, count in test_counts} + + # Filter by platform if requested (check if technique platforms contain it) + if platform and platform.lower() not in [p.lower() for p in (t.platforms or [])]: + continue + + rows.append({ + "mitre_id": t.mitre_id, + "name": t.name, + "tactic": t.tactic, + "platforms": t.platforms, + "status_global": t.status_global, + "total_tests": sum(counts.values()), + "tests_by_state": counts, + }) + + total = len(rows) + validated = sum(1 for r in rows if r["status_global"] == "validated") + partial = sum(1 for r in rows if r["status_global"] == "partial") + not_covered = sum(1 for r in rows if r["status_global"] == "not_covered") + in_progress = sum(1 for r in rows if r["status_global"] == "in_progress") + not_evaluated = sum(1 for r in rows if r["status_global"] == "not_evaluated") + + return { + "generated_at": datetime.utcnow().isoformat(), + "summary": { + "total_techniques": total, + "validated": validated, + "partial": partial, + "not_covered": not_covered, + "in_progress": in_progress, + "not_evaluated": not_evaluated, + "coverage_percentage": round((validated / total * 100) if total > 0 else 0, 1), + }, + "techniques": rows, + } + + +# --------------------------------------------------------------------------- +# GET /reports/coverage-csv +# --------------------------------------------------------------------------- + + +@router.get("/coverage-csv") +def coverage_csv( + tactic: Optional[str] = Query(None), + platform: Optional[str] = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Export coverage as a downloadable CSV.""" + query = db.query(Technique) + if tactic: + query = query.filter(Technique.tactic.ilike(f"%{tactic}%")) + + techniques = query.order_by(Technique.mitre_id).all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "MITRE ID", "Name", "Tactic", "Platforms", "Status", + "Total Tests", "Validated", "In Progress", "Not Covered", + ]) + + for t in techniques: + if platform and platform.lower() not in [p.lower() for p in (t.platforms or [])]: + continue + + test_counts = ( + db.query(Test.state, func.count(Test.id)) + .filter(Test.technique_id == t.id) + .group_by(Test.state) + .all() + ) + counts = {str(state): count for state, count in test_counts} + + writer.writerow([ + t.mitre_id, + t.name, + t.tactic, + ", ".join(t.platforms or []), + t.status_global, + sum(counts.values()), + counts.get("validated", 0), + sum(counts.get(s, 0) for s in ["draft", "red_executing", "blue_evaluating", "in_review"]), + counts.get("rejected", 0), + ]) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=aegis_coverage_{datetime.utcnow().strftime('%Y%m%d')}.csv"}, + ) + + +# --------------------------------------------------------------------------- +# GET /reports/test-results +# --------------------------------------------------------------------------- + + +@router.get("/test-results") +def test_results( + state: Optional[str] = Query(None), + date_from: Optional[str] = Query(None, description="ISO date string YYYY-MM-DD"), + date_to: Optional[str] = Query(None, description="ISO date string YYYY-MM-DD"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Report of test results with optional filters.""" + query = db.query(Test) + + if state: + query = query.filter(Test.state == state) + if date_from: + try: + dt = datetime.fromisoformat(date_from) + query = query.filter(Test.created_at >= dt) + except ValueError: + pass + if date_to: + try: + dt = datetime.fromisoformat(date_to) + query = query.filter(Test.created_at <= dt) + except ValueError: + pass + + tests = query.order_by(Test.created_at.desc()).all() + + # Summary + total = len(tests) + by_state = {} + by_result = {} + for t in tests: + s = t.state.value if hasattr(t.state, "value") else str(t.state) + by_state[s] = by_state.get(s, 0) + 1 + if t.detection_result: + r = t.detection_result.value if hasattr(t.detection_result, "value") else str(t.detection_result) + by_result[r] = by_result.get(r, 0) + 1 + + return { + "generated_at": datetime.utcnow().isoformat(), + "filters": {"state": state, "date_from": date_from, "date_to": date_to}, + "summary": { + "total_tests": total, + "by_state": by_state, + "by_detection_result": by_result, + }, + "tests": [ + { + "id": str(t.id), + "name": t.name, + "technique_id": str(t.technique_id), + "state": t.state.value if hasattr(t.state, "value") else str(t.state), + "platform": t.platform, + "attack_success": t.attack_success, + "detection_result": ( + t.detection_result.value if t.detection_result and hasattr(t.detection_result, "value") + else str(t.detection_result) if t.detection_result else None + ), + "red_validation_status": t.red_validation_status, + "blue_validation_status": t.blue_validation_status, + "created_at": t.created_at.isoformat() if t.created_at else None, + } + for t in tests + ], + } + + +# --------------------------------------------------------------------------- +# GET /reports/remediation-status +# --------------------------------------------------------------------------- + + +@router.get("/remediation-status") +def remediation_status( + status: Optional[str] = Query(None, description="Filter by remediation status"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Report of remediation status across all tests.""" + query = db.query(Test).filter(Test.remediation_steps.isnot(None)) + + if status: + query = query.filter(Test.remediation_status == status) + + tests = query.order_by(Test.created_at.desc()).all() + + by_status = {} + for t in tests: + s = t.remediation_status or "unset" + by_status[s] = by_status.get(s, 0) + 1 + + return { + "generated_at": datetime.utcnow().isoformat(), + "summary": { + "total_with_remediation": len(tests), + "by_status": by_status, + }, + "tests": [ + { + "id": str(t.id), + "name": t.name, + "technique_id": str(t.technique_id), + "state": t.state.value if hasattr(t.state, "value") else str(t.state), + "remediation_status": t.remediation_status, + "remediation_steps": t.remediation_steps, + "remediation_assignee": str(t.remediation_assignee) if t.remediation_assignee else None, + } + for t in tests + ], + } diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 4f94ae3..83dc90f 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -40,6 +40,7 @@ from app.schemas.test import ( TestBlueUpdate, TestRedValidate, TestBlueValidate, + TestRemediationUpdate, ) from app.schemas.test_template import TestTemplateInstantiate from app.services.audit_service import log_action @@ -211,6 +212,7 @@ def create_test_from_template( platform=template.platform, procedure_text=template.attack_procedure, tool_used=template.tool_suggested, + remediation_steps=template.suggested_remediation, created_by=current_user.id, state=TestState.draft, ) @@ -520,6 +522,40 @@ def reopen( return test +# --------------------------------------------------------------------------- +# PATCH /tests/{id}/remediation — update remediation fields +# --------------------------------------------------------------------------- + + +@router.patch("/{test_id}/remediation", response_model=TestOut) +def update_remediation( + test_id: uuid.UUID, + payload: TestRemediationUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update remediation fields on a test (any authenticated user).""" + test = _get_test_or_404(db, test_id) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(test, field, value) + + db.commit() + db.refresh(test) + + log_action( + db, + user_id=current_user.id, + action="update_remediation", + entity_type="test", + entity_id=test.id, + details={"updated_fields": list(update_data.keys())}, + ) + + return test + + # --------------------------------------------------------------------------- # GET /tests/{id}/timeline — audit history for this test # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index 30b2c79..75c927b 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -81,6 +81,17 @@ class TestBlueValidate(BaseModel): blue_validation_notes: str | None = None +# ── Remediation update ──────────────────────────────────────────── + + +class TestRemediationUpdate(BaseModel): + """Payload for updating remediation fields.""" + + remediation_steps: str | None = None + remediation_status: str | None = None # pending / in_progress / completed / not_applicable + remediation_assignee: uuid.UUID | None = None + + # ── Legacy validate (kept for backwards compat) ──────────────────── @@ -126,6 +137,11 @@ class TestOut(BaseModel): blue_validation_status: str | None = None blue_validation_notes: str | None = None + # Remediation fields + remediation_steps: str | None = None + remediation_status: str | None = None + remediation_assignee: uuid.UUID | None = None + # Technique info (populated when joined) technique_mitre_id: str | None = None technique_name: str | None = None diff --git a/backend/app/schemas/test_template.py b/backend/app/schemas/test_template.py index 602ba99..d6b8d92 100644 --- a/backend/app/schemas/test_template.py +++ b/backend/app/schemas/test_template.py @@ -24,6 +24,7 @@ class TestTemplateOut(BaseModel): tool_suggested: str | None = None severity: str | None = None atomic_test_id: str | None = None + suggested_remediation: str | None = None is_active: bool = True created_at: datetime | None = None @@ -47,6 +48,7 @@ class TestTemplateCreate(BaseModel): tool_suggested: str | None = None severity: str | None = None atomic_test_id: str | None = None + suggested_remediation: str | None = None # ── Summary (for listings) ───────────────────────────────────────── diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..13c4aa5 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,122 @@ +import client from "./client"; + +// ── Types ─────────────────────────────────────────────────────────── + +export interface CoverageReportSummary { + total_techniques: number; + validated: number; + partial: number; + not_covered: number; + in_progress: number; + not_evaluated: number; + coverage_percentage: number; +} + +export interface CoverageTechniqueRow { + mitre_id: string; + name: string; + tactic: string | null; + platforms: string[]; + status_global: string; + total_tests: number; + tests_by_state: Record; +} + +export interface CoverageReport { + generated_at: string; + summary: CoverageReportSummary; + techniques: CoverageTechniqueRow[]; +} + +export interface TestResultsReport { + generated_at: string; + filters: Record; + summary: { + total_tests: number; + by_state: Record; + by_detection_result: Record; + }; + tests: Array<{ + id: string; + name: string; + technique_id: string; + state: string; + platform: string | null; + attack_success: boolean | null; + detection_result: string | null; + red_validation_status: string | null; + blue_validation_status: string | null; + created_at: string | null; + }>; +} + +export interface RemediationReport { + generated_at: string; + summary: { + total_with_remediation: number; + by_status: Record; + }; + tests: Array<{ + id: string; + name: string; + technique_id: string; + state: string; + remediation_status: string | null; + remediation_steps: string | null; + remediation_assignee: string | null; + }>; +} + +export interface ReportFilters { + tactic?: string; + platform?: string; + state?: string; + date_from?: string; + date_to?: string; + status?: string; +} + +// ── API ───────────────────────────────────────────────────────────── + +export async function getCoverageSummary( + filters?: ReportFilters, +): Promise { + const params = new URLSearchParams(); + if (filters?.tactic) params.set("tactic", filters.tactic); + if (filters?.platform) params.set("platform", filters.platform); + const { data } = await client.get( + `/reports/coverage-summary?${params.toString()}`, + ); + return data; +} + +export function getCoverageCsvUrl(filters?: ReportFilters): string { + const params = new URLSearchParams(); + if (filters?.tactic) params.set("tactic", filters.tactic); + if (filters?.platform) params.set("platform", filters.platform); + return `/api/v1/reports/coverage-csv?${params.toString()}`; +} + +export async function getTestResults( + filters?: ReportFilters, +): Promise { + const params = new URLSearchParams(); + if (filters?.state) params.set("state", filters.state); + if (filters?.date_from) params.set("date_from", filters.date_from); + if (filters?.date_to) params.set("date_to", filters.date_to); + const { data } = await client.get( + `/reports/test-results?${params.toString()}`, + ); + return data; +} + +export async function getRemediationStatus( + filters?: ReportFilters, +): Promise { + const params = new URLSearchParams(); + if (filters?.status) params.set("status", filters.status); + const { data } = await client.get( + `/reports/remediation-status?${params.toString()}`, + ); + return data; +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..45025b6 --- /dev/null +++ b/frontend/src/pages/ReportsPage.tsx @@ -0,0 +1,491 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + FileText, + Download, + BarChart3, + Shield, + Wrench, + Loader2, + Filter, + ChevronDown, +} from "lucide-react"; +import { + getCoverageSummary, + getTestResults, + getRemediationStatus, + type CoverageReport, + type TestResultsReport, + type RemediationReport, + type ReportFilters, +} from "../api/reports"; + +type ReportType = "coverage" | "test-results" | "remediation"; + +const reportTypes: { id: ReportType; label: string; icon: React.ReactNode; desc: string }[] = [ + { + id: "coverage", + label: "Coverage Summary", + icon: , + desc: "Technique coverage status across the MITRE ATT&CK framework", + }, + { + id: "test-results", + label: "Test Results", + icon: , + desc: "Detailed test execution results with state and detection breakdowns", + }, + { + id: "remediation", + label: "Remediation Status", + icon: , + desc: "Remediation progress across all tests with assigned steps", + }, +]; + +export default function ReportsPage() { + const [selectedType, setSelectedType] = useState("coverage"); + const [filters, setFilters] = useState({}); + const [showFilters, setShowFilters] = useState(false); + + const coverageQuery = useQuery({ + queryKey: ["reports", "coverage", filters], + queryFn: () => getCoverageSummary(filters), + enabled: selectedType === "coverage", + }); + + const testResultsQuery = useQuery({ + queryKey: ["reports", "test-results", filters], + queryFn: () => getTestResults(filters), + enabled: selectedType === "test-results", + }); + + const remediationQuery = useQuery({ + queryKey: ["reports", "remediation", filters], + queryFn: () => getRemediationStatus(filters), + enabled: selectedType === "remediation", + }); + + const isLoading = + (selectedType === "coverage" && coverageQuery.isLoading) || + (selectedType === "test-results" && testResultsQuery.isLoading) || + (selectedType === "remediation" && remediationQuery.isLoading); + + const handleDownloadJson = () => { + let data: CoverageReport | TestResultsReport | RemediationReport | undefined; + if (selectedType === "coverage") data = coverageQuery.data; + if (selectedType === "test-results") data = testResultsQuery.data; + if (selectedType === "remediation") data = remediationQuery.data; + if (!data) return; + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `aegis_${selectedType}_${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleDownloadCsv = () => { + const token = localStorage.getItem("token"); + const params = new URLSearchParams(); + if (filters.tactic) params.set("tactic", filters.tactic); + if (filters.platform) params.set("platform", filters.platform); + window.open( + `/api/v1/reports/coverage-csv?${params.toString()}${token ? `&token=${token}` : ""}`, + "_blank", + ); + }; + + return ( +
+ {/* Header */} +
+
+

Reports

+

+ Generate and download coverage, test results, and remediation reports +

+
+
+ + {selectedType === "coverage" && ( + + )} +
+
+ + {/* Report type selector */} +
+ {reportTypes.map((rt) => ( + + ))} +
+ + {/* Filters */} +
+ + {showFilters && ( +
+
+ {(selectedType === "coverage" || selectedType === "test-results") && ( + <> +
+ + setFilters({ ...filters, tactic: e.target.value || undefined })} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" + /> +
+
+ + setFilters({ ...filters, platform: e.target.value || undefined })} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" + /> +
+ + )} + {selectedType === "test-results" && ( + <> +
+ + +
+
+ + setFilters({ ...filters, date_from: e.target.value || undefined })} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" + /> +
+
+ + setFilters({ ...filters, date_to: e.target.value || undefined })} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" + /> +
+ + )} + {selectedType === "remediation" && ( +
+ + +
+ )} +
+
+ )} +
+ + {/* Report content */} + {isLoading ? ( +
+ +
+ ) : ( + <> + {selectedType === "coverage" && coverageQuery.data && ( + + )} + {selectedType === "test-results" && testResultsQuery.data && ( + + )} + {selectedType === "remediation" && remediationQuery.data && ( + + )} + + )} +
+ ); +} + +// ── Sub-views ────────────────────────────────────────────────────── + +function CoverageReportView({ report }: { report: CoverageReport }) { + const s = report.summary; + return ( +
+ {/* Summary cards */} +
+ + + + + + +
+ + {/* Table */} +
+ + + + + + + + + + + + {report.techniques.map((t) => ( + + + + + + + + ))} + +
MITRE IDNameTacticStatusTests
{t.mitre_id}{t.name}{t.tactic} + + {t.total_tests}
+
+
+ ); +} + +function TestResultsView({ report }: { report: TestResultsReport }) { + const s = report.summary; + return ( +
+
+ + + + +
+ + {/* Detection results breakdown */} + {Object.keys(s.by_detection_result).length > 0 && ( +
+

Detection Results

+
+ {Object.entries(s.by_detection_result).map(([key, val]) => ( +
+

{val}

+

{key.replace(/_/g, " ")}

+
+ ))} +
+
+ )} + + {/* Table */} +
+ + + + + + + + + + + + + {report.tests.map((t) => ( + + + + + + + + + ))} + +
NameStatePlatformDetectionRed Val.Blue Val.
{t.name}{t.platform || "—"}{t.detection_result?.replace(/_/g, " ") || "—"}
+
+
+ ); +} + +function RemediationView({ report }: { report: RemediationReport }) { + const s = report.summary; + return ( +
+
+ + + + +
+ +
+ + + + + + + + + + + {report.tests.map((t) => ( + + + + + + + ))} + +
NameTest StateRemediation StatusSteps
{t.name} + {t.remediation_steps || "—"} +
+
+
+ ); +} + +// ── Shared components ────────────────────────────────────────────── + +function StatCard({ + label, + value, + color = "text-white", +}: { + label: string; + value: number | string; + color?: string; +}) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +const statusColors: Record = { + validated: "bg-green-500/10 text-green-400 border-green-500/30", + partial: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30", + in_progress: "bg-blue-500/10 text-blue-400 border-blue-500/30", + not_covered: "bg-red-500/10 text-red-400 border-red-500/30", + not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-500/30", + draft: "bg-gray-500/10 text-gray-400 border-gray-500/30", + red_executing: "bg-orange-500/10 text-orange-400 border-orange-500/30", + blue_evaluating: "bg-indigo-500/10 text-indigo-400 border-indigo-500/30", + in_review: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30", + rejected: "bg-red-500/10 text-red-400 border-red-500/30", +}; + +function StatusBadge({ status }: { status: string }) { + return ( + + {status.replace(/_/g, " ")} + + ); +} + +function ValidationBadge({ status }: { status: string | null }) { + if (!status) return ; + const colors: Record = { + approved: "text-green-400", + rejected: "text-red-400", + pending: "text-yellow-400", + }; + return {status}; +} + +function RemediationBadge({ status }: { status: string | null }) { + if (!status) return ; + const colors: Record = { + pending: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30", + in_progress: "bg-blue-500/10 text-blue-400 border-blue-500/30", + completed: "bg-green-500/10 text-green-400 border-green-500/30", + not_applicable: "bg-gray-500/10 text-gray-400 border-gray-500/30", + }; + return ( + + {status.replace(/_/g, " ")} + + ); +} diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index e77b8bb..73e5cc8 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -86,6 +86,11 @@ export interface Test { blue_validation_status: ValidationStatus | null; blue_validation_notes: string | null; + // Remediation fields + remediation_steps: string | null; + remediation_status: string | null; + remediation_assignee: string | null; + // Technique info (populated in list endpoints) technique_mitre_id: string | null; technique_name: string | null; @@ -125,6 +130,7 @@ export interface TestTemplate { tool_suggested: string | null; severity: string | null; atomic_test_id: string | null; + suggested_remediation: string | null; is_active: boolean; created_at: string; }