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:
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()
|
||||
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user