Files
rengine/shared/definitions/reports.py
T
Yogesh OjhaandClaude Opus 5 724ebd42bb feat(reports): a section declares its role, its order and what it asks at generate time
Choosing what a report contains required editing a template. That is the wrong
place for a decision made per report, and the scan launch flow already
established the alternative: a saved configuration seeds the run, the run may
depart from it, and the saved configuration is never touched.

Three declarations put sections on the same footing as stages. SectionRole
separates the content a reader asked for from the furniture around it, so a
picker can offer twenty-one choices and include the cover, the contents and the
reference matter without being asked. Section.order fixes the reading order,
which until now was however the directories happened to sort — the executive
summary came after attack paths because "a" precedes "e". Section.launch_fields
names the settings worth surfacing when the report is generated rather than
only in the builder, mirroring Stage.launch_fields; the findings section
declares its severities, its detail depth, its roll-up and its asset cap.

The estimate had to become honest for any of this to be worth showing. It
counted every finding in the scan whatever the section filtered to, so dropping
three severities moved nothing. It now counts through the same severity filter
the section applies and prices each tier separately, from the real per-severity
issue counts rather than an assumed split: 28 pages against 26 rendered for a
full template, 10 against 9 for critical and high alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
2026-09-06 17:14:01 +05:30

494 lines
15 KiB
Python

