mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
C1: drop debian/compat (kept debhelper-compat (= 13) Build-Depends) — the package was the only one in the repo carrying both, which debhelper refuses to build. C2: cli._run() now returns rc=None for "did not execute" (OSError/timeout), mirroring observe._run_cmd's contract. _cmd_scan checks rc for both systemctl list-unit-files and lxc-ls and aborts rather than silently deriving LXC modules as runtime="native" from empty output; a non-root invocation (rc=0, empty output) is refused explicitly since it is indistinguishable from "no containers" by rc alone. C3: PROTECTED_IDS moves from scan.py to manifest.py (a schema property, not a profiler detail) and load_manifest() now forces protected=True for those ids regardless of what a shipped manifest declares, closing the gap where a sloppy module package could silently unprotect auth/aggregator/core/etc. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
232 lines
9.8 KiB
Python
232 lines
9.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 :: profiles — CLI secubox-profilectl
|
|
CyberMind — https://cybermind.fr
|
|
|
|
Phase 1 : LECTURE SEULE. `scan`, `status`, `diff` — et rien d'autre. `apply`
|
|
n'existe pas encore : il arrive en Phase 3, avec snapshot 4R, application
|
|
séquentielle et audit.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from .diff import ProtectedViolation, plan_changes
|
|
from .manifest import ManifestError, load_all
|
|
from .observe import Actual, is_on, load_routes, observe
|
|
from .scan import discover, write_drafts
|
|
from .state import StateError, load_pins, load_profile
|
|
|
|
DEFAULT_ROOT = Path("/etc/secubox")
|
|
|
|
|
|
def _paths(root: Path):
|
|
return (root / "modules.d", root / "profiles",
|
|
root / "profiles" / "pins.toml", root / "profiles" / "active")
|
|
|
|
|
|
def _observe_all(manifests, routes):
|
|
return {mid: observe(m, routes=routes) for mid, m in manifests.items()}
|
|
|
|
|
|
def _active_profile_name(root: Path, override: str | None) -> str | None:
|
|
if override:
|
|
return override
|
|
_, _, _, active = _paths(root)
|
|
if active.exists():
|
|
name = active.read_text(encoding="utf-8").strip()
|
|
return name or None
|
|
return None
|
|
|
|
|
|
def _load_profile_or_none(root: Path, name: str | None):
|
|
if not name:
|
|
return None
|
|
_, prof_dir, _, _ = _paths(root)
|
|
p = prof_dir / f"{name}.toml"
|
|
if not p.exists():
|
|
raise StateError(f"profil inconnu: {name} ({p} absent)")
|
|
return load_profile(p)
|
|
|
|
|
|
def _cmd_status(args) -> int:
|
|
root = Path(args.root)
|
|
mod_dir, _, _, _ = _paths(root)
|
|
manifests = load_all(mod_dir)
|
|
routes = load_routes()
|
|
actuals = _observe_all(manifests, routes)
|
|
rows = []
|
|
for mid, m in sorted(manifests.items()):
|
|
a = actuals.get(mid, Actual())
|
|
rows.append({
|
|
"id": mid, "category": m.category, "runtime": m.runtime,
|
|
"exposure": m.exposure, "priority": m.priority,
|
|
"protected": m.protected, "on": is_on(a), "rss_kb": a.rss_kb,
|
|
})
|
|
if args.json:
|
|
print(json.dumps({"modules": rows}, ensure_ascii=False))
|
|
return 0
|
|
for r in sorted(rows, key=lambda r: (-r["priority"], r["id"])):
|
|
rss = f"{r['rss_kb'] / 1024:.0f} Mo" if r["rss_kb"] else "—"
|
|
print(f"{'🟢' if r['on'] else '⚫'} {r['id']:<20} {r['category']:<9} "
|
|
f"{r['runtime']:<7} {r['exposure']:<9} prio={r['priority']:<3} {rss:>8}"
|
|
f"{' 🔒' if r['protected'] else ''}")
|
|
return 0
|
|
|
|
|
|
def _cmd_diff(args) -> int:
|
|
root = Path(args.root)
|
|
mod_dir, _, pins_file, _ = _paths(root)
|
|
manifests = load_all(mod_dir)
|
|
profile = _load_profile_or_none(root, _active_profile_name(root, args.profile))
|
|
pins = load_pins(pins_file)
|
|
actuals = _observe_all(manifests, load_routes())
|
|
changes = plan_changes(manifests, profile, pins, actuals)
|
|
payload = [{"id": c.id, "action": c.action, "priority": c.priority,
|
|
"reason": c.reason} for c in changes]
|
|
if args.json:
|
|
print(json.dumps({"changes": payload}, ensure_ascii=False))
|
|
return 0
|
|
if not changes:
|
|
print("✅ rien à changer — l'état réel correspond déjà au profil.")
|
|
return 0
|
|
print(f"{len(changes)} changement(s) — Phase 1 n'applique rien :")
|
|
for c in changes:
|
|
print(f" {'⛔ stop ' if c.action == 'stop' else '▶️ start'} {c.id:<20} ({c.reason})")
|
|
return 0
|
|
|
|
|
|
def _running_as_root() -> bool:
|
|
return os.geteuid() == 0
|
|
|
|
|
|
def _cmd_scan(args) -> int:
|
|
root = Path(args.root)
|
|
mod_dir, _, _, _ = _paths(root)
|
|
|
|
rc, out = _run(["systemctl", "list-unit-files", "secubox-*.service",
|
|
"--no-legend", "--plain"])
|
|
# rc=None (n'a pas pu s'exécuter) ou rc!=0 (a répondu par un échec) sont
|
|
# tous deux des cas indéterminés : on ne peut pas distinguer "aucune unit
|
|
# secubox-*" (out="", rc=0, cas normal) de "systemctl a échoué" sans
|
|
# regarder rc. Continuer sur out="" écrirait silencieusement zéro
|
|
# manifeste en laissant croire que le scan a réussi.
|
|
if rc != 0:
|
|
print(f"⚠️ systemctl list-unit-files a échoué (rc={rc!r}) — impossible "
|
|
"d'énumérer les units secubox-*.service. scan ne peut pas "
|
|
"produire un inventaire fiable dans cet état ; corrigez systemd "
|
|
"puis relancez `scan`.", file=sys.stderr)
|
|
return 1
|
|
units = [line.split()[0] for line in out.splitlines() if line.strip()]
|
|
|
|
# lxc-ls non-root répond rc=0 avec une sortie vide (silencieuse) plutôt
|
|
# qu'une erreur — indistinguable d'une box sans conteneur. Sur cette box
|
|
# (24 conteneurs LXC), ça déclasserait silencieusement tous les modules
|
|
# LXC en runtime="native" dans un manifeste qui fait ensuite autorité :
|
|
# Phase 3 ne lancerait alors jamais `lxc-stop` sur eux. On refuse plutôt
|
|
# que d'écrire un inventaire qu'on sait potentiellement faux.
|
|
if not _running_as_root():
|
|
print("⚠️ scan doit être lancé en root : sans privilèges, `lxc-ls` "
|
|
"renvoie une liste vide (rc=0, pas une erreur) et scan "
|
|
"dériverait à tort tous les conteneurs LXC en runtime=\"native\" "
|
|
"dans un manifeste qui fait ensuite autorité (Phase 3 ne "
|
|
"lancerait alors jamais `lxc-stop` sur ces modules). Relancez "
|
|
"`scan` en root (sudo).", file=sys.stderr)
|
|
return 1
|
|
|
|
rc, out = _run(["lxc-ls", "-1"])
|
|
# Même raisonnement que ci-dessus pour list-unit-files : rc=None ou
|
|
# rc!=0 est indéterminé, jamais silencieusement traité comme "aucun
|
|
# conteneur". La conséquence d'un mauvais repli ici est sévère (tous les
|
|
# modules LXC dérivés en "native") : on abandonne plutôt que d'écrire.
|
|
if rc != 0:
|
|
print(f"⚠️ lxc-ls a échoué (rc={rc!r}) — impossible de déterminer "
|
|
"quels modules tournent en conteneur LXC. Continuer "
|
|
"dériverait tous les modules LXC en runtime=\"native\" dans un "
|
|
"manifeste qui fait ensuite autorité (Phase 3 ne lancerait "
|
|
"alors jamais `lxc-stop`). Corrigez lxc-ls (paquet lxc "
|
|
"installé ? PATH ?) puis relancez `scan`.", file=sys.stderr)
|
|
return 1
|
|
lxc_names = {n.strip() for n in out.splitlines() if n.strip()}
|
|
# load_routes() renvoie None quand le fichier de routes existe mais est
|
|
# illisible/corrompu (indéterminable, distinct de "aucune route" = set()).
|
|
# discover() attend un set() ferme (voir scan._route_for) donc on retombe
|
|
# sur "aucune route connue" pour ne pas planter — MAIS ce repli fait
|
|
# dériver `exposure` vers le bas (public -> lan/internal) pour tout module
|
|
# routé, silencieusement, et scan n'écrase pas sans --force : la valeur
|
|
# dégradée resterait autoritaire. On prévient donc l'opérateur sur stderr
|
|
# dans ce seul cas (fichier présent mais illisible), jamais quand le
|
|
# fichier est simplement absent (cas normal, routes = set()).
|
|
routes = load_routes()
|
|
if routes is None:
|
|
print("⚠️ fichier de routes WAF illisible/corrompu — exposure peut être "
|
|
"sous-évaluée pour les modules routés (public -> lan/internal). "
|
|
"Corrigez le fichier de routes puis relancez `scan --force`.",
|
|
file=sys.stderr)
|
|
routes = set()
|
|
manifests = discover(units=units, lxc_names=lxc_names, routes=routes)
|
|
written = write_drafts(manifests, mod_dir, force=args.force)
|
|
skipped = len(manifests) - len(written)
|
|
print(f"{len(manifests)} module(s) découvert(s) — {len(written)} manifeste(s) écrit(s), "
|
|
f"{skipped} conservé(s) (déjà présents ; --force pour écraser).")
|
|
return 0
|
|
|
|
|
|
def _run(argv: list[str]) -> tuple[int | None, str]:
|
|
"""rc=None signale que la commande n'a PAS pu s'exécuter (OSError, timeout) —
|
|
à distinguer d'un rc non-nul qui est une réponse authentique de la commande.
|
|
Même contrat que observe._run_cmd : un (1, "") fabriqué ici serait
|
|
indistinguable d'une vraie réponse "non" de la commande (voir _cmd_scan,
|
|
qui a besoin de cette distinction pour ne pas écrire un manifeste faux)."""
|
|
try:
|
|
p = subprocess.run(argv, capture_output=True, text=True, timeout=15)
|
|
return p.returncode, p.stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None, ""
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(
|
|
prog="secubox-profilectl",
|
|
description="Inventaire et diff des modules SecuBox (Phase 1 : lecture seule).")
|
|
p.add_argument("--root", default=str(DEFAULT_ROOT),
|
|
help="racine de config (défaut: /etc/secubox)")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
sp = sub.add_parser("status", help="état et coût de chaque module")
|
|
sp.add_argument("--json", action="store_true")
|
|
sp.set_defaults(func=_cmd_status)
|
|
|
|
sp = sub.add_parser("diff", help="ce qu'un profil changerait (n'applique rien)")
|
|
sp.add_argument("--profile", default=None)
|
|
sp.add_argument("--json", action="store_true")
|
|
sp.set_defaults(func=_cmd_diff)
|
|
|
|
sp = sub.add_parser("scan", help="dériver les manifestes du réel")
|
|
sp.add_argument("--force", action="store_true",
|
|
help="écraser les manifestes existants (ils font autorité par défaut)")
|
|
sp.set_defaults(func=_cmd_scan)
|
|
|
|
args = p.parse_args(argv)
|
|
try:
|
|
return args.func(args)
|
|
except (ManifestError, StateError) as exc:
|
|
print(f"erreur: {exc}", file=sys.stderr)
|
|
return 2
|
|
except ProtectedViolation as exc:
|
|
print(f"refusé: {exc}", file=sys.stderr)
|
|
return 3
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|