feat: Phase 5 - Metrics and dashboard API (T-020)
- Add GET /metrics/summary endpoint with global coverage counts and percentage - Add GET /metrics/by-tactic endpoint with per-tactic coverage breakdown - Handle multi-tactic techniques (comma-separated) counting in each tactic - Add CoverageSummary and TacticCoverage Pydantic schemas - Update README with metrics endpoints and project structure
This commit is contained in:
@@ -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 |
|
| 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 |
|
| 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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -166,7 +172,8 @@ Aegis/
|
|||||||
│ │ ├── techniques.py # CRUD techniques (list, detail, create, update, review)
|
│ │ ├── techniques.py # CRUD techniques (list, detail, create, update, review)
|
||||||
│ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject)
|
│ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject)
|
||||||
│ │ ├── evidence.py # Upload evidence, presigned download
|
│ │ ├── 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)
|
│ ├── dependencies/ # FastAPI dependencies (DI)
|
||||||
│ │ └── auth.py # get_current_user, require_role, require_any_role
|
│ │ └── auth.py # get_current_user, require_role, require_any_role
|
||||||
│ ├── jobs/ # Background scheduled jobs
|
│ ├── jobs/ # Background scheduled jobs
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.routers import techniques as techniques_router
|
|||||||
from app.routers import tests as tests_router
|
from app.routers import tests as tests_router
|
||||||
from app.routers import evidence as evidence_router
|
from app.routers import evidence as evidence_router
|
||||||
from app.routers import system as system_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.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
|
||||||
|
|
||||||
@@ -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(tests_router.router, prefix="/api/v1")
|
||||||
app.include_router(evidence_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(system_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(metrics_router.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
119
backend/app/routers/metrics.py
Normal file
119
backend/app/routers/metrics.py
Normal file
@@ -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
|
||||||
27
backend/app/schemas/metrics.py
Normal file
27
backend/app/schemas/metrics.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user