mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
- _carto_graph: radial "carto" (TOI hub → top-8 trackers, nodes sized by hits, country flag + domain labels, spokes) mirroring /social/me, drawn with fpdf2. - _emoji_table: generic emoji table; render Traceurs (flag/domain/hits/sites), Pays (flag/iso/trackers/hits) and DPI top-destinations (cat/service/part). - api.py report_me: pass carto_nodes + carto_country (social graph) to the PDF. Verified on gk2 (Linux UA): 3-page PDF, page 2 shows the carto hub graph + the three emoji tables with live data.
879 lines
37 KiB
Python
879 lines
37 KiB
Python
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||
|
||
"""HMAC-signed report tokens + PDF generation (fpdf2)."""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import logging
|
||
import secrets
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from .models import ReportToken
|
||
|
||
log = logging.getLogger("secubox.toolbox.reports")
|
||
|
||
# Phase 3 (#492) : Unicode fonts for real emoji rendering in PDF
|
||
DEJAVU_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||
DEJAVU_BOLD_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||
DEJAVU_OBLIQUE_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf"
|
||
# Symbola : broad monochrome emoji + symbol coverage (📱📡🔍🔒🎯🎵🐙📊🍪…)
|
||
# Apt package : fonts-symbola
|
||
SYMBOLA_PATH = "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf"
|
||
# Noto Color Emoji : CBDT/CBLC color bitmap font covering ALL emojis incl.
|
||
# flags (regional indicator pairs 🇫🇷🇺🇸🇩🇪) which Symbola lacks.
|
||
# fpdf2 >= 2.7 supports color emoji rendering — package fonts-noto-color-emoji
|
||
NOTO_COLOR_EMOJI_PATH = "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"
|
||
|
||
|
||
def mint_token(mac_hash: str, salt: str, ttl_seconds: int = 86400) -> ReportToken:
|
||
"""Mint an HMAC-signed ephemeral token for /report/{token} access."""
|
||
expires_at = int(time.time()) + ttl_seconds
|
||
nonce = secrets.token_urlsafe(8)
|
||
raw = f"{mac_hash}:{expires_at}:{nonce}".encode()
|
||
sig = hmac.new(salt.encode(), raw, hashlib.sha256).hexdigest()[:16]
|
||
token = f"{mac_hash}.{expires_at}.{nonce}.{sig}"
|
||
return ReportToken(token=token, mac_hash=mac_hash, expires_at=expires_at)
|
||
|
||
|
||
def verify_token(token: str, salt: str) -> tuple[bool, str | None]:
|
||
"""Returns (ok, mac_hash) — ok=False if expired / bad signature / malformed."""
|
||
try:
|
||
mac_hash, exp_str, nonce, sig = token.split(".")
|
||
expires_at = int(exp_str)
|
||
except (ValueError, AttributeError):
|
||
return False, None
|
||
if expires_at < int(time.time()):
|
||
return False, None
|
||
raw = f"{mac_hash}:{expires_at}:{nonce}".encode()
|
||
expected = hmac.new(salt.encode(), raw, hashlib.sha256).hexdigest()[:16]
|
||
if not hmac.compare_digest(sig, expected):
|
||
return False, None
|
||
return True, mac_hash
|
||
|
||
|
||
# ───────────────── PDF generation ─────────────────
|
||
|
||
def build_report_data(mac_hash: str, session_data: dict) -> dict:
|
||
"""Aggregate session data into the structure consumed by render_pdf().
|
||
session_data is expected to be the dict produced by api._aggregate_session()."""
|
||
return {
|
||
"mac_hash": mac_hash,
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
**session_data,
|
||
}
|
||
|
||
|
||
def _setup_fonts(pdf) -> str:
|
||
"""Load DejaVu Sans (broad Unicode) + Symbola (emoji) for the PDF.
|
||
|
||
Falls back to Helvetica (latin-1 only) if fonts are absent.
|
||
|
||
Returns the font family name to use for set_font() calls.
|
||
"""
|
||
family = "Helvetica"
|
||
if Path(DEJAVU_PATH).exists():
|
||
try:
|
||
pdf.add_font("DejaVu", style="", fname=DEJAVU_PATH)
|
||
if Path(DEJAVU_BOLD_PATH).exists():
|
||
pdf.add_font("DejaVu", style="B", fname=DEJAVU_BOLD_PATH)
|
||
if Path(DEJAVU_OBLIQUE_PATH).exists():
|
||
pdf.add_font("DejaVu", style="I", fname=DEJAVU_OBLIQUE_PATH)
|
||
family = "DejaVu"
|
||
# Fallback chain :
|
||
# NotoColorEmoji : COLOR CBDT/CBLC, covers ALL emojis including
|
||
# flags as composite glyphs (🇫🇷 = single bitmap)
|
||
# and modern 2020+ emojis Symbola lacks
|
||
# Symbola : monochrome vector fallback for chars not in Noto
|
||
# Order matters : Noto FIRST. Symbola has regional indicator letters
|
||
# but renders them as separate boxed letters, not composite flags.
|
||
# By trying Noto first, flag emojis render as proper single glyphs.
|
||
fallback = []
|
||
if Path(NOTO_COLOR_EMOJI_PATH).exists():
|
||
try:
|
||
pdf.add_font("NotoColorEmoji", style="", fname=NOTO_COLOR_EMOJI_PATH)
|
||
fallback.append("NotoColorEmoji")
|
||
log.info("NotoColorEmoji loaded — color emoji + flags enabled")
|
||
except Exception as e:
|
||
log.warning("NotoColorEmoji font load failed: %s", e)
|
||
if Path(SYMBOLA_PATH).exists():
|
||
try:
|
||
pdf.add_font("Symbola", style="", fname=SYMBOLA_PATH)
|
||
fallback.append("Symbola")
|
||
except Exception as e:
|
||
log.warning("Symbola font load failed: %s", e)
|
||
if fallback:
|
||
try:
|
||
pdf.set_fallback_fonts(fallback, exact_match=False)
|
||
except TypeError:
|
||
pdf.set_fallback_fonts(fallback)
|
||
except Exception as e:
|
||
log.warning("set_fallback_fonts failed: %s", e)
|
||
except Exception as e:
|
||
log.warning("DejaVu font load failed (%s), falling back to Helvetica", e)
|
||
return family
|
||
|
||
|
||
def render_pdf(report: dict) -> bytes:
|
||
"""Render the analysis report as PDF (fpdf2). Returns the binary blob."""
|
||
try:
|
||
from fpdf import FPDF
|
||
except ImportError:
|
||
# Fallback : return a text plain "PDF stub" if fpdf2 isn't installed
|
||
return _render_text_fallback(report).encode()
|
||
|
||
pdf = FPDF(orientation="P", unit="mm", format="A4")
|
||
pdf.add_page()
|
||
pdf.set_auto_page_break(auto=True, margin=15)
|
||
|
||
# Phase 3 (#492) : Unicode font for emoji rendering
|
||
family = _setup_fonts(pdf)
|
||
# Stash for the helper functions to use
|
||
pdf._secubox_family = family
|
||
|
||
# Header
|
||
pdf.set_font(family, "B", 20)
|
||
pdf.set_text_color(0, 90, 64) # P31 dim green
|
||
pdf.cell(0, 12, "📡 GONDWANA TOOLBOX", ln=True, align="C")
|
||
pdf.set_font(family, "", 10)
|
||
pdf.set_text_color(110, 64, 201)
|
||
pdf.cell(0, 5, "Rapport d'analyse de session — Cabine numérique VILLAGE3B", ln=True, align="C")
|
||
pdf.ln(3)
|
||
|
||
# ── HERO : Netrunner persona sheet (#707), falls back to the old dashboard ──
|
||
if report.get("persona"):
|
||
_persona_block(pdf, family, report)
|
||
else:
|
||
_dashboard_hero(pdf, family, report)
|
||
|
||
# Anonymous ID
|
||
_section(pdf, "🔑 IDENTIFIANT ANONYME")
|
||
_kv(pdf, "Hash session", report.get("mac_hash", "?"))
|
||
_kv(pdf, "Type appareil", report.get("device_type", "?"))
|
||
_kv(pdf, "Date analyse", report.get("generated_at", "?"))
|
||
_kv(pdf, "Sandbox subnet", "10.99.0.0/24 (réseau isolé VILLAGE3B)")
|
||
pdf.ln(2)
|
||
|
||
# Compromise analysis (the hero shows the risk badge ; details below)
|
||
_section(pdf, "🚨 ANALYSE COMPROMISSION")
|
||
pdf.set_text_color(0)
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "", 10)
|
||
pdf.ln(1)
|
||
explanation = report.get("risk_explanation", "")
|
||
if explanation:
|
||
pdf.multi_cell(_page_w(pdf), 5, _ascii_safe(explanation)[:600])
|
||
pdf.ln(1)
|
||
for sig in report.get("indicators", []):
|
||
_bullet(pdf, sig)
|
||
pdf.ln(2)
|
||
|
||
# Score breakdown — per-category transparency (Phase 2a)
|
||
scoring_data = report.get("scoring") or {}
|
||
breakdown = scoring_data.get("breakdown") or []
|
||
if breakdown:
|
||
_section(pdf, "BREAKDOWN DU SCORE")
|
||
for b in breakdown:
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "B", 9)
|
||
pdf.cell(0, 5, f"{b.get('category', '?').upper()} : poids {b.get('weight_subtotal', 0)} (sur {b.get('raw_signal_count', 0)} signal)", ln=True)
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "", 8)
|
||
for ex in (b.get("examples") or [])[:3]:
|
||
_bullet(pdf, ex, font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# Threat-intel matches (feeds malware) — Phase 2a
|
||
ti = report.get("threat_intel_matches") or []
|
||
if ti:
|
||
_section(pdf, "THREAT INTEL - MATCHES FEEDS MALWARE")
|
||
for m in ti[:10]:
|
||
_bullet(pdf, f"[{m.get('source', '?')}/{m.get('weight', 0)}] {m.get('label', '?')} : {m.get('ioc', '?')[:60]}", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# DGA candidates — Phase 2a
|
||
dga_list = report.get("dga_candidates") or []
|
||
if dga_list:
|
||
_section(pdf, "DGA - DOMAINES SUSPECTS")
|
||
for d in dga_list[:8]:
|
||
_bullet(pdf, f"[{d.get('score', 0)}] {d.get('host', '?')[:60]} ({','.join(d.get('indicators', []))})", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# Beaconing patterns — Phase 2a
|
||
bc = report.get("beaconing_candidates") or []
|
||
if bc:
|
||
_section(pdf, "BEACONING - PATTERNS PERIODIQUES SUSPECTS")
|
||
for b in bc[:8]:
|
||
_bullet(pdf, f"[{b.get('score', 0)}] {b.get('host', '?')[:50]} median={b.get('median_seconds', 0)}s cv={b.get('cv', 0)}", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# Cert-pinning protection
|
||
_section(pdf, "PROTECTION CERT-PINNING (apps qui RESISTENT au MITM)")
|
||
for app in report.get("pinned_apps", []):
|
||
_bullet(pdf, app)
|
||
pdf.ln(2)
|
||
|
||
# ── DPI classification (Phase 2a+ nDPI-style apps with emojis) ──
|
||
dpi_cls = report.get("dpi_classified") or {}
|
||
if dpi_cls.get("top_apps"):
|
||
_section(pdf, "APPS DETECTEES (nDPI-style classification)")
|
||
for a in dpi_cls["top_apps"][:15]:
|
||
_bullet(pdf, f"{a.get('emoji', '?')} {a.get('app', '?')} ({a.get('category', '?')}) - {a.get('count', 0)} connexions", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# ── DPI device donut charts (mitm/certs/ads/dpi) — #703 ──
|
||
_pdf_donut_grid(pdf, report.get("pdf_donuts") or [])
|
||
|
||
# ── #709 carto network map + emoji data tables ──
|
||
carto = report.get("carto_nodes") or []
|
||
_carto_graph(pdf, family, carto)
|
||
if carto:
|
||
_emoji_table(pdf, family, "🍪 TRACEURS — qui te suit",
|
||
[("Pays", 0.12), ("Domaine", 0.46), ("Hits", 0.14), ("Sites", 0.28)],
|
||
[[n.get("country_flag", "🏴"), n.get("domain", "?"),
|
||
n.get("hits", 0), f"{n.get('sites_count', 0)} sites"]
|
||
for n in sorted(carto, key=lambda x: x.get("hits", 0), reverse=True)[:12]])
|
||
by_country = report.get("carto_country") or []
|
||
if by_country:
|
||
_emoji_table(pdf, family, "🌍 PAYS — destinations du pistage",
|
||
[("Flag", 0.12), ("Pays", 0.30), ("Traceurs", 0.28), ("Hits", 0.30)],
|
||
[[c.get("flag", "🏴"), c.get("country_iso", "?"),
|
||
c.get("tracker_count", 0), c.get("hits", 0)]
|
||
for c in by_country[:10]])
|
||
dme = (report.get("dpi_exfil") or {}).get("me") or {}
|
||
dests = dme.get("destinations") or []
|
||
if dests:
|
||
_emoji_table(pdf, family, "🛰️ DPI — top destinations (envoi)",
|
||
[("Cat", 0.12), ("Service / hôte", 0.58), ("Part", 0.30)],
|
||
[[d.get("emoji", "🌐"), d.get("label", "?"), f"{d.get('pct', 0)}%"]
|
||
for d in dests[:10]])
|
||
|
||
# ── DPI / EXFILTRATION (R3 per-device + overall) — #701 (parity with HTML) ──
|
||
dexf = report.get("dpi_exfil") or {}
|
||
dme = dexf.get("me") or {}
|
||
dall = dexf.get("all") or {}
|
||
if dme.get("present") or dall.get("categories"):
|
||
_section(pdf, "DPI / EXFILTRATION (TUNNEL R3)")
|
||
|
||
def _donut_lines(title: str, items: list) -> None:
|
||
if not items:
|
||
return
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "B", 9)
|
||
pdf.cell(0, 5, _ascii_safe(title), ln=True)
|
||
for it in items:
|
||
_bullet(pdf, f"{it.get('emoji', '')} {it.get('label', '?')} - {it.get('pct', 0)}%", font_size=8)
|
||
|
||
if dme.get("present"):
|
||
up_mo = round((dme.get("up", 0) or 0) / 1048576, 1)
|
||
dn_mo = round((dme.get("down", 0) or 0) / 1048576, 1)
|
||
_kv(pdf, "Cet appareil",
|
||
f"{dme.get('flows', 0)} flux | {up_mo} Mo envoyes | {dn_mo} Mo recus | {dme.get('alert_count', 0)} alertes")
|
||
_donut_lines("Categories de service", dme.get("categories"))
|
||
_donut_lines("Protocoles", dme.get("protocols"))
|
||
_donut_lines("Alertes exfiltration", dme.get("alerts"))
|
||
_donut_lines("Top destinations (envoi)", dme.get("destinations"))
|
||
else:
|
||
_bullet(pdf, "Aucune donnee DPI pour cet appareil (surfer via le tunnel R3).", font_size=8)
|
||
|
||
if dall.get("categories"):
|
||
pdf.ln(1)
|
||
_kv(pdf, "Reseau (tous appareils)",
|
||
f"{dall.get('devices', 0)} appareils | {dall.get('flows', 0)} flux | {dall.get('alert_count', 0)} alertes")
|
||
_donut_lines("Categories (global)", dall.get("categories"))
|
||
pdf.ln(2)
|
||
|
||
# ── Geo top hosts (avec drapeaux + ASN) ──
|
||
geo_hosts = report.get("geo_top_hosts") or []
|
||
if geo_hosts:
|
||
_section(pdf, "HOTES PAR PAYS + ASN + APP (PHASE 2A+)")
|
||
for h in geo_hosts[:12]:
|
||
flag = h.get("flag", "")
|
||
line = f"{flag} {h.get('emoji', '')} {h.get('app', '?')} | {h.get('host', '?')[:40]} | {h.get('asn_org', '?')[:25]} | {h.get('count', 0)} hits"
|
||
_bullet(pdf, line, font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# ── Avatar / device fingerprint ──
|
||
avatar = report.get("avatar_analysis") or {}
|
||
if avatar.get("devices"):
|
||
_section(pdf, f"AVATAR / DEVICE FINGERPRINT")
|
||
_kv(pdf, "Most common", f"{avatar.get('most_common_emoji', '?')} {avatar.get('most_common', '?')}")
|
||
_kv(pdf, "UA distincts", str(avatar.get('raw_count', 0)))
|
||
for dev, info in (avatar.get("devices") or {}).items():
|
||
_bullet(pdf, f"{info.get('emoji', '?')} {info.get('os_label', dev)} - {info.get('count', 0)}x", font_size=8)
|
||
if avatar.get("browsers"):
|
||
pdf.ln(1)
|
||
for br, info in (avatar.get("browsers") or {}).items():
|
||
_bullet(pdf, f"{info.get('emoji', '?')} {info.get('label', br)} - {info.get('count', 0)}x", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# ── Cookies providers (Phase 2a+) ──
|
||
cookies_providers = report.get("cookies_providers") or []
|
||
if cookies_providers:
|
||
_section(pdf, "COOKIES / TRACKERS PROVIDERS")
|
||
for p in cookies_providers[:12]:
|
||
_bullet(pdf, f"{p.get('emoji', '?')} {p.get('provider', '?')} ({p.get('category', '?')}) x{p.get('count', 0)}", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# ── Cookies trackers ──
|
||
cookies = report.get("cookies") or {}
|
||
if cookies.get("total_set") or cookies.get("details"):
|
||
_section(pdf, "COOKIES / TRACKERS")
|
||
_kv(pdf, "Set-Cookie recus", str(cookies.get("total_set", 0)))
|
||
_kv(pdf, "Cookies envoyes", str(cookies.get("total_sent", 0)))
|
||
pdf.ln(1)
|
||
for detail in (cookies.get("details") or [])[:10]:
|
||
_bullet(pdf,
|
||
f"{detail.get('url', '?')[:60]} set={detail.get('set', 0)} sent={detail.get('sent', 0)}",
|
||
font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# ── SOC indicators ──
|
||
soc = report.get("soc") or {}
|
||
if soc.get("indicators"):
|
||
_section(pdf, "SOC - INDICATEURS DETECTES")
|
||
for ind in soc["indicators"][:10]:
|
||
_bullet(pdf,
|
||
f"[poids {ind.get('weight', 0)}] {ind.get('kind', '?')} : {ind.get('host', '?')[:60]}",
|
||
font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# ── JA4 (TLS fingerprinting) ──
|
||
ja4 = report.get("ja4") or {}
|
||
if ja4.get("snis_seen"):
|
||
_section(pdf, "JA4 - EMPREINTES TLS (HOSTNAMES)")
|
||
for sni in ja4["snis_seen"][:8]:
|
||
_bullet(pdf, sni[:80], font_size=8)
|
||
if ja4.get("alpns_seen"):
|
||
pdf.ln(1)
|
||
_kv(pdf, "ALPN protocols", ", ".join(ja4['alpns_seen'])[:80])
|
||
pdf.ln(2)
|
||
|
||
# Inspected traffic (cookies-flagged URLs - kept for compat)
|
||
inspected = report.get("inspected_urls", [])
|
||
if inspected and not cookies.get("details"):
|
||
_section(pdf, "TRAFIC INSPECTE (R2 consent explicite)")
|
||
for url in inspected[:15]:
|
||
_bullet(pdf, url, font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# Recommendations
|
||
_section(pdf, "RECOMMANDATIONS")
|
||
for rec in report.get("recommendations", []):
|
||
_bullet(pdf, rec)
|
||
pdf.ln(2)
|
||
|
||
# Phase 3 (#492) : Transparency — inspection breakdown + per-host quality
|
||
t = report.get("transparency") or {}
|
||
if t.get("total_events"):
|
||
_section(pdf, "INSPECTION : CE QUI A ETE REGARDE (et pas regarde)")
|
||
b = t.get("breakdown_pct") or {}
|
||
if b.get("inspected"):
|
||
_bullet(pdf, f"Inspecte (MITM via notre CA) : {b['inspected']}% - contenu visible")
|
||
if b.get("bypassed-whitelist"):
|
||
_bullet(pdf, f"Bypass whitelist : {b['bypassed-whitelist']}% - decision policy (vendor cert-pinning)")
|
||
if b.get("pinned-failed-mitm"):
|
||
_bullet(pdf, f"Cert-pinning : {b['pinned-failed-mitm']}% - app refuse notre CA, normal+bon signe")
|
||
if b.get("e2e-opaque"):
|
||
_bullet(pdf, f"E2E messaging : {b['e2e-opaque']}% - opaque par design, ton chiffrement marche")
|
||
_bullet(pdf, f"Total events analyses : {t.get('total_events', 0)}")
|
||
wl = (t.get("whitelist_stats") or {}).get("count", 0)
|
||
if wl:
|
||
_bullet(pdf, f"Patterns whitelist actifs : {wl} (baseline + override operateur)")
|
||
pdf.ln(2)
|
||
|
||
# Per-host quality table — worst first, capped 10
|
||
per_host = t.get("per_host") or []
|
||
if per_host:
|
||
_section(pdf, "🎯 QUALITÉ SÉCURITÉ PAR DESTINATION (worst-first)")
|
||
for h in per_host[:10]:
|
||
grade = h.get("grade", "?")
|
||
host = h.get("host", "?")[:50]
|
||
status = h.get("status", "?")
|
||
_bullet(pdf, f"[{grade}] {host} — {status}", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# Phase 2b/2c (#488/#490) : per-module mitm-ingest aggregate metrics
|
||
mm = report.get("mitm_modules") or {}
|
||
if mm and any(v.get("count", 0) > 0 for v in mm.values() if isinstance(v, dict)):
|
||
_section(pdf, "🛰 INGESTION PAR MODULE (Phase 2b/2c)")
|
||
for kind, data in mm.items():
|
||
if not isinstance(data, dict):
|
||
continue
|
||
cnt = data.get("count", 0)
|
||
if cnt == 0:
|
||
continue
|
||
emoji = {"dpi": "🌐", "cookies": "🍪", "avatar": "👤",
|
||
"soc": "🛡", "threat-analyst": "🕵"}.get(kind, "📡")
|
||
_bullet(pdf, f"{emoji} secubox-{kind} : {cnt} events ingérés (24h)", font_size=9)
|
||
es = data.get("enriched_summary") or {}
|
||
# Per-module top items
|
||
if kind == "dpi" and es.get("top_apps"):
|
||
apps_str = " · ".join(f"{a.get('emoji','?')} {a.get('app','?')}×{a.get('count',0)}"
|
||
for a in es["top_apps"][:5])
|
||
_bullet(pdf, f" Top apps : {apps_str[:200]}", font_size=8)
|
||
elif kind == "cookies" and es.get("top_providers"):
|
||
provs = " · ".join(f"{p.get('emoji','?')} {p.get('provider','?')}×{p.get('count',0)}"
|
||
for p in es["top_providers"][:5])
|
||
_bullet(pdf, f" Top trackers : {provs[:200]}", font_size=8)
|
||
_bullet(pdf, f" Total tracker hits : {es.get('tracker_total', 0)}", font_size=8)
|
||
elif kind == "avatar":
|
||
devs = es.get("devices") or {}
|
||
if devs:
|
||
dev_str = " · ".join(f"{v.get('emoji','?')} {k}×{v.get('count',0)}"
|
||
for k, v in list(devs.items())[:5])
|
||
_bullet(pdf, f" Devices : {dev_str[:200]}", font_size=8)
|
||
brws = es.get("browsers") or {}
|
||
if brws:
|
||
brw_str = " · ".join(f"{v.get('emoji','?')} {k}×{v.get('count',0)}"
|
||
for k, v in list(brws.items())[:5])
|
||
_bullet(pdf, f" Browsers : {brw_str[:200]}", font_size=8)
|
||
elif kind == "soc":
|
||
_bullet(pdf, f" Score total : {es.get('total_weight', 0)} · "
|
||
f"Band : {es.get('max_band', 'low')}", font_size=8)
|
||
kinds = es.get("indicator_kinds") or {}
|
||
if kinds:
|
||
k_str = " · ".join(f"{k}×{v}" for k, v in list(kinds.items())[:5])
|
||
_bullet(pdf, f" Indicateurs : {k_str[:180]}", font_size=8)
|
||
elif kind == "threat-analyst" and es.get("top_fingerprints"):
|
||
fps = ", ".join(f.get("fingerprint", "?")[:12]
|
||
for f in es["top_fingerprints"][:5])
|
||
_bullet(pdf, f" JA4 fingerprints uniques : {es.get('unique_count', 0)}",
|
||
font_size=8)
|
||
_bullet(pdf, f" Top : {fps[:180]}", font_size=8)
|
||
pdf.ln(2)
|
||
|
||
# Retention
|
||
_section(pdf, "🔒 RÉTENTION DES DONNÉES")
|
||
_bullet(pdf, "Hash MAC anonyme : 24h (sel rotatif quotidien)")
|
||
_bullet(pdf, "Events detailles : 24h")
|
||
_bullet(pdf, "Rapport ephemere : 24h (lien HMAC scelle)")
|
||
_bullet(pdf, "Logs bruts : supprimes a la fin de la session")
|
||
pdf.ln(2)
|
||
|
||
# Support & soutien
|
||
_section(pdf, "SUPPORT & SOUTIEN PROJET")
|
||
_bullet(pdf, "Don recurrent : liberapay.com/cybermind")
|
||
_bullet(pdf, "Don ponctuel : cybermind.fr/don")
|
||
_bullet(pdf, "Support technique : support@cybermind.fr")
|
||
_bullet(pdf, "Signaler un bug : github.com/CyberMind-FR/secubox-deb/issues")
|
||
_bullet(pdf, "Deployer une borne : gondwana@cybermind.fr")
|
||
_bullet(pdf, "Audit / formation : contact@cybermind.fr")
|
||
pdf.ln(4)
|
||
|
||
# Footer
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "I", 8)
|
||
pdf.set_text_color(110, 64, 201)
|
||
pdf.cell(0, 4, "Gondwana ToolBoX - LicenseRef-CMSD-1.0 (Source-Disclosed License)", ln=True, align="C")
|
||
pdf.cell(0, 4, "Source : github.com/CyberMind-FR/secubox-deb (issues #474 #475 #477)", ln=True, align="C")
|
||
|
||
return bytes(pdf.output(dest="S"))
|
||
|
||
|
||
def _page_w(pdf) -> float:
|
||
"""Available content width between margins."""
|
||
return pdf.w - pdf.l_margin - pdf.r_margin
|
||
|
||
|
||
# Phase 3 (#492) : with DejaVu Sans + Noto fallback, most emojis render as
|
||
# real glyphs (monochrome). For chars not in either font (e.g. some flags,
|
||
# very recent emoji), we fall back to a small ASCII replacement table.
|
||
# With NotoColorEmoji fallback enabled, ALL emojis (incl flags + recent
|
||
# 2020+ glyphs) render natively. The replacements table is now empty —
|
||
# kept for backward compat (e.g. if NotoColorEmoji absent on a board,
|
||
# we still want graceful degradation, but the user should install
|
||
# fonts-noto-color-emoji per debian/control deps).
|
||
_EMOJI_REPLACEMENTS: dict[str, str] = {
|
||
# Only catch the white-flag fallback (🏳 alone, no country) — explicit "unknown"
|
||
"🏳": "🏳", # keep as-is (NotoColorEmoji has it)
|
||
}
|
||
|
||
|
||
def _widget(pdf, family: str, x: float, y: float, w: float, h: float,
|
||
emoji: str, value: str, label: str,
|
||
bg: tuple, fg: tuple = (10, 10, 15)) -> None:
|
||
"""Render a single rounded widget tile : big emoji, value, label."""
|
||
pdf.set_fill_color(*bg)
|
||
pdf.set_draw_color(*bg)
|
||
pdf.set_line_width(0.3)
|
||
# Rounded rect (fpdf2 2.7+ supports round_corners + corner_radius)
|
||
try:
|
||
pdf.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=3)
|
||
except TypeError:
|
||
pdf.rect(x, y, w, h, style="DF") # older fpdf2 fallback
|
||
# Emoji at top-center
|
||
pdf.set_xy(x, y + 1.5)
|
||
pdf.set_font(family, "B", 14)
|
||
pdf.set_text_color(*fg)
|
||
pdf.cell(w, 6, _safe(emoji), ln=False, align="C")
|
||
# Value (big number)
|
||
pdf.set_xy(x, y + 7.5)
|
||
pdf.set_font(family, "B", 13)
|
||
pdf.cell(w, 6, _safe(value), ln=False, align="C")
|
||
# Label (small)
|
||
pdf.set_xy(x, y + 13.5)
|
||
pdf.set_font(family, "", 6)
|
||
pdf.cell(w, 3, _safe(label), ln=False, align="C")
|
||
|
||
|
||
def _persona_bar(pdf, family: str, label: str, pct: int, col: tuple) -> None:
|
||
"""A labelled horizontal progress bar (ICE / exposition)."""
|
||
pct = max(0, min(100, int(pct or 0)))
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.set_font(family, "", 8)
|
||
pdf.set_text_color(150, 150, 150)
|
||
pdf.cell(40, 4, _safe(f"{label} {pct}/100"), ln=True)
|
||
x, y, w = pdf.l_margin, pdf.get_y(), _page_w(pdf)
|
||
pdf.set_fill_color(20, 22, 28)
|
||
pdf.rect(x, y, w, 2.6, style="F")
|
||
pdf.set_fill_color(*col)
|
||
pdf.rect(x, y, w * pct / 100.0, 2.6, style="F")
|
||
pdf.set_y(y + 4)
|
||
|
||
|
||
def _persona_block(pdf, family: str, report: dict) -> None:
|
||
"""#707 — Cyberpunk-Netrunner character sheet header for the PDF."""
|
||
p = report.get("persona") or {}
|
||
pdf.set_font(family, "B", 12)
|
||
pdf.set_text_color(0, 212, 255)
|
||
pdf.cell(0, 6, _safe("🎮 FICHE NETRUNNER"), ln=True)
|
||
pdf.set_font(family, "B", 11)
|
||
pdf.set_text_color(0, 212, 255)
|
||
pdf.cell(0, 6, _safe(f"{p.get('emoji','')} {p.get('tag','?')}"), ln=True)
|
||
pdf.set_font(family, "", 9)
|
||
pdf.set_text_color(150, 120, 230)
|
||
pdf.cell(0, 5, _safe(f"Classe {p.get('klass','?')} · Niveau {p.get('level','?')} · {p.get('align','')}"), ln=True)
|
||
pdf.ln(1)
|
||
_persona_bar(pdf, family, "ICE / integrite", p.get("hp", 0), (0, 255, 65))
|
||
_persona_bar(pdf, family, "Exposition", p.get("exposure", 0), (255, 179, 71))
|
||
pdf.set_font(family, "", 8)
|
||
pdf.set_text_color(120, 120, 120)
|
||
pdf.cell(0, 4, _safe(f"XP {p.get('xp',0):,} Ko echanges (7j)"), ln=True)
|
||
pdf.ln(1)
|
||
# 4 attribute widgets
|
||
y = pdf.get_y()
|
||
bw = (_page_w(pdf) - 6) / 4
|
||
bh = 17
|
||
for i, a in enumerate((p.get("attrs") or [])[:4]):
|
||
x = pdf.l_margin + i * (bw + 2)
|
||
_widget(pdf, family, x, y, bw, bh, a.get("icon", "?"),
|
||
str(a.get("v", 0)), a.get("name", "")[:10], (15, 30, 40), fg=(0, 212, 255))
|
||
pdf.set_y(y + bh + 2)
|
||
# inventory + bestiary
|
||
_kv(pdf, "Inventaire",
|
||
" ".join(f"{it.get('name','')} {'OK' if it.get('on') else 'x'}" for it in (p.get("inventory") or [])))
|
||
best = report.get("bestiary") or []
|
||
if best:
|
||
_kv(pdf, "Bestiaire",
|
||
" · ".join(f"{b.get('label','?')[:14]} x{b.get('count',0)}" for b in best[:4]))
|
||
pdf.ln(2)
|
||
|
||
|
||
def _dashboard_hero(pdf, family: str, report: dict) -> None:
|
||
"""Phase 3 (#492) : 3-row widget dashboard, banner-style, with aggregations.
|
||
|
||
Row 1 (4 widgets) : raw global counters
|
||
🌐 conn 📡 hosts ✅ OK 2xx 🔒 pinned
|
||
|
||
Row 2 (4 widgets) : aggregated insights
|
||
📺 apps detected 🍪 trackers 🌍 countries 🎯 risk score
|
||
|
||
Row 3 (single banner) : verbal summary
|
||
'Top device 📱 iPhone · Top app 📺 YouTube · Top ASN 🇺🇸 Amazon · Risque: LOW'
|
||
"""
|
||
m = report.get("metrics") or {}
|
||
avatar = report.get("avatar_analysis") or {}
|
||
dpi_cls = report.get("dpi_classified") or {}
|
||
geo_hosts = report.get("geo_top_hosts") or []
|
||
cookies_p = report.get("cookies_providers") or []
|
||
score = report.get("risk_score", 0)
|
||
label = report.get("risk_label") or ("LOW" if score < 30 else "MEDIUM" if score < 70 else "HIGH")
|
||
|
||
# Title with sat dish + session label
|
||
pdf.set_font(family, "B", 12)
|
||
pdf.set_text_color(0, 221, 68)
|
||
pdf.cell(0, 6, _safe("📊 TA SESSION VILLAGE3B"), ln=True)
|
||
pdf.ln(1)
|
||
|
||
# ── Row 1 : raw counters ──
|
||
y = pdf.get_y()
|
||
box_w = (_page_w(pdf) - 6) / 4
|
||
box_h = 17
|
||
row_kpis = [
|
||
("🌐", str(m.get("connections", 0)), "connexions", (15, 35, 30)),
|
||
("📡", str(m.get("unique_hosts", 0)), "hôtes uniques", (15, 30, 40)),
|
||
("✅", str(m.get("successful", 0)), "OK 2xx/3xx", (15, 40, 25)),
|
||
("🔒", str(m.get("tls_pinned", 0)), "cert-pinning", (40, 30, 15)),
|
||
]
|
||
for i, (emoji, val, lbl, bg) in enumerate(row_kpis):
|
||
x = pdf.l_margin + i * (box_w + 2)
|
||
_widget(pdf, family, x, y, box_w, box_h, emoji, val, lbl, bg, fg=(0, 255, 100))
|
||
pdf.set_y(y + box_h + 2)
|
||
|
||
# ── Row 2 : aggregations ──
|
||
# Count distinct apps detected (top_apps)
|
||
n_apps = len([a for a in dpi_cls.get("top_apps", []) if a.get("app") and a.get("app") != "?"])
|
||
# Trackers detected (cookies providers)
|
||
n_trackers = sum(p.get("count", 0) for p in cookies_p) if cookies_p else 0
|
||
# Countries seen (distinct country codes in geo_top_hosts)
|
||
countries = {h.get("country") for h in geo_hosts if h.get("country")}
|
||
n_countries = len(countries)
|
||
# Risk score widget
|
||
risk_emoji = "🟢" if score < 30 else "🟡" if score < 70 else "🔴"
|
||
risk_bg = (15, 50, 20) if score < 30 else (60, 50, 15) if score < 70 else (60, 20, 20)
|
||
|
||
y = pdf.get_y()
|
||
row_agg = [
|
||
("📺", str(n_apps), "apps détectées", (25, 25, 45)),
|
||
("🍪", str(n_trackers), "trackers", (45, 25, 35)),
|
||
("🌍", str(n_countries), "pays/ASN", (25, 40, 35)),
|
||
(risk_emoji, f"{score}/100", f"risque {label}", risk_bg),
|
||
]
|
||
for i, (emoji, val, lbl, bg) in enumerate(row_agg):
|
||
x = pdf.l_margin + i * (box_w + 2)
|
||
_widget(pdf, family, x, y, box_w, box_h, emoji, val, lbl, bg, fg=(0, 230, 220))
|
||
pdf.set_y(y + box_h + 2)
|
||
|
||
# ── Row 3 : verbal summary banner ──
|
||
top_device = avatar.get("most_common") or "?"
|
||
top_device_emoji = avatar.get("most_common_emoji") or ""
|
||
top_app = "?"
|
||
if dpi_cls.get("top_apps"):
|
||
ta = dpi_cls["top_apps"][0]
|
||
if ta.get("app") and ta["app"] != "?":
|
||
top_app = f"{ta.get('emoji', '')} {ta.get('app')}"
|
||
top_country = "?"
|
||
if geo_hosts:
|
||
gh = geo_hosts[0]
|
||
if gh.get("flag") or gh.get("country") or gh.get("asn_org"):
|
||
top_country = f"{gh.get('flag', '')} {gh.get('asn_org', '?')[:30]}"
|
||
|
||
y = pdf.get_y()
|
||
# Banner with gradient-like background
|
||
pdf.set_fill_color(20, 25, 35)
|
||
pdf.set_draw_color(0, 90, 64)
|
||
try:
|
||
pdf.rect(pdf.l_margin, y, _page_w(pdf), 9, style="DF", round_corners=True, corner_radius=2)
|
||
except TypeError:
|
||
pdf.rect(pdf.l_margin, y, _page_w(pdf), 9, style="DF")
|
||
pdf.set_xy(pdf.l_margin + 3, y + 2)
|
||
pdf.set_font(family, "", 9)
|
||
pdf.set_text_color(220, 220, 200)
|
||
summary = (
|
||
f"Top device : {top_device_emoji} {top_device} · "
|
||
f"Top app : {top_app} · "
|
||
f"Top ASN : {top_country}"
|
||
)
|
||
pdf.cell(_page_w(pdf) - 6, 5, _safe(summary[:140]), ln=False)
|
||
pdf.set_y(y + 11)
|
||
|
||
# Reset
|
||
pdf.set_text_color(0, 0, 0)
|
||
pdf.set_font(family, "", 10)
|
||
pdf.ln(2)
|
||
|
||
|
||
def _safe(text: str) -> str:
|
||
"""Replace chars not in DejaVu+NotoSymbols fonts (mostly flag emojis).
|
||
|
||
Most emoji render directly via DejaVu Sans + fallback. Flag emojis use
|
||
regional indicator pairs that aren't in DejaVu, so we substitute them
|
||
with their ISO country code in brackets.
|
||
"""
|
||
if not text:
|
||
return ""
|
||
s = str(text)
|
||
for emoji, repl in _EMOJI_REPLACEMENTS.items():
|
||
if emoji in s:
|
||
s = s.replace(emoji, repl)
|
||
return s
|
||
|
||
|
||
def _ascii_safe(text: str) -> str:
|
||
"""Backward-compat alias (other code paths still call this name)."""
|
||
return _safe(text)
|
||
|
||
|
||
def _section(pdf, title: str) -> None:
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "B", 12)
|
||
pdf.set_text_color(0, 221, 68)
|
||
pdf.multi_cell(_page_w(pdf), 7, _ascii_safe(title)[:80])
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "", 10)
|
||
pdf.set_text_color(0)
|
||
|
||
|
||
def _kv(pdf, key: str, value: str) -> None:
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "B", 9)
|
||
pdf.cell(45, 5, _ascii_safe(key)[:30], ln=False)
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "", 9)
|
||
pdf.cell(_page_w(pdf) - 45, 5, _ascii_safe(value)[:100], ln=True)
|
||
|
||
|
||
def _bullet(pdf, text: str, font_size: int = 9) -> None:
|
||
"""Render a bullet line. multi_cell with hard truncation to avoid fpdf
|
||
'Not enough horizontal space' errors on long tokens/URLs."""
|
||
pdf.set_x(pdf.l_margin)
|
||
pdf.set_font(getattr(pdf, "_secubox_family", "Helvetica"), "", font_size)
|
||
safe = _ascii_safe(text)[:160]
|
||
# Break unreasonably long single tokens (URLs over ~60 chars)
|
||
parts = []
|
||
for word in safe.split(" "):
|
||
if len(word) > 60:
|
||
for i in range(0, len(word), 60):
|
||
parts.append(word[i:i + 60])
|
||
else:
|
||
parts.append(word)
|
||
safe = " ".join(parts)
|
||
pdf.multi_cell(_page_w(pdf), 5, " - " + safe)
|
||
|
||
|
||
# #703 — visual donut charts in the PDF (fpdf2 solid_arc sectors + white hole).
|
||
# RGB mirror of the HTML report palette.
|
||
_PDF_DONUT_PALETTE = [
|
||
(0, 221, 68), (158, 118, 255), (255, 136, 102),
|
||
(102, 187, 255), (255, 179, 71), (255, 68, 102),
|
||
]
|
||
|
||
|
||
def _pdf_donut(pdf, x: float, y: float, w: float, title: str, hole: str, segs: list) -> None:
|
||
"""Draw one donut (pie sectors + white centre hole) + legend inside a cell of
|
||
width w at (x, y). segs carry cumulative start/end percents (from _dpi_donut)."""
|
||
fam = getattr(pdf, "_secubox_family", "Helvetica")
|
||
pdf.set_xy(x, y)
|
||
pdf.set_font(fam, "B", 9)
|
||
pdf.set_text_color(0, 90, 64)
|
||
pdf.cell(w, 5, _ascii_safe(title)[:30], ln=False)
|
||
cx, cy, r, rh = x + 15, y + 23, 12.5, 8.0
|
||
if segs:
|
||
for i, s in enumerate(segs):
|
||
pdf.set_fill_color(*_PDF_DONUT_PALETTE[i % len(_PDF_DONUT_PALETTE)])
|
||
a0 = 90.0 - float(s.get("start", 0)) * 3.6
|
||
a1 = 90.0 - float(s.get("end", 0)) * 3.6
|
||
try:
|
||
pdf.solid_arc(cx, cy, r, a0, a1, clockwise=True, style="F")
|
||
except Exception:
|
||
pass
|
||
# centre hole (page is white)
|
||
pdf.set_fill_color(255, 255, 255)
|
||
pdf.ellipse(cx - rh, cy - rh, 2 * rh, 2 * rh, style="F")
|
||
if hole:
|
||
pdf.set_xy(cx - rh, cy - 2)
|
||
pdf.set_font(fam, "", 6)
|
||
pdf.set_text_color(110, 110, 110)
|
||
pdf.cell(2 * rh, 4, _ascii_safe(hole)[:8], align="C")
|
||
# legend (right of the donut)
|
||
ly = y + 8
|
||
for i, s in enumerate(segs[:6]):
|
||
pdf.set_fill_color(*_PDF_DONUT_PALETTE[i % len(_PDF_DONUT_PALETTE)])
|
||
pdf.rect(x + 33, ly + 0.6, 2.4, 2.4, style="F")
|
||
pdf.set_xy(x + 37, ly)
|
||
pdf.set_font(fam, "", 7)
|
||
pdf.set_text_color(40, 40, 40)
|
||
pdf.cell(w - 38, 3.5,
|
||
_ascii_safe(f"{s.get('label', '?')[:16]} {s.get('pct', 0)}%"), ln=False)
|
||
ly += 4
|
||
else:
|
||
pdf.set_xy(x, y + 20)
|
||
pdf.set_font(fam, "", 8)
|
||
pdf.set_text_color(120, 120, 120)
|
||
pdf.cell(w, 5, "Pas de donnees", ln=False)
|
||
pdf.set_text_color(0)
|
||
|
||
|
||
def _pdf_donut_grid(pdf, donuts: list) -> None:
|
||
"""Render up to 4 donuts in a 2x2 grid."""
|
||
if not donuts:
|
||
return
|
||
_section(pdf, "📊 STATS DE TON APPAREIL (graphiques)")
|
||
y0 = pdf.get_y() + 2
|
||
col_w = _page_w(pdf) / 2.0
|
||
row_h = 42.0
|
||
shown = donuts[:4]
|
||
for i, d in enumerate(shown):
|
||
col, row = i % 2, i // 2
|
||
_pdf_donut(pdf, pdf.l_margin + col * col_w, y0 + row * row_h, col_w - 4,
|
||
d.get("title", ""), d.get("hole", ""), d.get("segments") or [])
|
||
rows = (len(shown) + 1) // 2
|
||
pdf.set_y(y0 + rows * row_h + 2)
|
||
|
||
|
||
# #709 — radial "carto" network map (TOI hub → top trackers) for the PDF.
|
||
def _carto_graph(pdf, family: str, nodes: list) -> None:
|
||
import math
|
||
nodes = sorted([n for n in (nodes or []) if n.get("hits")],
|
||
key=lambda n: n.get("hits", 0), reverse=True)[:8]
|
||
if not nodes:
|
||
return
|
||
_section(pdf, "🗺️ CARTO — qui te piste (carte du réseau)")
|
||
cx = pdf.l_margin + _page_w(pdf) / 2.0
|
||
cy = pdf.get_y() + 34
|
||
R = 27.0
|
||
maxh = max(n.get("hits", 1) for n in nodes) or 1
|
||
pdf.set_draw_color(70, 90, 120)
|
||
pdf.set_line_width(0.2)
|
||
placed = []
|
||
for i, n in enumerate(nodes):
|
||
ang = math.radians(-90 + i * 360.0 / len(nodes))
|
||
x, y = cx + R * math.cos(ang), cy + R * math.sin(ang)
|
||
pdf.line(cx, cy, x, y)
|
||
placed.append((x, y, n))
|
||
for (x, y, n) in placed:
|
||
r = 1.6 + 3.2 * (n.get("hits", 1) / maxh)
|
||
pdf.set_fill_color(255, 80, 110)
|
||
pdf.ellipse(x - r, y - r, 2 * r, 2 * r, style="F")
|
||
lbl = f"{n.get('country_flag','')} {(n.get('domain','') or '?')[:12]}"
|
||
pdf.set_font(family, "", 6)
|
||
pdf.set_text_color(90, 90, 90)
|
||
pdf.set_xy(x - 17, (y + r + 0.5) if y >= cy else (y - r - 3.5))
|
||
pdf.cell(34, 3, _safe(lbl), align="C")
|
||
pdf.set_fill_color(0, 212, 255)
|
||
pdf.ellipse(cx - 4.5, cy - 4.5, 9, 9, style="F")
|
||
pdf.set_xy(cx - 9, cy - 1.6)
|
||
pdf.set_font(family, "B", 6)
|
||
pdf.set_text_color(10, 10, 15)
|
||
pdf.cell(18, 3, "TOI", align="C")
|
||
pdf.set_text_color(0)
|
||
pdf.set_y(cy + R + 7)
|
||
|
||
|
||
# #709 — generic emoji data table. cols = [(header, width_fraction), ...]
|
||
def _emoji_table(pdf, family: str, title: str, cols: list, rows: list) -> None:
|
||
if not rows:
|
||
return
|
||
_section(pdf, title)
|
||
w = _page_w(pdf)
|
||
widths = [w * f for (_, f) in cols]
|
||
pdf.set_font(family, "B", 8)
|
||
pdf.set_fill_color(16, 22, 30)
|
||
pdf.set_text_color(0, 212, 255)
|
||
pdf.set_x(pdf.l_margin)
|
||
for (h, _), cw in zip(cols, widths):
|
||
pdf.cell(cw, 5.5, _safe(h), border=0, fill=True)
|
||
pdf.ln()
|
||
pdf.set_font(family, "", 8)
|
||
pdf.set_text_color(35, 35, 35)
|
||
for r in rows:
|
||
pdf.set_x(pdf.l_margin)
|
||
for val, cw in zip(r, widths):
|
||
cap = max(6, int(cw / 1.7))
|
||
pdf.cell(cw, 5, _safe(str(val))[:cap], border="B")
|
||
pdf.ln()
|
||
pdf.ln(2)
|
||
|
||
|
||
def _render_text_fallback(report: dict) -> str:
|
||
"""Plain text fallback when fpdf2 isn't installed."""
|
||
lines = [
|
||
"=" * 64,
|
||
"GONDWANA TOOLBOX - Rapport d'analyse",
|
||
"=" * 64,
|
||
"",
|
||
f"Hash session : {report.get('mac_hash', '?')}",
|
||
f"Type appareil : {report.get('device_type', '?')}",
|
||
f"Date : {report.get('generated_at', '?')}",
|
||
"",
|
||
"fpdf2 not installed -- text fallback. apt install python3-fpdf2",
|
||
]
|
||
return "\n".join(lines)
|