"""The report vocabulary: formats, scopes, page setup and the customisation surface."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator
MAX_SECTIONS = 60
MAX_CUSTOM_BLOCKS = 20
MAX_TITLE_LENGTH = 200
MAX_TEXT_BLOCK = 20_000
MAX_LOGO_BYTES = 512_000
MAX_REPORT_ROWS = 5_000
MAX_EVIDENCE_CHARS = 4_000
MAX_SCREENSHOTS = 60
# a 500 KB PNG can still decode to a 64M pixel canvas, so the ceiling is on pixels, not bytes
MAX_IMAGE_PIXELS = 30_000_000
REPORT_ROOT = "/app/reports-out"
RETENTION_DAYS = 90
class ReportFormat(StrEnum):
PDF = "pdf"
HTML = "html"
MARKDOWN = "markdown"
JSON = "json"
REPORT_FORMATS: tuple[str, ...] = tuple(f.value for f in ReportFormat)
FORMAT_LABELS: dict[str, str] = {
ReportFormat.PDF.value: "PDF",
ReportFormat.HTML.value: "HTML",
ReportFormat.MARKDOWN.value: "Markdown",
ReportFormat.JSON.value: "JSON",
}
FORMAT_MEDIA_TYPES: dict[str, str] = {
ReportFormat.PDF.value: "application/pdf",
ReportFormat.HTML.value: "text/html",
ReportFormat.MARKDOWN.value: "text/markdown",
ReportFormat.JSON.value: "application/json",
}
FORMAT_EXTENSIONS: dict[str, str] = {
ReportFormat.PDF.value: "pdf",
ReportFormat.HTML.value: "html",
ReportFormat.MARKDOWN.value: "md",
ReportFormat.JSON.value: "json",
}
class ReportStatus(StrEnum):
QUEUED = "queued"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
REPORT_STATUS_LABELS: dict[str, str] = {
ReportStatus.QUEUED.value: "Queued",
ReportStatus.RUNNING.value: "Generating",
ReportStatus.COMPLETED.value: "Ready",
ReportStatus.FAILED.value: "Failed",
}
TERMINAL_STATUSES: tuple[str, ...] = (
ReportStatus.COMPLETED.value,
ReportStatus.FAILED.value,
)
class ReportScope(StrEnum):
SCAN = "scan"
TARGET = "target"
REPORT_SCOPES: tuple[str, ...] = tuple(s.value for s in ReportScope)
SCOPE_LABELS: dict[str, str] = {
ReportScope.SCAN.value: "One scan",
ReportScope.TARGET.value: "A target",
}
SCOPE_HELP: dict[str, str] = {
ReportScope.SCAN.value: "Everything a single run observed, as it observed it.",
ReportScope.TARGET.value: "The target's current surface, taken from the most recent run that covered each dimension.",
}
class SectionGroup(StrEnum):
FRONT_MATTER = "front_matter"
SUMMARY = "summary"
FINDINGS = "findings"
SURFACE = "surface"
INTELLIGENCE = "intelligence"
APPENDIX = "appendix"
SECTION_GROUP_ORDER: tuple[str, ...] = tuple(g.value for g in SectionGroup)
SECTION_GROUP_LABELS: dict[str, str] = {
SectionGroup.FRONT_MATTER.value: "Front matter",
SectionGroup.SUMMARY.value: "Summary",
SectionGroup.FINDINGS.value: "Findings",
SectionGroup.SURFACE.value: "Attack surface",
SectionGroup.INTELLIGENCE.value: "Intelligence",
SectionGroup.APPENDIX.value: "Appendices",
}
class SectionRole(StrEnum):
"""Content is what a reader asked for; furniture is the document around it."""
CONTENT = "content"
FURNITURE = "furniture"
SECTION_ROLES: tuple[str, ...] = tuple(r.value for r in SectionRole)
class Audience(StrEnum):
EXECUTIVE = "executive"
TECHNICAL = "technical"
MIXED = "mixed"
AUDIENCE_LABELS: dict[str, str] = {
Audience.EXECUTIVE.value: "Executive",
Audience.TECHNICAL.value: "Technical",
Audience.MIXED.value: "Mixed",
}
AUDIENCE_HELP: dict[str, str] = {
Audience.EXECUTIVE.value: "Business impact and decisions. No tool names or payloads.",
Audience.TECHNICAL.value: "Reproduction detail, evidence and exact remediation steps.",
Audience.MIXED.value: "An executive opening followed by full technical detail.",
}
class Depth(StrEnum):
BRIEF = "brief"
STANDARD = "standard"
DETAILED = "detailed"
DEPTH_LABELS: dict[str, str] = {
Depth.BRIEF.value: "Brief",
Depth.STANDARD.value: "Standard",
Depth.DETAILED.value: "Detailed",
}
class PageSize(StrEnum):
A4 = "a4"
LETTER = "letter"
LEGAL = "legal"
PAGE_SIZE_LABELS: dict[str, str] = {
PageSize.A4.value: "A4",
PageSize.LETTER.value: "US Letter",
PageSize.LEGAL.value: "US Legal",
}
PAGE_SIZE_CSS: dict[str, str] = {
PageSize.A4.value: "A4",
PageSize.LETTER.value: "Letter",
PageSize.LEGAL.value: "Legal",
}
# printable width in mm, used to size charts and decide table column budgets
PAGE_WIDTH_MM: dict[str, float] = {
PageSize.A4.value: 210.0,
PageSize.LETTER.value: 215.9,
PageSize.LEGAL.value: 215.9,
}
class Orientation(StrEnum):
PORTRAIT = "portrait"
LANDSCAPE = "landscape"
ORIENTATION_LABELS: dict[str, str] = {
Orientation.PORTRAIT.value: "Portrait",
Orientation.LANDSCAPE.value: "Landscape",
}
class Density(StrEnum):
COMPACT = "compact"
NORMAL = "normal"
RELAXED = "relaxed"
DENSITY_LABELS: dict[str, str] = {
Density.COMPACT.value: "Compact",
Density.NORMAL.value: "Normal",
Density.RELAXED.value: "Relaxed",
}
DENSITY_SCALE: dict[str, float] = {
Density.COMPACT.value: 0.85,
Density.NORMAL.value: 1.0,
Density.RELAXED.value: 1.18,
}
class Classification(StrEnum):
NONE = ""
PUBLIC = "Public"
INTERNAL = "Internal"
CONFIDENTIAL = "Confidential"
RESTRICTED = "Restricted"
CLASSIFICATIONS: tuple[str, ...] = tuple(c.value for c in Classification)
@dataclass(frozen=True)
class FontFamily:
key: str
label: str
stack: str
role: str
note: str = ""
# families vendored under reports/assets/fonts; the stack is what the CSS asks for
FONT_FAMILIES: tuple[FontFamily, ...] = (
FontFamily("inter", "Inter", "Inter", "sans", "Neutral UI sans."),
FontFamily(
"space-grotesk", "Space Grotesk", "Space Grotesk", "sans", "Display sans."
),
FontFamily("ibm-plex-sans", "IBM Plex Sans", "IBM Plex Sans", "sans", "Technical."),
FontFamily(
"source-serif", "Source Serif 4", "Source Serif 4", "serif", "Document serif."
),
FontFamily(
"jetbrains-mono",
"JetBrains Mono",
"JetBrains Mono",
"mono",
"Code and evidence.",
),
FontFamily("ibm-plex-mono", "IBM Plex Mono", "IBM Plex Mono", "mono", "Code."),
)
FONT_BY_KEY: dict[str, FontFamily] = {f.key: f for f in FONT_FAMILIES}
SANS_FONTS: tuple[str, ...] = tuple(f.key for f in FONT_FAMILIES if f.role == "sans")
SERIF_FONTS: tuple[str, ...] = tuple(f.key for f in FONT_FAMILIES if f.role == "serif")
MONO_FONTS: tuple[str, ...] = tuple(f.key for f in FONT_FAMILIES if f.role == "mono")
@dataclass(frozen=True)
class SlotToken:
token: str
label: str
# substituted into the running header and footer slots
SLOT_TOKENS: tuple[SlotToken, ...] = (
SlotToken("{title}", "Report title"),
SlotToken("{subtitle}", "Subtitle"),
SlotToken("{target}", "Target"),
SlotToken("{client}", "Client name"),
SlotToken("{company}", "Your company"),
SlotToken("{classification}", "Classification"),
SlotToken("{date}", "Report date"),
SlotToken("{scan_date}", "Scan date"),
SlotToken("{document_id}", "Document ID"),
SlotToken("{version}", "Version"),
SlotToken("{section}", "Current section"),
SlotToken("{page}", "Page number"),
SlotToken("{pages}", "Total pages"),
)
SLOT_TOKEN_VALUES: tuple[str, ...] = tuple(t.token for t in SLOT_TOKENS)
DEFAULT_HEADER_LEFT = "{section}"
DEFAULT_HEADER_RIGHT = "{classification}"
DEFAULT_FOOTER_LEFT = "{title}"
DEFAULT_FOOTER_CENTER = ""
DEFAULT_FOOTER_RIGHT = "{page} / {pages}"
DEFAULT_THEME = "midnight"
# an uploaded image is embedded, never referenced, so the renderer can never be made to fetch
ALLOWED_IMAGE_TYPES: frozenset[str] = frozenset(
{"image/png", "image/jpeg", "image/svg+xml", "image/webp", "image/gif"}
)
_DATA_IMAGE = re.compile(r"^data:(image/[a-z0-9.+-]+);base64,[A-Za-z0-9+/=\s]+$", re.I)
def validate_embedded_image(value: str, field: str) -> str:
"""An image must be embedded data of a known type. A URL is refused, not fetched."""
text = (value or "").strip()
if not text:
return ""
match = _DATA_IMAGE.match(text)
if match is None:
msg = (
f"{field} must be an uploaded image. "
"A link is not accepted, because a report never fetches from the network."
)
raise ValueError(msg)
if match.group(1).lower() not in ALLOWED_IMAGE_TYPES:
msg = f"{field} must be a PNG, JPEG, SVG, WebP or GIF."
raise ValueError(msg)
if len(text) > MAX_LOGO_BYTES:
msg = f"{field} is larger than the {MAX_LOGO_BYTES // 1024} KB limit."
raise ValueError(msg)
return text
# Severity is a reserved ramp, not a theme decision: it means the same thing in every
# document. Lightness carries the rank so it survives deuteranopia, and each level is
# always printed beside its own label. Validated all-pairs on white paper.
DEFAULT_SEVERITY_COLORS: dict[str, str] = {
"critical": "#d02f43",
"high": "#f07c21",
"medium": "#e7c65c",
"low": "#3c8edf",
"info": "#a8bacb",
"unknown": "#a8bacb",
}
# the same ramp restepped for dark paper
DARK_SEVERITY_COLORS: dict[str, str] = {
"critical": "#ed566f",
"high": "#f3963e",
"medium": "#efda79",
"low": "#69bbff",
"info": "#858f9a",
"unknown": "#858f9a",
}
# a categorical scale that clears the CVD and contrast gates on both papers
DEFAULT_CHART_PALETTE: tuple[str, ...] = (
"#2a78d6",
"#eb6834",
"#1baf7a",
"#eda100",
"#e87ba4",
)
DARK_CHART_PALETTE: tuple[str, ...] = (
"#3987e5",
"#d95926",
"#199e70",
"#c98500",
"#d55181",
)
class ReportStyle(BaseModel):
"""Everything about how the document looks. Empty means the theme decides."""
model_config = ConfigDict(extra="ignore", validate_assignment=True)
theme: str = Field(default=DEFAULT_THEME, max_length=40)
page_size: str = Field(default=PageSize.A4.value, max_length=10)
orientation: str = Field(default=Orientation.PORTRAIT.value, max_length=10)
margin_top: float = Field(default=26.0, ge=5, le=60)
margin_right: float = Field(default=24.0, ge=5, le=60)
margin_bottom: float = Field(default=23.0, ge=5, le=60)
margin_left: float = Field(default=24.0, ge=5, le=60)
density: str = Field(default=Density.NORMAL.value, max_length=10)
base_font_size: float = Field(default=10.5, ge=6, le=16)
line_height: float = Field(default=1.6, ge=1.0, le=2.4)
heading_font: str = Field(default="", max_length=40)
body_font: str = Field(default="", max_length=40)
mono_font: str = Field(default="", max_length=40)
accent: str = Field(default="", max_length=9)
accent_soft: str = Field(default="", max_length=9)
severity_colors: dict[str, str] = Field(default_factory=dict)
chart_palette: list[str] = Field(default_factory=list, max_length=8)
mono_safe: bool = False
table_zebra: bool = True
section_numbering: bool = True
chapter_breaks: bool = True
figure_numbering: bool = True
page_numbers: bool = True
show_header: bool = True
show_footer: bool = True
header_left: str = Field(default=DEFAULT_HEADER_LEFT, max_length=120)
header_center: str = Field(default="", max_length=120)
header_right: str = Field(default=DEFAULT_HEADER_RIGHT, max_length=120)
footer_left: str = Field(default=DEFAULT_FOOTER_LEFT, max_length=120)
footer_center: str = Field(default=DEFAULT_FOOTER_CENTER, max_length=120)
footer_right: str = Field(default=DEFAULT_FOOTER_RIGHT, max_length=120)
cover_layout: str = Field(default="", max_length=40)
cover_image: str = Field(default="", max_length=MAX_LOGO_BYTES)
watermark_text: str = Field(default="", max_length=40)
watermark_opacity: float = Field(default=0.05, ge=0.01, le=0.4)
link_urls: bool = True
justify: bool = True
hyphenate: bool = True
@field_validator("cover_image")
@classmethod
def _cover_is_embedded(cls, value: str) -> str:
return validate_embedded_image(value, "The cover image")
class Revision(BaseModel):
model_config = ConfigDict(extra="forbid")
version: str = Field(default="", max_length=20)
date: str = Field(default="", max_length=40)
author: str = Field(default="", max_length=120)
note: str = Field(default="", max_length=500)
class ReportBranding(BaseModel):
"""Who the document is from, who it is for, and what may be done with it."""
model_config = ConfigDict(extra="ignore", validate_assignment=True)
company_name: str = Field(default="", max_length=160)
company_logo: str = Field(default="", max_length=MAX_LOGO_BYTES)
client_name: str = Field(default="", max_length=160)
prepared_for: str = Field(default="", max_length=200)
prepared_by: str = Field(default="", max_length=200)
author: str = Field(default="", max_length=160)
contact_email: str = Field(default="", max_length=200)
contact_url: str = Field(default="", max_length=300)
classification: str = Field(default="", max_length=40)
document_id: str = Field(default="", max_length=60)
version: str = Field(default="", max_length=20)
distribution: list[str] = Field(default_factory=list, max_length=20)
revisions: list[Revision] = Field(default_factory=list, max_length=20)
confidentiality_statement: str = Field(default="", max_length=2000)
disclaimer: str = Field(default="", max_length=2000)
@field_validator("company_logo")
@classmethod
def _logo_is_embedded(cls, value: str) -> str:
return validate_embedded_image(value, "The logo")
class NarrativeOptions(BaseModel):
"""How the prose is written, and whether a model writes any of it."""
model_config = ConfigDict(extra="ignore", validate_assignment=True)
ai_enabled: bool = False
audience: str = Field(default=Audience.MIXED.value, max_length=20)
depth: str = Field(default=Depth.STANDARD.value, max_length=20)
explain_findings: bool = False
max_explained_issues: int = Field(default=12, ge=1, le=40)
model: str = Field(default="", max_length=80)
disclose_ai: bool = True
house_style: str = Field(default="", max_length=1000)
class SectionEntry(BaseModel):
"""One row of the document outline."""
model_config = ConfigDict(extra="forbid")
section: str = Field(max_length=60)
enabled: bool = True
title: str = Field(default="", max_length=MAX_TITLE_LENGTH)
config: dict = Field(default_factory=dict)
class ReportSpec(BaseModel):
"""The resolved document: what to say, about what, and how it looks."""
model_config = ConfigDict(extra="ignore")
title: str = Field(default="", max_length=MAX_TITLE_LENGTH)
subtitle: str = Field(default="", max_length=MAX_TITLE_LENGTH)
scope: str = Field(default=ReportScope.SCAN.value, max_length=16)
sections: list[SectionEntry] = Field(default_factory=list, max_length=MAX_SECTIONS)
style: ReportStyle = Field(default_factory=ReportStyle)
branding: ReportBranding = Field(default_factory=ReportBranding)
narrative: NarrativeOptions = Field(default_factory=NarrativeOptions)
formats: list[str] = Field(default_factory=lambda: [ReportFormat.PDF.value])
def coerce_format(value: str | None) -> str:
key = (value or "").strip().lower()
return key if key in FORMAT_LABELS else ReportFormat.PDF.value
def coerce_scope(value: str | None) -> str:
key = (value or "").strip().lower()
return key if key in REPORT_SCOPES else ReportScope.SCAN.value