feat(phase-19): add remediation fields and reports system (T-130, T-131)

This commit is contained in:
2026-02-09 13:58:35 +01:00
parent fb7f340038
commit 9ea6ce1326
11 changed files with 996 additions and 0 deletions

View File

@@ -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')

View File

@@ -17,6 +17,7 @@ from app.routers import metrics as metrics_router
from app.routers import users as users_router from app.routers import users as users_router
from app.routers import audit as audit_router from app.routers import audit as audit_router
from app.routers import notifications as notifications_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.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler 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(users_router.router, prefix="/api/v1")
app.include_router(audit_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(notifications_router.router, prefix="/api/v1")
app.include_router(reports_router.router, prefix="/api/v1")
@app.get("/health") @app.get("/health")

View File

@@ -49,9 +49,15 @@ class Test(Base):
blue_validation_status = Column(String, nullable=True) # pending / approved / rejected blue_validation_status = Column(String, nullable=True) # pending / approved / rejected
blue_validation_notes = Column(Text, nullable=True) 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 ─────────────────────────────────────────────── # ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests") technique = relationship("Technique", back_populates="tests")
evidences = relationship("Evidence", back_populates="test") evidences = relationship("Evidence", back_populates="test")
creator = relationship("User", foreign_keys=[created_by]) creator = relationship("User", foreign_keys=[created_by])
red_validator = relationship("User", foreign_keys=[red_validated_by]) red_validator = relationship("User", foreign_keys=[red_validated_by])
blue_validator = relationship("User", foreign_keys=[blue_validated_by]) blue_validator = relationship("User", foreign_keys=[blue_validated_by])
remediation_user = relationship("User", foreign_keys=[remediation_assignee])

View File

@@ -34,6 +34,7 @@ class TestTemplate(Base):
tool_suggested = Column(String, nullable=True) tool_suggested = Column(String, nullable=True)
severity = Column(String, nullable=True) # low / medium / high / critical severity = Column(String, nullable=True) # low / medium / high / critical
atomic_test_id = Column(String, nullable=True) # ID in Atomic Red Team repo 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) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -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
],
}

View File

@@ -40,6 +40,7 @@ from app.schemas.test import (
TestBlueUpdate, TestBlueUpdate,
TestRedValidate, TestRedValidate,
TestBlueValidate, TestBlueValidate,
TestRemediationUpdate,
) )
from app.schemas.test_template import TestTemplateInstantiate from app.schemas.test_template import TestTemplateInstantiate
from app.services.audit_service import log_action from app.services.audit_service import log_action
@@ -211,6 +212,7 @@ def create_test_from_template(
platform=template.platform, platform=template.platform,
procedure_text=template.attack_procedure, procedure_text=template.attack_procedure,
tool_used=template.tool_suggested, tool_used=template.tool_suggested,
remediation_steps=template.suggested_remediation,
created_by=current_user.id, created_by=current_user.id,
state=TestState.draft, state=TestState.draft,
) )
@@ -520,6 +522,40 @@ def reopen(
return test 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 # GET /tests/{id}/timeline — audit history for this test
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -81,6 +81,17 @@ class TestBlueValidate(BaseModel):
blue_validation_notes: str | None = None 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) ──────────────────── # ── Legacy validate (kept for backwards compat) ────────────────────
@@ -126,6 +137,11 @@ class TestOut(BaseModel):
blue_validation_status: str | None = None blue_validation_status: str | None = None
blue_validation_notes: 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 info (populated when joined)
technique_mitre_id: str | None = None technique_mitre_id: str | None = None
technique_name: str | None = None technique_name: str | None = None

View File

@@ -24,6 +24,7 @@ class TestTemplateOut(BaseModel):
tool_suggested: str | None = None tool_suggested: str | None = None
severity: str | None = None severity: str | None = None
atomic_test_id: str | None = None atomic_test_id: str | None = None
suggested_remediation: str | None = None
is_active: bool = True is_active: bool = True
created_at: datetime | None = None created_at: datetime | None = None
@@ -47,6 +48,7 @@ class TestTemplateCreate(BaseModel):
tool_suggested: str | None = None tool_suggested: str | None = None
severity: str | None = None severity: str | None = None
atomic_test_id: str | None = None atomic_test_id: str | None = None
suggested_remediation: str | None = None
# ── Summary (for listings) ───────────────────────────────────────── # ── Summary (for listings) ─────────────────────────────────────────

122
frontend/src/api/reports.ts Normal file
View File

