feat(phase-37): timer pause/resume + professional reporting engine
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:
2026-02-17 17:20:45 +01:00
parent febf460580
commit 31e116b4ba
23 changed files with 1564 additions and 25 deletions

View 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()

View 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)

View File

@@ -135,24 +135,32 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
"""
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(
db, test, TestState.blue_evaluating, user,
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(
db,
test=test,
user=user,
phase_started_at=test.red_started_at,
phase_ended_at=now,
paused_seconds=(test.red_paused_seconds or 0) + paused_extra,
activity_type="red_team_execution",
description=f"Red Team execution: {test.name}",
)
# Start Blue Team timer
test.blue_started_at = now
test.blue_paused_seconds = 0
db.commit()
return test
@@ -165,18 +173,25 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
"""
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(
db, test, TestState.in_review, user,
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(
db,
test=test,
user=user,
phase_started_at=test.blue_started_at,
phase_ended_at=now,
paused_seconds=(test.blue_paused_seconds or 0) + paused_extra,
activity_type="blue_team_evaluation",
description=f"Blue Team evaluation: {test.name}",
)
@@ -185,6 +200,62 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> 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(
db: Session,
*,
@@ -192,11 +263,14 @@ def _create_phase_worklog(
user: User,
phase_started_at: datetime | None,
phase_ended_at: datetime,
paused_seconds: int = 0,
activity_type: str,
description: str,
) -> None:
"""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.
"""
if not phase_started_at:
@@ -206,7 +280,8 @@ def _create_phase_worklog(
)
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:
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
test.red_started_at = None
test.blue_started_at = None
test.paused_at = None
test.red_paused_seconds = 0
test.blue_paused_seconds = 0
db.commit()
return test