mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
Some checks are pending
License Headers / check (push) Waiting to run
* docs(spec): rapport kbin fidèle + media types + WebUI DPI (ref #785) * docs(plan): rapport kbin media types implementation plan (ref #785) * feat(core): shared media-catch JSONL aggregator (ref #785) * fix(core): guard media-catch aggregate against malformed fields + full SPDX header (ref #785) * feat(toolbox): _media_stats + wire media_exfil into report routes (ref #785) * feat(toolbox): PDF DPI-exfil/overall donut-grids + media-type block (ref #785) Replaces the text-bullet DPI/EXFIL section (per-device + overall) with _pdf_donut_grid 4-donut grids and adds a new "TYPES DE MEDIAS CAPTES" section (kinds/content-types donut grid + top-hosts emoji table), for parity with the HTML report. Also guards the PDF footer's italic set_font against DejaVu-Oblique not being registered (fonts-dejavu-core ships Regular+Bold only) — a pre-existing latent crash on any full render_pdf() call, uncovered because no prior test exercised the whole function end-to-end. * fix(toolbox): per-grid captions so network/media donuts aren't mislabeled as device stats (ref #785) * feat(toolbox): media-type cards in report web page (me + overall) (ref #785) * fix(toolbox): close media card divs so Overall tab + footer aren't nested in DPI pane (ref #785) * feat(dpi): WebUI card — services by category (bytes) (ref #785) * feat(dpi): /media_types endpoint (MIME media-catch, fail-empty) (ref #785) * feat(dpi): WebUI card — MIME media types + refreshAll wiring (ref #785) * chore: gitignore SDD run scratch + drop stray task report (ref #785) * perf(core): bounded tail-read for media-catch + import-in-try for uniform fail-empty (ref #785) * fix(dpi): backport board-live async control endpoints (drift reconciliation, ref #785) The board ran a manually-deployed main.py with status/restart/start/stop/logs/ interface_list/tc_status/remove_mirred as 'async def' — never committed to git. Backported so the next .deb install doesn't revert them. Signature-only change, verified identical to the live-validated deployed file. * fix(toolbox): PDF render must not wedge the event loop (#785 incident) Heavier #785 report PDFs (more matplotlib donut-grids, ~9s render) + the WAF 504-page auto-retry storm pegged the single uvicorn worker and 504'd the whole toolbox vhost. Fixes: - run render off the event loop (threadpool) so HTML report / landing stay live - serialize renders through one asyncio lock (pyplot is not thread-safe; also bounds CPU so retries can't render in parallel) - short per-device PDF cache with a double-checked lock: a retry storm now triggers exactly ONE render, all other hits return cached bytes instantly - persistent MPLCONFIGDIR (systemd drop-in + postinst dir) so matplotlib builds its font cache once instead of on every process start Validated live on gk2: cold render caches, 8-request retry storm all served from cache (~0.1s), /landing 0.012s throughout. --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
136 lines
4.8 KiB
Python
136 lines
4.8 KiB
Python
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
|
# See LICENCE-CMSD-1.0.md for terms.
|
|
|
|
"""
|
|
SecuBox-Deb :: media-catch aggregator
|
|
|
|
Shared reader for the sbxmitm media-catch discovery log
|
|
(/run/secubox/media-catch.jsonl). Each line is one mediaRecord written by the
|
|
toolbox-ng MITM workers in R4/analyst mode:
|
|
|
|
{"ts":…, "client":"<mac_hash>", "host":…, "url":…,
|
|
"kind":"manifest|video|audio|page", "ctype":"video/mp4", "bytes":123}
|
|
|
|
`client` is the same wg-persona mac_hash the report keys on, so aggregate() can
|
|
produce a per-device (`me`) view alongside the board-wide (`all`) view. Pure /
|
|
stdlib only — no FastAPI, no I/O beyond reading the file. Fail-empty: a missing
|
|
file, empty file, or corrupt lines never raise.
|
|
|
|
`max_bytes` bounds how much of the file is actually read off disk (a bounded
|
|
tail read), while `max_lines` bounds how many records are kept/processed after
|
|
that read — the two are independent caps.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
|
|
MEDIA_CATCH_PATH = "/run/secubox/media-catch.jsonl"
|
|
|
|
_KIND_EMOJI = {"video": "📺", "audio": "🎵", "manifest": "🎞️", "page": "▶️"}
|
|
|
|
|
|
def _tail_lines(path: Path, max_lines: int,
|
|
max_bytes: int = 16 * 1024 * 1024) -> list[str]:
|
|
"""Return up to the last `max_lines` decoded lines, best-effort.
|
|
|
|
Only the tail `max_bytes` of the file are ever read off disk — on a busy
|
|
board the log can grow large, so this bounds memory/IO regardless of
|
|
`max_lines`. If the seek lands mid-line, the (possibly partial) first
|
|
line of the read is dropped.
|
|
"""
|
|
try:
|
|
with path.open("rb") as f:
|
|
size = f.seek(0, os.SEEK_END)
|
|
if size > max_bytes:
|
|
f.seek(size - max_bytes)
|
|
raw = f.read()
|
|
parts = raw.splitlines()
|
|
# Drop the first (possibly partial) line from the mid-file seek.
|
|
parts = parts[1:]
|
|
else:
|
|
f.seek(0)
|
|
raw = f.read()
|
|
parts = raw.splitlines()
|
|
except OSError:
|
|
return []
|
|
if len(parts) > max_lines:
|
|
parts = parts[-max_lines:]
|
|
out: list[str] = []
|
|
for b in parts:
|
|
try:
|
|
out.append(b.decode("utf-8"))
|
|
except UnicodeDecodeError:
|
|
continue
|
|
return out
|
|
|
|
|
|
def _empty_view() -> dict:
|
|
return {"present": False, "flows": 0, "bytes": 0,
|
|
"kinds": [], "ctypes": [], "top_hosts": []}
|
|
|
|
|
|
def _summarize(records: list[dict]) -> dict:
|
|
if not records:
|
|
return _empty_view()
|
|
kinds: Counter = Counter()
|
|
ctypes: Counter = Counter()
|
|
host_bytes: dict = defaultdict(int)
|
|
host_kind: dict = {}
|
|
total_bytes = 0
|
|
for r in records:
|
|
try:
|
|
kind = r.get("kind") or "?"
|
|
kinds[kind] += 1
|
|
ct = r.get("ctype") or ""
|
|
if ct:
|
|
ctypes[ct] += 1
|
|
b = int(r.get("bytes") or 0)
|
|
total_bytes += b
|
|
host = r.get("host") or "?"
|
|
host_bytes[host] += b
|
|
host_kind.setdefault(host, kind)
|
|
except (ValueError, TypeError):
|
|
# Skip malformed-field record; continue with next
|
|
continue
|
|
kinds_out = [{"label": k, "emoji": _KIND_EMOJI.get(k, "🎬"), "count": c}
|
|
for k, c in kinds.most_common()]
|
|
ctypes_out = [{"label": k, "emoji": "🏷️", "count": c}
|
|
for k, c in ctypes.most_common(8)]
|
|
top_hosts = sorted(
|
|
({"host": h, "kind": host_kind.get(h, "?"), "bytes": b}
|
|
for h, b in host_bytes.items()),
|
|
key=lambda x: x["bytes"], reverse=True)[:10]
|
|
return {"present": True, "flows": len(records), "bytes": total_bytes,
|
|
"kinds": kinds_out, "ctypes": ctypes_out, "top_hosts": top_hosts}
|
|
|
|
|
|
def aggregate(path: str = MEDIA_CATCH_PATH, mac_hash: str | None = None,
|
|
max_lines: int = 50_000,
|
|
max_bytes: int = 16 * 1024 * 1024) -> dict:
|
|
"""Aggregate the media-catch log into {all, me} views. Fail-empty."""
|
|
p = Path(path)
|
|
all_records: list[dict] = []
|
|
me_records: list[dict] = []
|
|
for line in _tail_lines(p, max_lines, max_bytes=max_bytes):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rec = json.loads(line)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if not isinstance(rec, dict):
|
|
continue
|
|
all_records.append(rec)
|
|
if mac_hash and rec.get("client") == mac_hash:
|
|
me_records.append(rec)
|
|
return {
|
|
"all": _summarize(all_records),
|
|
"me": _summarize(me_records) if mac_hash else _empty_view(),
|
|
}
|