mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 07:11:28 +02:00
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
494 lines
15 KiB
Python
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
|