@@ -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<string, number>;
}
export interface CoverageReport {
generated_at: string;
summary: CoverageReportSummary;
techniques: CoverageTechniqueRow[];
}
export interface TestResultsReport {
generated_at: string;
filters: Record<string, string | null>;
summary: {
total_tests: number;
by_state: Record<string, number>;
by_detection_result: Record<string, number>;
};
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<string, number>;
};
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<CoverageReport> {
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<CoverageReport>(
`/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<TestResultsReport> {
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<TestResultsReport>(
`/reports/test-results?${params.toString()}`,
);
return data;
}
export async function getRemediationStatus(
filters?: ReportFilters,
): Promise<RemediationReport> {
const params = new URLSearchParams();
if (filters?.status) params.set("status", filters.status);
const { data } = await client.get<RemediationReport>(
`/reports/remediation-status?${params.toString()}`,
);
return data;
}

View File

@@ -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: <Shield className="h-5 w-5" />,
desc: "Technique coverage status across the MITRE ATT&CK framework",
},
{
id: "test-results",
label: "Test Results",
icon: <BarChart3 className="h-5 w-5" />,
desc: "Detailed test execution results with state and detection breakdowns",
},
{
id: "remediation",
label: "Remediation Status",
icon: <Wrench className="h-5 w-5" />,
desc: "Remediation progress across all tests with assigned steps",
},
];
export default function ReportsPage() {
const [selectedType, setSelectedType] = useState<ReportType>("coverage");
const [filters, setFilters] = useState<ReportFilters>({});
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Reports</h1>
<p className="mt-1 text-sm text-gray-400">
Generate and download coverage, test results, and remediation reports
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleDownloadJson}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
Download JSON
</button>
{selectedType === "coverage" && (
<button
onClick={handleDownloadCsv}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg bg-gray-700 px-4 py-2 text-sm font-medium text-white hover:bg-gray-600 transition-colors disabled:opacity-50"
>
<FileText className="h-4 w-4" />
Download CSV
</button>
)}
</div>
</div>
{/* Report type selector */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{reportTypes.map((rt) => (
<button
key={rt.id}
onClick={() => {
setSelectedType(rt.id);
setFilters({});
}}
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
selectedType === rt.id
? "border-cyan-500/50 bg-cyan-500/10"
: "border-gray-800 bg-gray-900 hover:border-gray-700"
}`}
>
<div
className={`rounded-lg p-2 ${
selectedType === rt.id ? "bg-cyan-500/20 text-cyan-400" : "bg-gray-800 text-gray-400"
}`}
>
{rt.icon}
</div>
<div>
<p className={`text-sm font-medium ${selectedType === rt.id ? "text-cyan-400" : "text-white"}`}>
{rt.label}
</p>
<p className="mt-0.5 text-xs text-gray-500">{rt.desc}</p>
</div>
</button>
))}
</div>
{/* Filters */}
<div className="rounded-xl border border-gray-800 bg-gray-900">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
<span className="flex items-center gap-2">
<Filter className="h-4 w-4" />
Filters
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? "rotate-180" : ""}`} />
</button>
{showFilters && (
<div className="border-t border-gray-800 px-4 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{(selectedType === "coverage" || selectedType === "test-results") && (
<>
<div>
<label className="block text-xs text-gray-400 mb-1">Tactic</label>
<input
type="text"
placeholder="e.g. execution"
value={filters.tactic || ""}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Platform</label>
<input
type="text"
placeholder="e.g. windows"
value={filters.platform || ""}
onChange={(e) => 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"
/>
</div>
</>
)}
{selectedType === "test-results" && (
<>
<div>
<label className="block text-xs text-gray-400 mb-1">State</label>
<select
value={filters.state || ""}
onChange={(e) => setFilters({ ...filters, state: e.target.value || undefined })}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
>
<option value="">All states</option>
<option value="draft">Draft</option>
<option value="red_executing">Red Executing</option>
<option value="blue_evaluating">Blue Evaluating</option>
<option value="in_review">In Review</option>
<option value="validated">Validated</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Date From</label>
<input
type="date"
value={filters.date_from || ""}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Date To</label>
<input
type="date"
value={filters.date_to || ""}
onChange={(e) => 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"
/>
</div>
</>
)}
{selectedType === "remediation" && (
<div>
<label className="block text-xs text-gray-400 mb-1">Remediation Status</label>
<select
value={filters.status || ""}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined })}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
>
<option value="">All</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="not_applicable">Not Applicable</option>
</select>
</div>
)}
</div>
</div>
)}
</div>
{/* Report content */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
) : (
<>
{selectedType === "coverage" && coverageQuery.data && (
<CoverageReportView report={coverageQuery.data} />
)}
{selectedType === "test-results" && testResultsQuery.data && (
<TestResultsView report={testResultsQuery.data} />
)}
{selectedType === "remediation" && remediationQuery.data && (
<RemediationView report={remediationQuery.data} />
)}
</>
)}
</div>
);
}
// ── Sub-views ──────────────────────────────────────────────────────
function CoverageReportView({ report }: { report: CoverageReport }) {
const s = report.summary;
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-6">
<StatCard label="Total" value={s.total_techniques} />
<StatCard label="Validated" value={s.validated} color="text-green-400" />
<StatCard label="Partial" value={s.partial} color="text-yellow-400" />
<StatCard label="In Progress" value={s.in_progress} color="text-blue-400" />
<StatCard label="Not Covered" value={s.not_covered} color="text-red-400" />
<StatCard label="Coverage" value={`${s.coverage_percentage}%`} color="text-cyan-400" />
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border border-gray-800">
<table className="w-full text-sm">
<thead className="bg-gray-900/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">MITRE ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Tactic</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-400">Tests</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{report.techniques.map((t) => (
<tr key={t.mitre_id} className="hover:bg-gray-900/30">
<td className="px-4 py-2.5 font-mono text-cyan-400">{t.mitre_id}</td>
<td className="px-4 py-2.5 text-white">{t.name}</td>
<td className="px-4 py-2.5 text-gray-400">{t.tactic}</td>
<td className="px-4 py-2.5">
<StatusBadge status={t.status_global} />
</td>
<td className="px-4 py-2.5 text-right text-gray-300">{t.total_tests}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function TestResultsView({ report }: { report: TestResultsReport }) {
const s = report.summary;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="Total Tests" value={s.total_tests} />
<StatCard label="Validated" value={s.by_state.validated ?? 0} color="text-green-400" />
<StatCard label="In Review" value={s.by_state.in_review ?? 0} color="text-yellow-400" />
<StatCard label="Rejected" value={s.by_state.rejected ?? 0} color="text-red-400" />
</div>
{/* Detection results breakdown */}
{Object.keys(s.by_detection_result).length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Detection Results</h3>
<div className="flex gap-4">
{Object.entries(s.by_detection_result).map(([key, val]) => (
<div key={key} className="text-center">
<p className="text-xl font-bold text-white">{val}</p>
<p className="text-xs text-gray-400">{key.replace(/_/g, " ")}</p>
</div>
))}
</div>
</div>
)}
{/* Table */}
<div className="overflow-hidden rounded-xl border border-gray-800">
<table className="w-full text-sm">
<thead className="bg-gray-900/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">State</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Platform</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Detection</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Red Val.</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Blue Val.</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{report.tests.map((t) => (
<tr key={t.id} className="hover:bg-gray-900/30">
<td className="px-4 py-2.5 text-white">{t.name}</td>
<td className="px-4 py-2.5"><StatusBadge status={t.state} /></td>
<td className="px-4 py-2.5 text-gray-400">{t.platform || "—"}</td>
<td className="px-4 py-2.5 text-gray-300">{t.detection_result?.replace(/_/g, " ") || "—"}</td>
<td className="px-4 py-2.5"><ValidationBadge status={t.red_validation_status} /></td>
<td className="px-4 py-2.5"><ValidationBadge status={t.blue_validation_status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function RemediationView({ report }: { report: RemediationReport }) {
const s = report.summary;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="Total w/ Remediation" value={s.total_with_remediation} />
<StatCard label="Pending" value={s.by_status.pending ?? 0} color="text-yellow-400" />
<StatCard label="In Progress" value={s.by_status.in_progress ?? 0} color="text-blue-400" />
<StatCard label="Completed" value={s.by_status.completed ?? 0} color="text-green-400" />
</div>
<div className="overflow-hidden rounded-xl border border-gray-800">
<table className="w-full text-sm">
<thead className="bg-gray-900/50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Test State</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Remediation Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400">Steps</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{report.tests.map((t) => (
<tr key={t.id} className="hover:bg-gray-900/30">
<td className="px-4 py-2.5 text-white">{t.name}</td>
<td className="px-4 py-2.5"><StatusBadge status={t.state} /></td>
<td className="px-4 py-2.5"><RemediationBadge status={t.remediation_status} /></td>
<td className="px-4 py-2.5 max-w-xs truncate text-gray-400">
{t.remediation_steps || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ── Shared components ──────────────────────────────────────────────
function StatCard({
label,
value,
color = "text-white",
}: {
label: string;
value: number | string;
color?: string;
}) {
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
<p className="text-xs text-gray-500">{label}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
</div>
);
}
const statusColors: Record<string, string> = {
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 (
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusColors[status] || statusColors.not_evaluated}`}>
{status.replace(/_/g, " ")}
</span>
);
}
function ValidationBadge({ status }: { status: string | null }) {
if (!status) return <span className="text-gray-600 text-xs"></span>;
const colors: Record<string, string> = {
approved: "text-green-400",
rejected: "text-red-400",
pending: "text-yellow-400",
};
return <span className={`text-xs font-medium ${colors[status] || "text-gray-400"}`}>{status}</span>;
}
function RemediationBadge({ status }: { status: string | null }) {
if (!status) return <span className="text-gray-600 text-xs"></span>;
const colors: Record<string, string> = {
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 (
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${colors[status] || colors.pending}`}>
{status.replace(/_/g, " ")}
</span>
);
}

View File

@@ -86,6 +86,11 @@ export interface Test {
blue_validation_status: ValidationStatus | null; blue_validation_status: ValidationStatus | null;
blue_validation_notes: string | 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 info (populated in list endpoints)
technique_mitre_id: string | null; technique_mitre_id: string | null;
technique_name: string | null; technique_name: string | null;
@@ -125,6 +130,7 @@ export interface TestTemplate {
tool_suggested: string | null; tool_suggested: string | null;
severity: string | null; severity: string | null;
atomic_test_id: string | null; atomic_test_id: string | null;
suggested_remediation: string | null;
is_active: boolean; is_active: boolean;
created_at: string; created_at: string;
} }