Elle éprouvait 45 modèles sur 125 portant une règle globale, contre quatre utilisateurs internes — et les quatre étaient administrateurs. Un modèle que seuls les administrateurs peuvent lire passait donc au vert : c'est exactement la forme du bug DMS qui a motivé cet outil, vue d'un autre angle. Elle ne peut pas créer un témoin ordinaire — ce serait une écriture. Elle peut dire qu'elle n'en avait pas, annoncer sa couverture au lieu du seul nombre éprouvé, et déclarer qu'un masquage codé en Python lui échappe puisqu'elle part d'ir_rule. Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge. --- EN --- It tested 45 models out of the 125 carrying a global rule, against four internal users — and all four were administrators. A model only admins can read therefore passed green: exactly the shape of the DMS bug that motivated this tool, seen from another angle. It cannot create an ordinary witness — that would be a write. It can say it had none, announce its coverage rather than only the number checked, and declare that masking coded in Python escapes it since it starts from ir_rule. A green that proves less than it looks is worse than a red. Assisted-by: Claude Opus 5
230 lines
8 KiB
Python
Executable file
230 lines
8 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
||
"""Les modèles qui ont des données que PLUS PERSONNE ne peut voir.
|
||
|
||
D'où vient ce test
|
||
------------------
|
||
Après une migration 12 → 18, les documents DMS avaient « disparu ». Ils
|
||
n'avaient pas disparu : 69 fichiers et 23 Mo étaient intacts en base. Ce
|
||
qui avait changé, c'est le modèle de sécurité — OCA DMS pose une règle
|
||
GLOBALE sur une permission accordée par un `dms.access.group`, et la
|
||
conversion depuis MuK n'en avait créé aucun, faute d'équivalent à
|
||
convertir.
|
||
|
||
Rien ne pouvait le voir venir. Les comptages de lignes disaient « tout est
|
||
là », et c'était vrai. Le test de fumée ouvrait des pages publiques, et
|
||
elles répondaient. Le trou est exactement entre les deux : des données
|
||
présentes, et une règle qui les masque intégralement.
|
||
|
||
Ce qu'on cherche
|
||
----------------
|
||
Un modèle tel que : il a des lignes, il porte au moins une règle GLOBALE,
|
||
et AUCUN utilisateur interne actif n'en voit une seule. « Aucun » est le
|
||
mot important — un modèle que seul un comptable voit est normal ; un
|
||
modèle que personne ne voit est soit une refonte de sécurité ratée, soit
|
||
des données devenues inatteignables.
|
||
|
||
Pourquoi pas en SQL
|
||
-------------------
|
||
Une règle est un domaine Odoo, parfois avec `user.`, `company_ids`, ou un
|
||
champ calculé cherchable — `permission_read` de DMS en est un, et il
|
||
déclenche une sous-requête que rien dans `ir_rule` ne laisse deviner. Il
|
||
faut l'ORM pour l'évaluer, donc le shell.
|
||
|
||
Le super-utilisateur est exclu : les règles ne s'appliquent pas à lui, et
|
||
un comptage fait en son nom déclarerait saine une base muette. C'est
|
||
précisément l'erreur qui aurait laissé passer le cas DMS.
|
||
|
||
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
|
||
sys.path.append(
|
||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||
)
|
||
|
||
from script.odoo.migration import database_cleanup # noqa: E402
|
||
|
||
try:
|
||
from script.todo.todo_i18n import t
|
||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||
|
||
def t(key: str) -> str:
|
||
return key
|
||
|
||
|
||
DEBUT = database_cleanup.START
|
||
FIN = database_cleanup.END
|
||
|
||
# Ces modèles sont invisibles PAR DESSEIN : ils portent des règles globales
|
||
# et ne s'adressent à personne en particulier. Les signaler à chaque
|
||
# migration noierait la vraie trouvaille sous le bruit, et un rapport
|
||
# qu'on apprend à ignorer ne sert plus à rien.
|
||
ATTENDUS = (
|
||
"ir.rule",
|
||
"ir.model.access",
|
||
"res.users.log",
|
||
"bus.bus",
|
||
"bus.presence",
|
||
"mail.notification",
|
||
"ir.logging",
|
||
"ir.autovacuum",
|
||
)
|
||
|
||
SCRIPT = """
|
||
import json
|
||
LIMITE = {limite}
|
||
ATTENDUS = set({attendus!r})
|
||
rapport = {{"models": [], "checked": 0, "users": []}}
|
||
try:
|
||
# Les utilisateurs INTERNES actifs, sans le super-utilisateur : les
|
||
# règles ne s'appliquent pas à lui, et compter en son nom
|
||
# déclarerait saine une base que personne ne peut lire.
|
||
membres = env["res.users"].sudo().search([
|
||
("active", "=", True),
|
||
("id", "!=", 1),
|
||
("share", "=", False),
|
||
], limit=LIMITE)
|
||
rapport["users"] = membres.mapped("login")
|
||
# Les témoins ORDINAIRES : ceux qui ne sont pas administrateurs.
|
||
# Un modèle que seuls les administrateurs peuvent lire passe au vert
|
||
# si tous les témoins en sont — c'est exactement la forme du bug DMS,
|
||
# vue d'un autre angle. Mesuré sur une base réelle : les QUATRE
|
||
# utilisateurs internes étaient membres de base.group_system.
|
||
rapport["ordinary"] = [
|
||
u.login for u in membres if not u.has_group("base.group_system")
|
||
]
|
||
if not membres:
|
||
rapport["no_user"] = True
|
||
else:
|
||
vus = set(
|
||
env["ir.rule"].sudo()
|
||
.search([("global", "=", True), ("active", "=", True)])
|
||
.mapped("model_id.model")
|
||
)
|
||
# La COUVERTURE : combien de modèles portent une règle globale,
|
||
# et non combien on a pu éprouver. « 45 éprouvés » sans dire
|
||
# « sur 125 » laisse croire à un examen complet.
|
||
rapport["with_rule"] = len(vus - ATTENDUS)
|
||
for nom in sorted(vus - ATTENDUS):
|
||
modele = env.get(nom)
|
||
if modele is None or modele._abstract or modele._transient:
|
||
continue
|
||
try:
|
||
total = modele.sudo().search_count([])
|
||
except Exception:
|
||
continue
|
||
if not total:
|
||
continue
|
||
rapport["checked"] += 1
|
||
# Court-circuit : dès qu'UN utilisateur voit une ligne, le
|
||
# modèle n'est pas muet. Inutile d'interroger les autres.
|
||
visible = False
|
||
for membre in membres:
|
||
try:
|
||
if modele.with_user(membre).search([], limit=1):
|
||
visible = True
|
||
break
|
||
except Exception:
|
||
# Un refus d'accès n'est pas une ligne visible.
|
||
continue
|
||
if not visible:
|
||
rapport["models"].append({{"model": nom, "rows": total}})
|
||
except Exception as exc:
|
||
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
|
||
print({debut!r})
|
||
print(json.dumps(rapport))
|
||
print({fin!r})
|
||
"""
|
||
|
||
|
||
def build_script(limite=25):
|
||
return SCRIPT.format(
|
||
limite=limite, attendus=list(ATTENDUS), debut=DEBUT, fin=FIN
|
||
)
|
||
|
||
|
||
def render(rapport):
|
||
if rapport.get("no_user"):
|
||
return [f"⚠ {t('No internal user to test visibility with.')}"]
|
||
muets = rapport.get("models") or []
|
||
porteurs = rapport.get("with_rule")
|
||
sur = f" {t('out of')} {porteurs}" if porteurs else ""
|
||
lignes = [
|
||
f"🔍 {rapport.get('checked', 0)}{sur}"
|
||
f" {t('model(s) with a global rule and some data,')}"
|
||
f" {t('checked against')} {len(rapport.get('users') or [])}"
|
||
f" {t('internal user(s)')}"
|
||
]
|
||
# Dire ce qu'on n'a PAS pu éprouver, avant de dire ce qu'on a trouvé.
|
||
# Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge.
|
||
if "ordinary" in rapport and not rapport["ordinary"]:
|
||
lignes.append(
|
||
f" ⚠ {t('Every witness is an administrator: a model only')}"
|
||
f" {t('admins can read passes this test.')}"
|
||
)
|
||
lignes.append(
|
||
f" ℹ {t('Reads ir_rule only — masking coded in Python escapes it.')}"
|
||
)
|
||
if not muets:
|
||
lignes.append(f" ✅ {t('Every one of them is visible to someone.')}")
|
||
return lignes
|
||
lignes.append(
|
||
f" ❌ {len(muets)} {t('model(s) nobody can see a single row of')} :"
|
||
)
|
||
for entree in sorted(muets, key=lambda item: -item["rows"]):
|
||
lignes.append(
|
||
f" {entree['model']:<38} {entree['rows']:>8}"
|
||
f" {t('row(s)')}"
|
||
)
|
||
lignes.append(
|
||
f" {t('The data is there; a global rule hides all of it.')}"
|
||
)
|
||
return lignes
|
||
|
||
|
||
def main(argv=None):
|
||
parser = argparse.ArgumentParser(
|
||
description=(
|
||
"Report models that hold data no internal user can see,"
|
||
" because a global record rule filters every row out."
|
||
)
|
||
)
|
||
parser.add_argument("-d", "--database", required=True)
|
||
parser.add_argument("-c", "--config", default="config.conf")
|
||
parser.add_argument(
|
||
"--users",
|
||
type=int,
|
||
default=25,
|
||
help="how many internal users to test with (default: 25)",
|
||
)
|
||
config = parser.parse_args(argv)
|
||
|
||
souci = database_cleanup.require_matching_version(config.database)
|
||
if souci:
|
||
print(f"❌ {souci}")
|
||
return 2
|
||
try:
|
||
rapport = database_cleanup.run_shell(
|
||
config.database,
|
||
config.config,
|
||
build_script(config.users),
|
||
echo=lambda texte: print(f"⧖ {texte}", flush=True),
|
||
)
|
||
except RuntimeError as exc:
|
||
print(f"❌ {exc}")
|
||
return 2
|
||
if rapport.get("error"):
|
||
print(f"❌ {rapport['error']}")
|
||
return 2
|
||
print("\n".join(render(rapport)))
|
||
return 1 if rapport.get("models") else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|