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:
@@ -20,7 +20,10 @@ def upgrade() -> None:
|
|||||||
op.execute("""
|
op.execute("""
|
||||||
ALTER TABLE tests
|
ALTER TABLE tests
|
||||||
ADD COLUMN IF NOT EXISTS red_started_at TIMESTAMP,
|
ADD COLUMN IF NOT EXISTS red_started_at TIMESTAMP,
|
||||||
ADD COLUMN IF NOT EXISTS blue_started_at TIMESTAMP;
|
ADD COLUMN IF NOT EXISTS blue_started_at TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS paused_at TIMESTAMP,
|
||||||
|
ADD COLUMN IF NOT EXISTS red_paused_seconds INTEGER DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS blue_paused_seconds INTEGER DEFAULT 0;
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
@@ -28,5 +31,8 @@ def downgrade() -> None:
|
|||||||
op.execute("""
|
op.execute("""
|
||||||
ALTER TABLE tests
|
ALTER TABLE tests
|
||||||
DROP COLUMN IF EXISTS red_started_at,
|
DROP COLUMN IF EXISTS red_started_at,
|
||||||
DROP COLUMN IF EXISTS blue_started_at;
|
DROP COLUMN IF EXISTS blue_started_at,
|
||||||
|
DROP COLUMN IF EXISTS paused_at,
|
||||||
|
DROP COLUMN IF EXISTS red_paused_seconds,
|
||||||
|
DROP COLUMN IF EXISTS blue_paused_seconds;
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ class Settings(BaseSettings):
|
|||||||
TEMPO_API_TOKEN: str = ""
|
TEMPO_API_TOKEN: str = ""
|
||||||
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
|
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
|
||||||
|
|
||||||
|
# ── Reporting ─────────────────────────────────────────────────────
|
||||||
|
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
||||||
|
REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
|
||||||
|
COMPANY_NAME: str = "Organization"
|
||||||
|
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
|
||||||
|
|
||||||
# ── Scoring weights (must sum to 100) ────────────────────────────
|
# ── Scoring weights (must sum to 100) ────────────────────────────
|
||||||
SCORING_WEIGHT_TESTS: int = 40
|
SCORING_WEIGHT_TESTS: int = 40
|
||||||
SCORING_WEIGHT_DETECTION_RULES: int = 20
|
SCORING_WEIGHT_DETECTION_RULES: int = 20
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ from app.routers import compliance as compliance_router
|
|||||||
from app.routers import snapshots as snapshots_router
|
from app.routers import snapshots as snapshots_router
|
||||||
from app.routers import jira as jira_router
|
from app.routers import jira as jira_router
|
||||||
from app.routers import worklogs as worklogs_router
|
from app.routers import worklogs as worklogs_router
|
||||||
|
from app.routers import professional_reports as professional_reports_router
|
||||||
|
from app.routers import analytics as analytics_router
|
||||||
|
from app.routers import advanced_metrics as advanced_metrics_router
|
||||||
from app.domain.exceptions import DomainException
|
from app.domain.exceptions import DomainException
|
||||||
from app.middleware.error_handler import domain_exception_handler
|
from app.middleware.error_handler import domain_exception_handler
|
||||||
from app.storage import ensure_bucket_exists
|
from app.storage import ensure_bucket_exists
|
||||||
@@ -114,6 +117,9 @@ app.include_router(compliance_router.router, prefix="/api/v1")
|
|||||||
app.include_router(snapshots_router.router, prefix="/api/v1")
|
app.include_router(snapshots_router.router, prefix="/api/v1")
|
||||||
app.include_router(jira_router.router, prefix="/api/v1")
|
app.include_router(jira_router.router, prefix="/api/v1")
|
||||||
app.include_router(worklogs_router.router, prefix="/api/v1")
|
app.include_router(worklogs_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(professional_reports_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(analytics_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", include_in_schema=False)
|
@app.get("/health", include_in_schema=False)
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class Test(Base):
|
|||||||
# ── Phase timing fields (for automatic Tempo worklogs) ──────────
|
# ── Phase timing fields (for automatic Tempo worklogs) ──────────
|
||||||
red_started_at = Column(DateTime, nullable=True)
|
red_started_at = Column(DateTime, nullable=True)
|
||||||
blue_started_at = Column(DateTime, nullable=True)
|
blue_started_at = Column(DateTime, nullable=True)
|
||||||
|
paused_at = Column(DateTime, nullable=True)
|
||||||
|
red_paused_seconds = Column(Integer, default=0)
|
||||||
|
blue_paused_seconds = Column(Integer, default=0)
|
||||||
|
|
||||||
# ── Remediation fields ───────────────────────────────────────────
|
# ── Remediation fields ───────────────────────────────────────────
|
||||||
remediation_steps = Column(Text, nullable=True)
|
remediation_steps = Column(Text, nullable=True)
|
||||||
|
|||||||
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,
|
reopen_test as wf_reopen,
|
||||||
handle_remediation_completed as wf_handle_remediation,
|
handle_remediation_completed as wf_handle_remediation,
|
||||||
get_retest_chain as wf_get_retest_chain,
|
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"])
|
router = APIRouter(prefix="/tests", tags=["tests"])
|
||||||
@@ -473,6 +475,42 @@ def submit_blue(
|
|||||||
return test
|
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
|
# POST /tests/{id}/validate-red — Red Lead validates
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ class TestOut(BaseModel):
|
|||||||
# Phase timing fields (for Tempo worklogs)
|
# Phase timing fields (for Tempo worklogs)
|
||||||
red_started_at: datetime | None = None
|
red_started_at: datetime | None = None
|
||||||
blue_started_at: datetime | None = None
|
blue_started_at: datetime | None = None
|
||||||
|
paused_at: datetime | None = None
|
||||||
|
red_paused_seconds: int = 0
|
||||||
|
blue_paused_seconds: int = 0
|
||||||
|
|
||||||
# Remediation fields
|
# Remediation fields
|
||||||
remediation_steps: str | None = None
|
remediation_steps: str | None = None
|
||||||
|
|||||||
93
backend/app/services/report_engine.py
Normal file
93
backend/app/services/report_engine.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Report engine — renders Jinja2 HTML templates to PDF, DOCX, and HTML.
|
||||||
|
|
||||||
|
Uses WeasyPrint for PDF generation and docxtpl for DOCX.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportEngine:
|
||||||
|
"""Template-based report generator supporting PDF, DOCX, and HTML output."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader(settings.REPORT_TEMPLATES_DIR),
|
||||||
|
autoescape=True,
|
||||||
|
)
|
||||||
|
os.makedirs(settings.REPORT_OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
def render_html(self, template_name: str, context: dict) -> str:
|
||||||
|
"""Render a Jinja2 template to an HTML string."""
|
||||||
|
template = self.jinja_env.get_template(f"{template_name}.html")
|
||||||
|
context.setdefault("company_name", settings.COMPANY_NAME)
|
||||||
|
context.setdefault("generated_at", datetime.utcnow().strftime("%B %d, %Y %H:%M UTC"))
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
def generate_pdf(self, template_name: str, context: dict) -> str:
|
||||||
|
"""Render HTML and convert to PDF with WeasyPrint."""
|
||||||
|
from weasyprint import HTML, CSS
|
||||||
|
|
||||||
|
html_content = self.render_html(template_name, context)
|
||||||
|
css_path = os.path.join(settings.REPORT_TEMPLATES_DIR, "styles", "report.css")
|
||||||
|
output_path = os.path.join(
|
||||||
|
settings.REPORT_OUTPUT_DIR,
|
||||||
|
f"{template_name}_{uuid.uuid4().hex[:8]}.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
stylesheets = []
|
||||||
|
if os.path.exists(css_path):
|
||||||
|
stylesheets.append(CSS(filename=css_path))
|
||||||
|
|
||||||
|
HTML(
|
||||||
|
string=html_content,
|
||||||
|
base_url=settings.REPORT_TEMPLATES_DIR,
|
||||||
|
).write_pdf(output_path, stylesheets=stylesheets)
|
||||||
|
|
||||||
|
logger.info("PDF generated: %s", output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def generate_docx(self, template_name: str, context: dict) -> str:
|
||||||
|
"""Render a .docx template with docxtpl."""
|
||||||
|
from docxtpl import DocxTemplate
|
||||||
|
|
||||||
|
template_path = os.path.join(
|
||||||
|
settings.REPORT_TEMPLATES_DIR, f"{template_name}.docx"
|
||||||
|
)
|
||||||
|
output_path = os.path.join(
|
||||||
|
settings.REPORT_OUTPUT_DIR,
|
||||||
|
f"{template_name}_{uuid.uuid4().hex[:8]}.docx",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = DocxTemplate(template_path)
|
||||||
|
context.setdefault("company_name", settings.COMPANY_NAME)
|
||||||
|
context.setdefault("generated_at", datetime.utcnow().strftime("%B %d, %Y"))
|
||||||
|
doc.render(context)
|
||||||
|
doc.save(output_path)
|
||||||
|
|
||||||
|
logger.info("DOCX generated: %s", output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def generate_html_file(self, template_name: str, context: dict) -> str:
|
||||||
|
"""Render and save a standalone HTML report."""
|
||||||
|
html_content = self.render_html(template_name, context)
|
||||||
|
output_path = os.path.join(
|
||||||
|
settings.REPORT_OUTPUT_DIR,
|
||||||
|
f"{template_name}_{uuid.uuid4().hex[:8]}.html",
|
||||||
|
)
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_content)
|
||||||
|
|
||||||
|
logger.info("HTML report generated: %s", output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
report_engine = ReportEngine()
|
||||||
250
backend/app/services/report_generation_service.py
Normal file
250
backend/app/services/report_generation_service.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""High-level report generation — collects domain data and delegates to ReportEngine."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.domain.exceptions import EntityNotFoundError
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
|
from app.models.coverage_snapshot import CoverageSnapshot
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.threat_actor import ThreatActor
|
||||||
|
from app.services.report_engine import report_engine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_purple_campaign_report(
|
||||||
|
db: Session,
|
||||||
|
campaign_id: str,
|
||||||
|
output_format: str = "pdf",
|
||||||
|
) -> str:
|
||||||
|
"""Generate the full Purple Team campaign report."""
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||||
|
if not campaign:
|
||||||
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
|
campaign_tests = (
|
||||||
|
db.query(Test)
|
||||||
|
.join(CampaignTest, CampaignTest.test_id == Test.id)
|
||||||
|
.filter(CampaignTest.campaign_id == campaign_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
tests_data = []
|
||||||
|
for test in campaign_tests:
|
||||||
|
technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
|
||||||
|
tests_data.append({
|
||||||
|
"technique_mitre_id": technique.mitre_id if technique else "N/A",
|
||||||
|
"name": test.name,
|
||||||
|
"tactic": technique.tactic if technique else "N/A",
|
||||||
|
"state": test.state.value if test.state else "draft",
|
||||||
|
"detection_result": (
|
||||||
|
test.detection_result.value if test.detection_result else "pending"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
validated = [t for t in campaign_tests if t.state and t.state.value == "validated"]
|
||||||
|
detected = [
|
||||||
|
t for t in validated
|
||||||
|
if t.detection_result and t.detection_result.value == "detected"
|
||||||
|
]
|
||||||
|
not_detected = [
|
||||||
|
t for t in validated
|
||||||
|
if t.detection_result and t.detection_result.value == "not_detected"
|
||||||
|
]
|
||||||
|
|
||||||
|
critical_findings = [
|
||||||
|
{
|
||||||
|
"technique_id": t["technique_mitre_id"],
|
||||||
|
"name": t["name"],
|
||||||
|
"severity": "critical",
|
||||||
|
"description": "Technique was not detected during campaign execution.",
|
||||||
|
"recommendation": "Implement detection rule or review existing SIEM/EDR configuration.",
|
||||||
|
}
|
||||||
|
for t in tests_data
|
||||||
|
if t["detection_result"] == "not_detected"
|
||||||
|
]
|
||||||
|
|
||||||
|
org_score = _safe_org_score(db)
|
||||||
|
|
||||||
|
threat_actors = []
|
||||||
|
if campaign.threat_actor_id:
|
||||||
|
actor = db.query(ThreatActor).filter(ThreatActor.id == campaign.threat_actor_id).first()
|
||||||
|
if actor:
|
||||||
|
threat_actors = [{"name": actor.name}]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"campaign": campaign,
|
||||||
|
"tests": tests_data,
|
||||||
|
"tests_validated": len(validated),
|
||||||
|
"tests_detected": len(detected),
|
||||||
|
"tests_not_detected": len(not_detected),
|
||||||
|
"critical_findings": critical_findings,
|
||||||
|
"org_score": org_score.get("overall", 0),
|
||||||
|
"tactics": list({t["tactic"] for t in tests_data}),
|
||||||
|
"threat_actors": threat_actors,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _generate(output_format, "purple_campaign", context)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_coverage_report(
|
||||||
|
db: Session,
|
||||||
|
output_format: str = "pdf",
|
||||||
|
) -> str:
|
||||||
|
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
org_score = _safe_org_score(db)
|
||||||
|
|
||||||
|
techniques = db.query(Technique).all()
|
||||||
|
status_counts = {"validated": 0, "partial": 0, "not_covered": 0, "in_progress": 0, "not_evaluated": 0}
|
||||||
|
for t in techniques:
|
||||||
|
s = t.status_global.value if t.status_global else "not_evaluated"
|
||||||
|
if s in status_counts:
|
||||||
|
status_counts[s] += 1
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"total_techniques": len(techniques),
|
||||||
|
**status_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Coverage by tactic
|
||||||
|
tactic_rows = (
|
||||||
|
db.query(
|
||||||
|
Technique.tactic,
|
||||||
|
func.count(Technique.id).label("total"),
|
||||||
|
func.sum(case((Technique.status_global == "validated", 1), else_=0)).label("validated"),
|
||||||
|
)
|
||||||
|
.group_by(Technique.tactic)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tactics_coverage = [
|
||||||
|
{
|
||||||
|
"tactic": r[0] or "Unknown",
|
||||||
|
"total": r[1],
|
||||||
|
"validated": int(r[2]),
|
||||||
|
"coverage_pct": round((int(r[2]) / r[1]) * 100, 1) if r[1] > 0 else 0,
|
||||||
|
}
|
||||||
|
for r in tactic_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# Never-tested techniques
|
||||||
|
tested_ids = {t.technique_id for t in db.query(Test.technique_id).distinct().all()}
|
||||||
|
never_tested = [
|
||||||
|
{"mitre_id": t.mitre_id, "name": t.name, "tactic": t.tactic}
|
||||||
|
for t in techniques
|
||||||
|
if t.id not in tested_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"org_score": org_score,
|
||||||
|
"summary": summary,
|
||||||
|
"tactics_coverage": tactics_coverage,
|
||||||
|
"never_tested": never_tested[:50],
|
||||||
|
}
|
||||||
|
|
||||||
|
return _generate(output_format, "coverage_report", context)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_executive_summary(
|
||||||
|
db: Session,
|
||||||
|
output_format: str = "pdf",
|
||||||
|
) -> str:
|
||||||
|
"""Generate an executive summary report."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
org_score = _safe_org_score(db)
|
||||||
|
techniques = db.query(Technique).all()
|
||||||
|
|
||||||
|
status_counts = {"validated": 0, "partial": 0, "not_covered": 0, "in_progress": 0, "not_evaluated": 0}
|
||||||
|
for t in techniques:
|
||||||
|
s = t.status_global.value if t.status_global else "not_evaluated"
|
||||||
|
if s in status_counts:
|
||||||
|
status_counts[s] += 1
|
||||||
|
|
||||||
|
summary = {"total_techniques": len(techniques), **status_counts}
|
||||||
|
|
||||||
|
total_tests = db.query(func.count(Test.id)).scalar() or 0
|
||||||
|
active_campaigns = (
|
||||||
|
db.query(func.count(Campaign.id)).filter(Campaign.status == "active").scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
quarter_ago = datetime.utcnow() - timedelta(days=90)
|
||||||
|
tests_this_quarter = (
|
||||||
|
db.query(func.count(Test.id)).filter(Test.created_at >= quarter_ago).scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
open_remediations = (
|
||||||
|
db.query(func.count(Test.id))
|
||||||
|
.filter(Test.remediation_status.in_(["pending", "in_progress"]))
|
||||||
|
.scalar() or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detection rate among validated tests
|
||||||
|
validated_count = status_counts["validated"]
|
||||||
|
detected_count = (
|
||||||
|
db.query(func.count(Test.id))
|
||||||
|
.filter(Test.state == "validated", Test.detection_result == "detected")
|
||||||
|
.scalar() or 0
|
||||||
|
)
|
||||||
|
detection_rate = round((detected_count / validated_count) * 100, 1) if validated_count > 0 else 0
|
||||||
|
|
||||||
|
# Top gaps — lowest coverage tactics
|
||||||
|
from sqlalchemy import case as sql_case
|
||||||
|
tactic_rows = (
|
||||||
|
db.query(
|
||||||
|
Technique.tactic,
|
||||||
|
func.count(Technique.id).label("total"),
|
||||||
|
func.sum(sql_case((Technique.status_global == "validated", 1), else_=0)).label("validated"),
|
||||||
|
)
|
||||||
|
.group_by(Technique.tactic)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tactic_coverage = [
|
||||||
|
{
|
||||||
|
"tactic": r[0] or "Unknown",
|
||||||
|
"coverage_pct": round((int(r[2]) / r[1]) * 100, 1) if r[1] > 0 else 0,
|
||||||
|
}
|
||||||
|
for r in tactic_rows
|
||||||
|
]
|
||||||
|
top_gaps = sorted(tactic_coverage, key=lambda x: x["coverage_pct"])[:5]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"org_score": org_score,
|
||||||
|
"summary": summary,
|
||||||
|
"total_tests": total_tests,
|
||||||
|
"active_campaigns": active_campaigns,
|
||||||
|
"tests_this_quarter": tests_this_quarter,
|
||||||
|
"open_remediations": open_remediations,
|
||||||
|
"detection_rate": detection_rate,
|
||||||
|
"top_gaps": top_gaps,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _generate(output_format, "executive_summary", context)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_org_score(db: Session) -> dict:
|
||||||
|
"""Safely call the scoring service; return empty dict on failure."""
|
||||||
|
try:
|
||||||
|
from app.services.scoring_service import calculate_organization_score
|
||||||
|
return calculate_organization_score(db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Scoring service unavailable: %s", e)
|
||||||
|
return {"overall": 0, "coverage": 0, "detection_maturity": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(output_format: str, template_name: str, context: dict) -> str:
|
||||||
|
"""Dispatch to the correct ReportEngine method."""
|
||||||
|
if output_format == "pdf":
|
||||||
|
return report_engine.generate_pdf(template_name, context)
|
||||||
|
elif output_format == "docx":
|
||||||
|
return report_engine.generate_docx(template_name, context)
|
||||||
|
else:
|
||||||
|
return report_engine.generate_html_file(template_name, context)
|
||||||
@@ -135,24 +135,32 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""
|
"""
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Auto-resume if paused
|
||||||
|
paused_extra = 0
|
||||||
|
if test.paused_at is not None:
|
||||||
|
paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
||||||
|
test.paused_at = None
|
||||||
|
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.blue_evaluating, user,
|
db, test, TestState.blue_evaluating, user,
|
||||||
action_name="submit_red_evidence",
|
action_name="submit_red_evidence",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create automatic worklog for Red Team phase
|
# Create automatic worklog for Red Team phase (subtract paused time)
|
||||||
_create_phase_worklog(
|
_create_phase_worklog(
|
||||||
db,
|
db,
|
||||||
test=test,
|
test=test,
|
||||||
user=user,
|
user=user,
|
||||||
phase_started_at=test.red_started_at,
|
phase_started_at=test.red_started_at,
|
||||||
phase_ended_at=now,
|
phase_ended_at=now,
|
||||||
|
paused_seconds=(test.red_paused_seconds or 0) + paused_extra,
|
||||||
activity_type="red_team_execution",
|
activity_type="red_team_execution",
|
||||||
description=f"Red Team execution: {test.name}",
|
description=f"Red Team execution: {test.name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start Blue Team timer
|
# Start Blue Team timer
|
||||||
test.blue_started_at = now
|
test.blue_started_at = now
|
||||||
|
test.blue_paused_seconds = 0
|
||||||
db.commit()
|
db.commit()
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@@ -165,18 +173,25 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
"""
|
"""
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Auto-resume if paused
|
||||||
|
paused_extra = 0
|
||||||
|
if test.paused_at is not None:
|
||||||
|
paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
||||||
|
test.paused_at = None
|
||||||
|
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.in_review, user,
|
db, test, TestState.in_review, user,
|
||||||
action_name="submit_blue_evidence",
|
action_name="submit_blue_evidence",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create automatic worklog for Blue Team phase
|
# Create automatic worklog for Blue Team phase (subtract paused time)
|
||||||
_create_phase_worklog(
|
_create_phase_worklog(
|
||||||
db,
|
db,
|
||||||
test=test,
|
test=test,
|
||||||
user=user,
|
user=user,
|
||||||
phase_started_at=test.blue_started_at,
|
phase_started_at=test.blue_started_at,
|
||||||
phase_ended_at=now,
|
phase_ended_at=now,
|
||||||
|
paused_seconds=(test.blue_paused_seconds or 0) + paused_extra,
|
||||||
activity_type="blue_team_evaluation",
|
activity_type="blue_team_evaluation",
|
||||||
description=f"Blue Team evaluation: {test.name}",
|
description=f"Blue Team evaluation: {test.name}",
|
||||||
)
|
)
|
||||||
@@ -185,6 +200,62 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def pause_timer(db: Session, test: Test, user: User) -> Test:
|
||||||
|
"""Pause the active phase timer.
|
||||||
|
|
||||||
|
Can only be called when the test is in ``red_executing`` or
|
||||||
|
``blue_evaluating`` and is not already paused.
|
||||||
|
"""
|
||||||
|
if test.state not in (TestState.red_executing, TestState.blue_evaluating):
|
||||||
|
raise InvalidOperationError(
|
||||||
|
f"Cannot pause timer in '{test.state.value}' state"
|
||||||
|
)
|
||||||
|
if test.paused_at is not None:
|
||||||
|
raise InvalidOperationError("Timer is already paused")
|
||||||
|
|
||||||
|
test.paused_at = datetime.utcnow()
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
action="pause_timer",
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
details={"state": test.state.value},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def resume_timer(db: Session, test: Test, user: User) -> Test:
|
||||||
|
"""Resume a paused phase timer.
|
||||||
|
|
||||||
|
Accumulates the paused duration into the appropriate counter so
|
||||||
|
it is subtracted from the final worklog.
|
||||||
|
"""
|
||||||
|
if test.paused_at is None:
|
||||||
|
raise InvalidOperationError("Timer is not paused")
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
paused_seconds = max(int((now - test.paused_at).total_seconds()), 0)
|
||||||
|
|
||||||
|
if test.state == TestState.red_executing:
|
||||||
|
test.red_paused_seconds = (test.red_paused_seconds or 0) + paused_seconds
|
||||||
|
elif test.state == TestState.blue_evaluating:
|
||||||
|
test.blue_paused_seconds = (test.blue_paused_seconds or 0) + paused_seconds
|
||||||
|
|
||||||
|
test.paused_at = None
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
action="resume_timer",
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
details={"paused_seconds": paused_seconds, "state": test.state.value},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
def _create_phase_worklog(
|
def _create_phase_worklog(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
@@ -192,11 +263,14 @@ def _create_phase_worklog(
|
|||||||
user: User,
|
user: User,
|
||||||
phase_started_at: datetime | None,
|
phase_started_at: datetime | None,
|
||||||
phase_ended_at: datetime,
|
phase_ended_at: datetime,
|
||||||
|
paused_seconds: int = 0,
|
||||||
activity_type: str,
|
activity_type: str,
|
||||||
description: str,
|
description: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create an automatic, integrity-hashed worklog for a completed phase.
|
"""Create an automatic, integrity-hashed worklog for a completed phase.
|
||||||
|
|
||||||
|
Subtracts accumulated *paused_seconds* from the gross elapsed time
|
||||||
|
so the worklog reflects only active working time.
|
||||||
Also triggers Tempo sync if the test has a Jira link.
|
Also triggers Tempo sync if the test has a Jira link.
|
||||||
"""
|
"""
|
||||||
if not phase_started_at:
|
if not phase_started_at:
|
||||||
@@ -206,7 +280,8 @@ def _create_phase_worklog(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
duration_seconds = max(int((phase_ended_at - phase_started_at).total_seconds()), 1)
|
gross_seconds = int((phase_ended_at - phase_started_at).total_seconds())
|
||||||
|
duration_seconds = max(gross_seconds - paused_seconds, 1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services.worklog_service import create_worklog
|
from app.services.worklog_service import create_worklog
|
||||||
@@ -520,6 +595,9 @@ def reopen_test(db: Session, test: Test, user: User) -> Test:
|
|||||||
# Clear phase timing fields
|
# Clear phase timing fields
|
||||||
test.red_started_at = None
|
test.red_started_at = None
|
||||||
test.blue_started_at = None
|
test.blue_started_at = None
|
||||||
|
test.paused_at = None
|
||||||
|
test.red_paused_seconds = 0
|
||||||
|
test.blue_paused_seconds = 0
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return test
|
return test
|
||||||
|
|||||||
4
backend/app/templates/reports/assets/logo.svg
Normal file
4
backend/app/templates/reports/assets/logo.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 60" width="200" height="60">
|
||||||
|
<rect width="200" height="60" rx="8" fill="#0e7490"/>
|
||||||
|
<text x="100" y="38" fill="white" font-family="Arial,sans-serif" font-size="28" font-weight="bold" text-anchor="middle">AEGIS</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 284 B |
119
backend/app/templates/reports/coverage_report.html
Normal file
119
backend/app/templates/reports/coverage_report.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="styles/report.css">
|
||||||
|
<title>Coverage Report — {{ company_name }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="cover-page">
|
||||||
|
<img src="assets/logo.png" class="logo" alt="Logo">
|
||||||
|
<h1>MITRE ATT&CK Coverage Report</h1>
|
||||||
|
<h2>{{ company_name }}</h2>
|
||||||
|
<p class="date">{{ generated_at }}</p>
|
||||||
|
<p class="classification">{{ classification | default('INTERNAL') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>1. Organization Score</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ org_score.overall | default(0) }}%</span>
|
||||||
|
<span class="label">Overall Score</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ org_score.coverage | default(0) }}%</span>
|
||||||
|
<span class="label">Coverage</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ org_score.detection_maturity | default(0) }}%</span>
|
||||||
|
<span class="label">Detection Maturity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>2. Coverage Summary</h2>
|
||||||
|
<div class="metric-cards">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">{{ summary.total_techniques }}</div>
|
||||||
|
<div class="label">Total Techniques</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">{{ summary.validated }}</div>
|
||||||
|
<div class="label">Validated</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">{{ summary.partial }}</div>
|
||||||
|
<div class="label">Partial</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">{{ summary.not_covered }}</div>
|
||||||
|
<div class="label">Not Covered</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">{{ summary.in_progress }}</div>
|
||||||
|
<div class="label">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="value">{{ summary.not_evaluated }}</div>
|
||||||
|
<div class="label">Not Evaluated</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>3. Coverage by Tactic</h2>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tactic</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Validated</th>
|
||||||
|
<th>Coverage %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tactic in tactics_coverage %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ tactic.tactic }}</td>
|
||||||
|
<td>{{ tactic.total }}</td>
|
||||||
|
<td>{{ tactic.validated }}</td>
|
||||||
|
<td>{{ tactic.coverage_pct }}%</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>4. Never-Tested Techniques</h2>
|
||||||
|
{% if never_tested %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>MITRE ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Tactic</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in never_tested %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.mitre_id }}</td>
|
||||||
|
<td>{{ t.name }}</td>
|
||||||
|
<td>{{ t.tactic }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>All techniques have been tested at least once.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>{{ company_name }} — Confidential</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
backend/app/templates/reports/executive_summary.html
Normal file
74
backend/app/templates/reports/executive_summary.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="styles/report.css">
|
||||||
|
<title>Executive Summary — {{ company_name }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="cover-page">
|
||||||
|
<img src="assets/logo.png" class="logo" alt="Logo">
|
||||||
|
<h1>Executive Security Summary</h1>
|
||||||
|
<h2>{{ company_name }}</h2>
|
||||||
|
<p class="date">{{ generated_at }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Security Posture Overview</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ org_score.overall | default(0) }}%</span>
|
||||||
|
<span class="label">Overall Score</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ total_tests }}</span>
|
||||||
|
<span class="label">Tests Conducted</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ active_campaigns }}</span>
|
||||||
|
<span class="label">Active Campaigns</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Key Metrics</h2>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Metric</th><th>Value</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Techniques validated</td><td>{{ summary.validated }} / {{ summary.total_techniques }}</td></tr>
|
||||||
|
<tr><td>Detection rate</td><td>{{ detection_rate }}%</td></tr>
|
||||||
|
<tr><td>Tests this quarter</td><td>{{ tests_this_quarter }}</td></tr>
|
||||||
|
<tr><td>Open remediations</td><td>{{ open_remediations }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Top Gaps</h2>
|
||||||
|
{% if top_gaps %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Tactic</th><th>Coverage</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for gap in top_gaps %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ gap.tactic }}</td>
|
||||||
|
<td>{{ gap.coverage_pct }}%</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No significant gaps identified.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>{{ company_name }} — Confidential</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
130
backend/app/templates/reports/purple_campaign.html
Normal file
130
backend/app/templates/reports/purple_campaign.html
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="styles/report.css">
|
||||||
|
<title>Purple Team Assessment Report — {{ campaign.name }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="cover-page">
|
||||||
|
<img src="assets/logo.png" class="logo" alt="Logo">
|
||||||
|
<h1>Purple Team Assessment Report</h1>
|
||||||
|
<h2>{{ campaign.name }}</h2>
|
||||||
|
<p class="date">{{ generated_at }}</p>
|
||||||
|
<p class="classification">{{ classification | default('INTERNAL') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="toc">
|
||||||
|
<h2>Table of Contents</h2>
|
||||||
|
<ul>
|
||||||
|
<li>1. Executive Summary</li>
|
||||||
|
<li>2. Scope & Methodology</li>
|
||||||
|
<li>3. Techniques Tested</li>
|
||||||
|
<li>4. Critical Findings</li>
|
||||||
|
<li>5. Coverage Evolution</li>
|
||||||
|
<li>6. Recommendations</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>1. Executive Summary</h2>
|
||||||
|
<p>Campaign <strong>{{ campaign.name }}</strong> tested
|
||||||
|
{{ tests | length }} techniques across {{ tactics | length }} tactics.
|
||||||
|
Overall organization coverage score: <strong>{{ org_score }}%</strong>.</p>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ tests_validated }}</span>
|
||||||
|
<span class="label">Validated</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ tests_detected }}</span>
|
||||||
|
<span class="label">Detected</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="number">{{ tests_not_detected }}</span>
|
||||||
|
<span class="label">Not Detected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>2. Scope & Methodology</h2>
|
||||||
|
<p>{{ campaign.description or 'No description provided.' }}</p>
|
||||||
|
{% if campaign.scheduled_at and campaign.completed_at %}
|
||||||
|
<p>Period: {{ campaign.scheduled_at }} — {{ campaign.completed_at }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if threat_actors %}
|
||||||
|
<p>Threat actors modeled:
|
||||||
|
{% for actor in threat_actors %}{{ actor.name }}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>3. Techniques Tested</h2>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>MITRE ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Tactic</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Detection</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for test in tests %}
|
||||||
|
<tr class="result-{{ test.detection_result }}">
|
||||||
|
<td>{{ test.technique_mitre_id }}</td>
|
||||||
|
<td>{{ test.name }}</td>
|
||||||
|
<td>{{ test.tactic }}</td>
|
||||||
|
<td>{{ test.state }}</td>
|
||||||
|
<td>{{ test.detection_result }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>4. Critical Findings</h2>
|
||||||
|
{% if critical_findings %}
|
||||||
|
{% for finding in critical_findings %}
|
||||||
|
<div class="finding {{ finding.severity }}">
|
||||||
|
<h3>{{ finding.technique_id }}: {{ finding.name }}</h3>
|
||||||
|
<p>{{ finding.description }}</p>
|
||||||
|
<p><strong>Recommendation:</strong> {{ finding.recommendation }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p>No critical findings — all tested techniques were detected.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>5. Coverage Evolution</h2>
|
||||||
|
{% if previous_campaign %}
|
||||||
|
<p>Compared to previous campaign (<em>{{ previous_campaign.name }}</em>):
|
||||||
|
Coverage changed from {{ previous_score }}% to {{ org_score }}%.</p>
|
||||||
|
{% else %}
|
||||||
|
<p>This is the first campaign run — no historical comparison available.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>6. Recommendations</h2>
|
||||||
|
<ul>
|
||||||
|
{% for finding in critical_findings %}
|
||||||
|
<li><strong>{{ finding.technique_id }}</strong>: {{ finding.recommendation }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not critical_findings %}
|
||||||
|
<li>Continue periodic purple team exercises to maintain coverage.</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>{{ company_name }} — Confidential</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
238
backend/app/templates/reports/styles/report.css
Normal file
238
backend/app/templates/reports/styles/report.css
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/* ── Aegis Professional Report CSS ─────────────────────────────── */
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 2.5cm;
|
||||||
|
@bottom-center {
|
||||||
|
content: "Page " counter(page) " of " counter(pages);
|
||||||
|
font-size: 9px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover Page ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cover-page {
|
||||||
|
page-break-after: always;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 80vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-page .logo {
|
||||||
|
max-width: 180px;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-page h1 {
|
||||||
|
font-size: 28pt;
|
||||||
|
color: #0e7490;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-page h2 {
|
||||||
|
font-size: 18pt;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-page .date {
|
||||||
|
font-size: 12pt;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-page .classification {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 0.4rem 1.5rem;
|
||||||
|
border: 2px solid #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section headings ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
section {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16pt;
|
||||||
|
color: #0e7490;
|
||||||
|
border-bottom: 2px solid #0e7490;
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 13pt;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats grid ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .number {
|
||||||
|
display: block;
|
||||||
|
font-size: 28pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0e7490;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Data table ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: #0e7490;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:nth-child(even) {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detection result row colors ───────────────────────────────── */
|
||||||
|
|
||||||
|
tr.result-detected td:last-child { color: #059669; font-weight: 600; }
|
||||||
|
tr.result-not_detected td:last-child { color: #dc2626; font-weight: 600; }
|
||||||
|
tr.result-partially_detected td:last-child { color: #d97706; font-weight: 600; }
|
||||||
|
tr.result-pending td:last-child { color: #6b7280; }
|
||||||
|
|
||||||
|
/* ── Findings ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.finding {
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid #d1d5db;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding.critical { border-left-color: #dc2626; }
|
||||||
|
.finding.high { border-left-color: #ea580c; }
|
||||||
|
.finding.medium { border-left-color: #d97706; }
|
||||||
|
.finding.low { border-left-color: #059669; }
|
||||||
|
|
||||||
|
.finding h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table of Contents ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.toc {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc h2 {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc li {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
border-bottom: 1px dotted #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metric cards (for coverage reports) ────────────────────────── */
|
||||||
|
|
||||||
|
.metric-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
flex: 1 1 calc(33% - 1rem);
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .value {
|
||||||
|
font-size: 24pt;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0e7490;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .label {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ slowapi
|
|||||||
defusedxml
|
defusedxml
|
||||||
redis>=5.0.0
|
redis>=5.0.0
|
||||||
atlassian-python-api>=4.0.0
|
atlassian-python-api>=4.0.0
|
||||||
|
weasyprint>=62.0
|
||||||
|
docxtpl>=0.18.0
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
pytest
|
pytest
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ export async function submitRedEvidence(testId: string): Promise<Test> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Timer Controls ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Pause the active phase timer. */
|
||||||
|
export async function pauseTimer(testId: string): Promise<Test> {
|
||||||
|
const { data } = await client.post<Test>(`/tests/${testId}/pause-timer`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume a paused phase timer. */
|
||||||
|
export async function resumeTimer(testId: string): Promise<Test> {
|
||||||
|
const { data } = await client.post<Test>(`/tests/${testId}/resume-timer`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Blue Team ──────────────────────────────────────────────────────
|
// ── Blue Team ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Blue Team updates their fields (blue_evaluating only). */
|
/** Blue Team updates their fields (blue_evaluating only). */
|
||||||
|
|||||||
@@ -1,65 +1,108 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Timer } from "lucide-react";
|
import { Timer, Pause, Play } from "lucide-react";
|
||||||
|
|
||||||
interface LiveTimerProps {
|
interface LiveTimerProps {
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
pausedAt: string | null;
|
||||||
|
pausedSeconds: number;
|
||||||
label: string;
|
label: string;
|
||||||
variant: "red" | "blue";
|
variant: "red" | "blue";
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
canControl: boolean;
|
||||||
|
isToggling: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real-time elapsed timer that counts up from a given start timestamp.
|
* Real-time elapsed timer that counts up from a given start timestamp,
|
||||||
* Shown while a Red/Blue Team phase is active so users can see
|
* subtracting accumulated pause time. Shows pause/resume controls.
|
||||||
* exactly how long they've been working. This time is recorded
|
|
||||||
* as an automatic worklog when the phase ends.
|
|
||||||
*/
|
*/
|
||||||
export default function LiveTimer({ startedAt, label, variant }: LiveTimerProps) {
|
export default function LiveTimer({
|
||||||
|
startedAt,
|
||||||
|
pausedAt,
|
||||||
|
pausedSeconds,
|
||||||
|
label,
|
||||||
|
variant,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
canControl,
|
||||||
|
isToggling,
|
||||||
|
}: LiveTimerProps) {
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const isPaused = pausedAt !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const start = new Date(startedAt).getTime();
|
const start = new Date(startedAt).getTime();
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
setElapsed(Math.max(0, Math.floor((now - start) / 1000)));
|
const grossSeconds = Math.floor((now - start) / 1000);
|
||||||
|
|
||||||
|
let totalPaused = pausedSeconds;
|
||||||
|
if (isPaused) {
|
||||||
|
const pauseStart = new Date(pausedAt!).getTime();
|
||||||
|
totalPaused += Math.floor((now - pauseStart) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setElapsed(Math.max(0, grossSeconds - totalPaused));
|
||||||
};
|
};
|
||||||
|
|
||||||
tick();
|
tick();
|
||||||
|
if (!isPaused) {
|
||||||
const interval = setInterval(tick, 1000);
|
const interval = setInterval(tick, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [startedAt]);
|
}
|
||||||
|
}, [startedAt, pausedAt, pausedSeconds, isPaused]);
|
||||||
|
|
||||||
const hours = Math.floor(elapsed / 3600);
|
const hours = Math.floor(elapsed / 3600);
|
||||||
const minutes = Math.floor((elapsed % 3600) / 60);
|
const minutes = Math.floor((elapsed % 3600) / 60);
|
||||||
const seconds = elapsed % 60;
|
const seconds = elapsed % 60;
|
||||||
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
const colors =
|
const colors = isPaused
|
||||||
variant === "red"
|
? "border-yellow-500/40 bg-yellow-900/30 text-yellow-300"
|
||||||
|
: variant === "red"
|
||||||
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
|
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
|
||||||
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
|
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
|
||||||
|
|
||||||
const dotColor = variant === "red" ? "bg-orange-400" : "bg-indigo-400";
|
const dotColor = isPaused
|
||||||
|
? "bg-yellow-400"
|
||||||
|
: variant === "red"
|
||||||
|
? "bg-orange-400"
|
||||||
|
: "bg-indigo-400";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}>
|
||||||
className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}
|
|
||||||
>
|
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Timer className="h-4 w-4" />
|
<Timer className="h-4 w-4" />
|
||||||
<span
|
<span
|
||||||
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} animate-pulse`}
|
className={`absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full ${dotColor} ${
|
||||||
|
isPaused ? "" : "animate-pulse"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wider opacity-70">
|
<span className="text-[10px] font-medium uppercase tracking-wider opacity-70">
|
||||||
{label}
|
{label}{isPaused ? " (Paused)" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-sm font-bold tabular-nums">
|
<span className="font-mono text-sm font-bold tabular-nums">
|
||||||
{pad(hours)}:{pad(minutes)}:{pad(seconds)}
|
{pad(hours)}:{pad(minutes)}:{pad(seconds)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{canControl && (
|
||||||
|
<button
|
||||||
|
onClick={isPaused ? onResume : onPause}
|
||||||
|
disabled={isToggling}
|
||||||
|
className={`ml-1 rounded-md p-1.5 transition-colors disabled:opacity-50 ${
|
||||||
|
isPaused
|
||||||
|
? "bg-green-600/20 text-green-400 hover:bg-green-600/30"
|
||||||
|
: "bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30"
|
||||||
|
}`}
|
||||||
|
title={isPaused ? "Resume timer" : "Pause timer"}
|
||||||
|
>
|
||||||
|
{isPaused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ interface TestDetailHeaderProps {
|
|||||||
onSubmitBlue: () => void;
|
onSubmitBlue: () => void;
|
||||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||||
onReopen: () => void;
|
onReopen: () => void;
|
||||||
|
onPauseTimer: () => void;
|
||||||
|
onResumeTimer: () => void;
|
||||||
|
isTogglingTimer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
@@ -66,6 +69,9 @@ export default function TestDetailHeader({
|
|||||||
onSubmitBlue,
|
onSubmitBlue,
|
||||||
onOpenValidateModal,
|
onOpenValidateModal,
|
||||||
onReopen,
|
onReopen,
|
||||||
|
onPauseTimer,
|
||||||
|
onResumeTimer,
|
||||||
|
isTogglingTimer,
|
||||||
}: TestDetailHeaderProps) {
|
}: TestDetailHeaderProps) {
|
||||||
const role = user?.role ?? "";
|
const role = user?.role ?? "";
|
||||||
const currentIdx = STATE_INDEX[test.state];
|
const currentIdx = STATE_INDEX[test.state];
|
||||||
@@ -238,13 +244,23 @@ export default function TestDetailHeader({
|
|||||||
|
|
||||||
// ── Live timer ───────────────────────────────────────────────────
|
// ── Live timer ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canControlTimer =
|
||||||
|
(test.state === "red_executing" && (role === "red_tech" || role === "admin")) ||
|
||||||
|
(test.state === "blue_evaluating" && (role === "blue_tech" || role === "admin"));
|
||||||
|
|
||||||
const renderLiveTimer = () => {
|
const renderLiveTimer = () => {
|
||||||
if (test.state === "red_executing" && test.red_started_at) {
|
if (test.state === "red_executing" && test.red_started_at) {
|
||||||
return (
|
return (
|
||||||
<LiveTimer
|
<LiveTimer
|
||||||
startedAt={test.red_started_at}
|
startedAt={test.red_started_at}
|
||||||
|
pausedAt={test.paused_at}
|
||||||
|
pausedSeconds={test.red_paused_seconds}
|
||||||
label="Red Team Timer"
|
label="Red Team Timer"
|
||||||
variant="red"
|
variant="red"
|
||||||
|
onPause={onPauseTimer}
|
||||||
|
onResume={onResumeTimer}
|
||||||
|
canControl={canControlTimer}
|
||||||
|
isToggling={isTogglingTimer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -252,8 +268,14 @@ export default function TestDetailHeader({
|
|||||||
return (
|
return (
|
||||||
<LiveTimer
|
<LiveTimer
|
||||||
startedAt={test.blue_started_at}
|
startedAt={test.blue_started_at}
|
||||||
|
pausedAt={test.paused_at}
|
||||||
|
pausedSeconds={test.blue_paused_seconds}
|
||||||
label="Blue Team Timer"
|
label="Blue Team Timer"
|
||||||
variant="blue"
|
variant="blue"
|
||||||
|
onPause={onPauseTimer}
|
||||||
|
onResume={onResumeTimer}
|
||||||
|
canControl={canControlTimer}
|
||||||
|
isToggling={isTogglingTimer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
validateAsRedLead,
|
validateAsRedLead,
|
||||||
validateAsBlueLead,
|
validateAsBlueLead,
|
||||||
reopenTest,
|
reopenTest,
|
||||||
|
pauseTimer,
|
||||||
|
resumeTimer,
|
||||||
getTestTimeline,
|
getTestTimeline,
|
||||||
getRetestChain,
|
getRetestChain,
|
||||||
} from "../api/tests";
|
} from "../api/tests";
|
||||||
@@ -223,6 +225,25 @@ export default function TestDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Timer pause/resume
|
||||||
|
const pauseTimerMutation = useMutation({
|
||||||
|
mutationFn: () => pauseTimer(testId!),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
showToast("Timer paused", "success");
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeTimerMutation = useMutation({
|
||||||
|
mutationFn: () => resumeTimer(testId!),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
showToast("Timer resumed", "success");
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
|
});
|
||||||
|
|
||||||
// Evidence upload
|
// Evidence upload
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
|
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
|
||||||
@@ -351,6 +372,9 @@ export default function TestDetailPage() {
|
|||||||
onSubmitBlue={() => submitBlueMutation.mutate()}
|
onSubmitBlue={() => submitBlueMutation.mutate()}
|
||||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||||
onReopen={() => setConfirmReopen(true)}
|
onReopen={() => setConfirmReopen(true)}
|
||||||
|
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||||
|
onResumeTimer={() => resumeTimerMutation.mutate()}
|
||||||
|
isTogglingTimer={pauseTimerMutation.isPending || resumeTimerMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content: Tabs + Sidebar */}
|
{/* Content: Tabs + Sidebar */}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export interface Test {
|
|||||||
// Phase timing fields (for automatic Tempo worklogs)
|
// Phase timing fields (for automatic Tempo worklogs)
|
||||||
red_started_at: string | null;
|
red_started_at: string | null;
|
||||||
blue_started_at: string | null;
|
blue_started_at: string | null;
|
||||||
|
paused_at: string | null;
|
||||||
|
red_paused_seconds: number;
|
||||||
|
blue_paused_seconds: number;
|
||||||
|
|
||||||
// Remediation fields
|
// Remediation fields
|
||||||
remediation_steps: string | null;
|
remediation_steps: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user