mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
Page subtitles, panel descriptions and field labels had drifted into prose that narrates a feature rather than naming it. A subtitle read "Connect a language model to draft report narratives. Everything reNgine computes works without it; a model only changes how the findings are written up." where the older pages already say "Instance-wide configuration". A subtitle and a panel description are noun phrases with no trailing period. Four patterns went out: - Second person. "Your keywords", "Your themes", "Your templates", "Your company" and three "Yours" origin badges become "Keywords", "Custom themes", "Custom templates", "Company", "Custom" — moved in the backend SSOT so the frontend mirrors follow. "Checks you uploaded" is "Custom templates"; "Upload your own in the Tools Arsenal" is "Custom lists are uploaded in the Tools Arsenal". The user's own credentials keep "your". - Rhetorical What/Where/Why headings. "What is listening" is "Service classes", "Where it concentrates" is "Findings by target", "What changed" is "Changes", "Worth acting on first" is "Priority queue", "Why this is interesting" is "Signals". Nine query-grammar field descriptions follow the rule already on record: "How many parameters the endpoint accepts" is "Number of parameters the endpoint accepts". - The reassuring second clause that explains the first away. Cut from the AI page, the reports empty state and the schedules panel. The AI alert states what is sent and what is not, then stops. - "Create your first X" as a heading, now "No scan engines" / "Create a project". Two delete dialogs were missing the standard "This action cannot be undone." tail. schedule-list-card used a native title= for a hover hint, which the project forbids; it is a Hint now. Copy in shared/, stages/, reports/ and interest/ renders straight into the UI and gets the same pass. MCP tool descriptions are left alone: they are prompts a model plans against, not UI copy, even though /mcp displays them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AeRG6G2FdSyWjG4AXPhBA
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}", "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
|