feat(phase-37): timer pause/resume + professional reporting engine
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Pause/Resume timer:
- Add paused_at, red_paused_seconds, blue_paused_seconds fields to Test model
- Add pause_timer/resume_timer workflow functions with accumulated pause tracking
- Auto-resume on phase submit; subtract paused time from worklog duration
- Add POST /tests/{id}/pause-timer and resume-timer endpoints
- Update LiveTimer component with pause/resume button and paused visual state
- Wire pause/resume mutations through TestDetailPage and TestDetailHeader
Professional Reporting Engine - Fase 2:
- Add ReportEngine service with Jinja2 HTML rendering, WeasyPrint PDF, and docxtpl DOCX
- Add corporate CSS stylesheet with cover page, data tables, stats grid, findings
- Create purple_campaign, coverage_report, and executive_summary HTML templates
- Add report_generation_service collecting domain data for each report type
- Add professional_reports router: GET /reports/generate/purple-campaign/{id}, coverage-summary, executive-summary
- Add analytics router with flat JSON endpoints for PowerBI: /coverage, /tests, /trends, /operators
- Add advanced_metrics router: /coverage-by-tactic, /never-tested, /avg-validation-time, /detection-rate-trend
- Add weasyprint and docxtpl to requirements.txt
- Add REPORT_TEMPLATES_DIR, REPORT_OUTPUT_DIR, COMPANY_NAME, COMPANY_LOGO_PATH to config
This commit is contained in:
184
backend/app/routers/advanced_metrics.py
Normal file
184
backend/app/routers/advanced_metrics.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Advanced metrics endpoints — coverage by tactic, never-tested, avg validation time."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, case
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/metrics/advanced", tags=["advanced-metrics"])
|
||||
|
||||
|
||||
@router.get("/coverage-by-tactic")
|
||||
def coverage_by_tactic(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Coverage percentage broken down by MITRE ATT&CK tactic."""
|
||||
results = (
|
||||
db.query(
|
||||
Technique.tactic,
|
||||
func.count(Technique.id).label("total"),
|
||||
func.sum(
|
||||
case((Technique.status_global == "validated", 1), else_=0)
|
||||
).label("validated"),
|
||||
func.sum(
|
||||
case((Technique.status_global == "partial", 1), else_=0)
|
||||
).label("partial"),
|
||||
func.sum(
|
||||
case((Technique.status_global == "not_covered", 1), else_=0)
|
||||
).label("not_covered"),
|
||||
func.sum(
|
||||
case((Technique.status_global == "in_progress", 1), else_=0)
|
||||
).label("in_progress"),
|
||||
)
|
||||
.group_by(Technique.tactic)
|
||||
.order_by(Technique.tactic)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"tactic": r[0] or "Unknown",
|
||||
"total": r[1],
|
||||
"validated": int(r[2]),
|
||||
"partial": int(r[3]),
|
||||
"not_covered": int(r[4]),
|
||||
"in_progress": int(r[5]),
|
||||
"coverage_pct": round((int(r[2]) / r[1]) * 100, 1) if r[1] > 0 else 0,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
@router.get("/never-tested")
|
||||
def never_tested_techniques(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Techniques that have never had a test created."""
|
||||
tested_technique_ids = (
|
||||
db.query(Test.technique_id).distinct().subquery()
|
||||
)
|
||||
techniques = (
|
||||
db.query(Technique)
|
||||
.filter(~Technique.id.in_(db.query(tested_technique_ids)))
|
||||
.order_by(Technique.mitre_id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"mitre_id": t.mitre_id,
|
||||
"name": t.name,
|
||||
"tactic": t.tactic,
|
||||
"is_subtechnique": t.is_subtechnique,
|
||||
}
|
||||
for t in techniques
|
||||
]
|
||||
|
||||
|
||||
@router.get("/avg-validation-time")
|
||||
def avg_validation_time(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Average time from test creation to validation, computed from audit logs.
|
||||
|
||||
Returns overall average and per-phase averages where data is available.
|
||||
"""
|
||||
validated_tests = (
|
||||
db.query(Test)
|
||||
.filter(Test.state == "validated")
|
||||
.all()
|
||||
)
|
||||
|
||||
if not validated_tests:
|
||||
return {
|
||||
"total_validated": 0,
|
||||
"avg_total_hours": 0,
|
||||
"avg_red_phase_hours": 0,
|
||||
"avg_blue_phase_hours": 0,
|
||||
}
|
||||
|
||||
total_durations = []
|
||||
red_durations = []
|
||||
blue_durations = []
|
||||
|
||||
for test in validated_tests:
|
||||
if test.created_at and test.red_validated_at:
|
||||
total_seconds = (test.red_validated_at - test.created_at).total_seconds()
|
||||
total_durations.append(total_seconds)
|
||||
|
||||
if test.red_started_at and test.blue_started_at:
|
||||
red_sec = (test.blue_started_at - test.red_started_at).total_seconds()
|
||||
red_paused = test.red_paused_seconds or 0
|
||||
red_durations.append(max(red_sec - red_paused, 0))
|
||||
|
||||
if test.blue_started_at and test.blue_validated_at:
|
||||
blue_sec = (test.blue_validated_at - test.blue_started_at).total_seconds()
|
||||
blue_paused = test.blue_paused_seconds or 0
|
||||
blue_durations.append(max(blue_sec - blue_paused, 0))
|
||||
|
||||
def avg_hours(durations: list[float]) -> float:
|
||||
if not durations:
|
||||
return 0
|
||||
return round(sum(durations) / len(durations) / 3600, 2)
|
||||
|
||||
return {
|
||||
"total_validated": len(validated_tests),
|
||||
"avg_total_hours": avg_hours(total_durations),
|
||||
"avg_red_phase_hours": avg_hours(red_durations),
|
||||
"avg_blue_phase_hours": avg_hours(blue_durations),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/detection-rate-trend")
|
||||
def detection_rate_trend(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Monthly detection rate trend for the last 12 months."""
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.utcnow()
|
||||
months = []
|
||||
|
||||
for i in range(11, -1, -1):
|
||||
month_start = datetime(now.year, now.month, 1) - timedelta(days=i * 30)
|
||||
month_end = month_start + timedelta(days=30)
|
||||
|
||||
validated = (
|
||||
db.query(func.count(Test.id))
|
||||
.filter(
|
||||
Test.state == "validated",
|
||||
Test.created_at >= month_start,
|
||||
Test.created_at < month_end,
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
detected = (
|
||||
db.query(func.count(Test.id))
|
||||
.filter(
|
||||
Test.state == "validated",
|
||||
Test.detection_result == "detected",
|
||||
Test.created_at >= month_start,
|
||||
Test.created_at < month_end,
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
months.append({
|
||||
"month": month_start.strftime("%Y-%m"),
|
||||
"validated": validated,
|
||||
"detected": detected,
|
||||
"detection_rate": round((detected / validated) * 100, 1) if validated > 0 else 0,
|
||||
})
|
||||
|
||||
return months
|
||||
127
backend/app/routers/analytics.py
Normal file
127
backend/app/routers/analytics.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Analytics endpoints — flat JSON optimized for PowerBI / BI tools.
|
||||
|
||||
Returns complete datasets without pagination so BI tools can ingest
|
||||
directly from URL. All endpoints require authentication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.models.coverage_snapshot import CoverageSnapshot
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
@router.get("/coverage")
|
||||
def analytics_coverage(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Coverage per technique — flat format for BI dashboards."""
|
||||
techniques = db.query(Technique).all()
|
||||
return [
|
||||
{
|
||||
"mitre_id": t.mitre_id,
|
||||
"name": t.name,
|
||||
"tactic": t.tactic,
|
||||
"status": t.status_global.value if t.status_global else "not_evaluated",
|
||||
"is_subtechnique": t.is_subtechnique,
|
||||
"test_count": len(t.tests) if t.tests else 0,
|
||||
"review_required": t.review_required,
|
||||
"last_review_date": (
|
||||
t.last_review_date.isoformat() if t.last_review_date else None
|
||||
),
|
||||
}
|
||||
for t in techniques
|
||||
]
|
||||
|
||||
|
||||
@router.get("/tests")
|
||||
def analytics_tests(
|
||||
date_from: str = Query(None, description="ISO date filter (>=)"),
|
||||
date_to: str = Query(None, description="ISO date filter (<=)"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All tests with timestamps — flat format for BI dashboards."""
|
||||
query = db.query(Test)
|
||||
if date_from:
|
||||
query = query.filter(Test.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Test.created_at <= date_to)
|
||||
tests = query.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(t.id),
|
||||
"technique_id": str(t.technique_id),
|
||||
"name": t.name,
|
||||
"state": t.state.value if t.state else None,
|
||||
"result": t.result.value if t.result else None,
|
||||
"detection_result": (
|
||||
t.detection_result.value if t.detection_result else None
|
||||
),
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"execution_date": (
|
||||
t.execution_date.isoformat() if t.execution_date else None
|
||||
),
|
||||
"platform": t.platform,
|
||||
"tool_used": t.tool_used,
|
||||
"attack_success": t.attack_success,
|
||||
"remediation_status": t.remediation_status,
|
||||
}
|
||||
for t in tests
|
||||
]
|
||||
|
||||
|
||||
@router.get("/trends")
|
||||
def analytics_trends(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Historical coverage snapshots for trend visualization."""
|
||||
snapshots = (
|
||||
db.query(CoverageSnapshot)
|
||||
.order_by(CoverageSnapshot.created_at)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"date": s.created_at.isoformat() if s.created_at else None,
|
||||
"name": s.name,
|
||||
"total_techniques": s.total_techniques,
|
||||
"validated_count": s.validated_count,
|
||||
"partial_count": s.partial_count,
|
||||
"not_covered_count": s.not_covered_count,
|
||||
"organization_score": s.organization_score,
|
||||
}
|
||||
for s in snapshots
|
||||
]
|
||||
|
||||
|
||||
@router.get("/operators")
|
||||
def analytics_operators(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
):
|
||||
"""Per-operator metrics — for workload management dashboards."""
|
||||
results = (
|
||||
db.query(
|
||||
User.username,
|
||||
User.role,
|
||||
func.count(Test.id).label("test_count"),
|
||||
)
|
||||
.outerjoin(Test, Test.created_by == User.id)
|
||||
.group_by(User.id, User.username, User.role)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"username": r[0], "role": r[1], "test_count": r[2]}
|
||||
for r in results
|
||||
]
|
||||
72
backend/app/routers/professional_reports.py
Normal file
72
backend/app/routers/professional_reports.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Professional report generation endpoints — PDF, DOCX, HTML output."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.models.user import User
|
||||
from app.services import report_generation_service
|
||||
|
||||
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||
|
||||
_MEDIA_TYPES = {
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"html": "text/html",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/purple-campaign/{campaign_id}")
|
||||
def generate_purple_report(
|
||||
campaign_id: UUID,
|
||||
format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
):
|
||||
"""Generate a Purple Team campaign assessment report."""
|
||||
filepath = report_generation_service.generate_purple_campaign_report(
|
||||
db, str(campaign_id), output_format=format,
|
||||
)
|
||||
return FileResponse(
|
||||
filepath,
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
filename=f"purple_report.{format}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/coverage-summary")
|
||||
def generate_coverage_report(
|
||||
format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
||||
filepath = report_generation_service.generate_coverage_report(
|
||||
db, output_format=format,
|
||||
)
|
||||
return FileResponse(
|
||||
filepath,
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
filename=f"coverage_report.{format}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/executive-summary")
|
||||
def generate_executive_report(
|
||||
format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
):
|
||||
"""Generate an executive security summary report."""
|
||||
filepath = report_generation_service.generate_executive_summary(
|
||||
db, output_format=format,
|
||||
)
|
||||
return FileResponse(
|
||||
filepath,
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
filename=f"executive_summary.{format}",
|
||||
)
|
||||
@@ -54,6 +54,8 @@ from app.services.test_workflow_service import (
|
||||
reopen_test as wf_reopen,
|
||||
handle_remediation_completed as wf_handle_remediation,
|
||||
get_retest_chain as wf_get_retest_chain,
|
||||
pause_timer as wf_pause_timer,
|
||||
resume_timer as wf_resume_timer,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tests", tags=["tests"])
|
||||
@@ -473,6 +475,42 @@ def submit_blue(
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/pause-timer — pause the active phase timer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/pause-timer", response_model=TestOut)
|
||||
def pause_timer(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Pause the running timer for the current phase (red_executing or blue_evaluating)."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
test = wf_pause_timer(db, test, current_user)
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/resume-timer — resume a paused phase timer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{test_id}/resume-timer", response_model=TestOut)
|
||||
def resume_timer(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Resume the paused timer for the current phase."""
|
||||
test = _get_test_or_404(db, test_id)
|
||||
test = wf_resume_timer(db, test, current_user)
|
||||
db.refresh(test)
|
||||
return test
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/validate-red — Red Lead validates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user