feat(phase-19): add remediation fields and reports system (T-130, T-131)
This commit is contained in:
44
backend/alembic/versions/b007_add_remediation_fields.py
Normal file
44
backend/alembic/versions/b007_add_remediation_fields.py
Normal 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')
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
270
backend/app/routers/reports.py
Normal file
270
backend/app/routers/reports.py
Normal 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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
122
frontend/src/api/reports.ts
Normal 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;
|
||||||
|
}
|
||||||
491
frontend/src/pages/ReportsPage.tsx
Normal file
491
frontend/src/pages/ReportsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user