diff --git a/README.md b/README.md index d781161..9dddf0e 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,12 @@ Once the backend is running, access the interactive API documentation at: | POST | `/api/v1/system/sync-mitre` | Admin | Manually trigger MITRE ATT&CK sync | | GET | `/api/v1/system/scheduler-status` | Admin | Background scheduler health & job list | +### Metrics +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/metrics/summary` | Authenticated | Global coverage summary (counts + percentage) | +| GET | `/api/v1/metrics/by-tactic` | Authenticated | Coverage breakdown per MITRE tactic | + ## Project Structure ``` @@ -166,7 +172,8 @@ Aegis/ │ │ ├── techniques.py # CRUD techniques (list, detail, create, update, review) │ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject) │ │ ├── evidence.py # Upload evidence, presigned download -│ │ └── system.py # MITRE sync trigger, scheduler status +│ │ ├── system.py # MITRE sync trigger, scheduler status +│ │ └── metrics.py # Coverage summary & per-tactic breakdown │ ├── dependencies/ # FastAPI dependencies (DI) │ │ └── auth.py # get_current_user, require_role, require_any_role │ ├── jobs/ # Background scheduled jobs diff --git a/backend/app/main.py b/backend/app/main.py index d9c553a..00f679f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from app.routers import techniques as techniques_router from app.routers import tests as tests_router from app.routers import evidence as evidence_router from app.routers import system as system_router +from app.routers import metrics as metrics_router from app.storage import ensure_bucket_exists from app.jobs.mitre_sync_job import start_scheduler, scheduler @@ -45,6 +46,7 @@ app.include_router(techniques_router.router, prefix="/api/v1") app.include_router(tests_router.router, prefix="/api/v1") app.include_router(evidence_router.router, prefix="/api/v1") app.include_router(system_router.router, prefix="/api/v1") +app.include_router(metrics_router.router, prefix="/api/v1") @app.get("/health") diff --git a/backend/app/routers/metrics.py b/backend/app/routers/metrics.py new file mode 100644 index 0000000..b2b09e2 --- /dev/null +++ b/backend/app/routers/metrics.py @@ -0,0 +1,119 @@ +"""Coverage-metrics endpoints. + +Provides aggregated views of MITRE ATT&CK technique coverage for +dashboards and reporting. +""" + +from collections import defaultdict + +from fastapi import APIRouter, Depends +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 TechniqueStatus +from app.models.technique import Technique +from app.models.user import User +from app.schemas.metrics import CoverageSummary, TacticCoverage + +router = APIRouter(prefix="/metrics", tags=["metrics"]) + + +# --------------------------------------------------------------------------- +# GET /metrics/summary +# --------------------------------------------------------------------------- + + +@router.get("/summary", response_model=CoverageSummary) +def coverage_summary( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return a global coverage summary across all techniques.""" + + rows = ( + db.query( + Technique.status_global, + func.count(Technique.id).label("cnt"), + ) + .group_by(Technique.status_global) + .all() + ) + + counts: dict[str, int] = {s.value: 0 for s in TechniqueStatus} + for status, cnt in rows: + counts[status.value] = cnt + + total = sum(counts.values()) + validated = counts["validated"] + partial = counts["partial"] + + coverage_pct = ( + round((validated + partial) / total * 100, 2) if total > 0 else 0.0 + ) + + return CoverageSummary( + total_techniques=total, + validated=validated, + partial=partial, + not_covered=counts["not_covered"], + in_progress=counts["in_progress"], + not_evaluated=counts["not_evaluated"], + coverage_percentage=coverage_pct, + ) + + +# --------------------------------------------------------------------------- +# GET /metrics/by-tactic +# --------------------------------------------------------------------------- + + +@router.get("/by-tactic", response_model=list[TacticCoverage]) +def coverage_by_tactic( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return coverage breakdown grouped by tactic. + + Since a technique can belong to multiple tactics (stored as a + comma-separated string), the technique is counted once per tactic + it belongs to. + """ + + techniques = db.query( + Technique.tactic, Technique.status_global + ).all() + + # Accumulate per-tactic counters. A technique with tactic + # "persistence, privilege-escalation" is counted in both. + tactic_data: dict[str, dict[str, int]] = defaultdict( + lambda: {s.value: 0 for s in TechniqueStatus} + ) + + for tactic_str, status in techniques: + if not tactic_str: + tactics = ["unknown"] + else: + tactics = [t.strip() for t in tactic_str.split(",")] + + for tactic in tactics: + tactic_data[tactic][status.value] += 1 + + result = [] + for tactic in sorted(tactic_data): + counts = tactic_data[tactic] + total = sum(counts.values()) + result.append( + TacticCoverage( + tactic=tactic, + total=total, + validated=counts["validated"], + partial=counts["partial"], + not_covered=counts["not_covered"], + not_evaluated=counts["not_evaluated"], + in_progress=counts["in_progress"], + ) + ) + + return result diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py new file mode 100644 index 0000000..1ec2a64 --- /dev/null +++ b/backend/app/schemas/metrics.py @@ -0,0 +1,27 @@ +"""Pydantic schemas for coverage-metrics endpoints.""" + +from pydantic import BaseModel + + +class CoverageSummary(BaseModel): + """Global coverage summary across all MITRE ATT&CK techniques.""" + + total_techniques: int + validated: int + partial: int + not_covered: int + in_progress: int + not_evaluated: int + coverage_percentage: float # (validated + partial) / total * 100 + + +class TacticCoverage(BaseModel): + """Coverage breakdown for a single tactic.""" + + tactic: str + total: int + validated: int + partial: int + not_covered: int + not_evaluated: int + in_progress: int