diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py new file mode 100755 index 0000000..63be592 --- /dev/null +++ b/script/analyse/anonymize.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Remplacer les données d'une copie par des données sans intérêt. + +Aucune IA, aucun appel réseau : des mots pris dans une liste et des +nombres tirés au hasard, écrits par des UPDATE SQL. Ce qui rend la chose +délicate n'est pas le remplacement, c'est de savoir CE QU'ON N'A PAS LE +DROIT DE TOUCHER. + +Quatre pièges, tous mesurés sur une base réelle +----------------------------------------------- +1. « Tous les champs string » n'existe pas. 505 champs `selection` sont + stockés en varchar : `res.partner.lang`, `sale.order.invoice_status`. + Y écrire un mot au hasard casse l'ORM, pas la confidentialité. On ne + se fie donc jamais au type PostgreSQL seul, mais au `ttype` que + `ir_model_fields` déclare. +2. 2693 champs `many2one` sont des ENTIERS. Les tirer au hasard + mélangerait toutes les relations de la base. Les nombres qu'on touche + sont ceux qu'Odoo appelle integer, float ou monetary — jamais une + relation. +3. 194 champs texte sont en `jsonb` depuis Odoo 17, un objet par langue. + Écrire une chaîne par-dessus détruit la colonne ; on reconstruit + l'objet, clé par clé. C'est le piège qui a déjà coûté un /contact + réparé en anglais et resté cassé en français. +4. 301 contraintes d'unicité. Deux lignes qui reçoivent le même mot font + échouer tout l'UPDATE. Sur une colonne unique, l'identifiant est + collé au mot. + +Ce qu'on ne touche jamais +------------------------- +Les modèles `ir.*` — vues, champs, xmlid : la base ne s'ouvrirait plus. +Les langues, devises et pays : ce ne sont pas des données personnelles, +et les casser casse les adresses et les montants. Cette liste est un +PLANCHER : aucune liste blanche ne la lève. + +`res.users.login` et le mot de passe restent en place par défaut. On +anonymise pour POUVOIR partager une copie utilisable ; personne ne +pourrait plus s'y connecter. `--include-logins` pour l'autre choix. + +Rien n'est écrit sans `--apply` ET `--confirm `. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +from script.analyse import lib_analyse # noqa: E402 + +TYPES_TEXTE = ("char", "text", "html") +TYPES_NOMBRE = ("integer", "float", "monetary") + +# Le plancher : aucune liste blanche ne le lève. +PREFIXES_INTERDITS = ("ir.",) +MODELES_INTERDITS = frozenset( + { + "res.groups", + "res.lang", + "res.currency", + "res.currency.rate", + "res.country", + "res.country.state", + "res.country.group", + "res.config.settings", + "base.language.install", + "decimal.precision", + "uom.uom", + "uom.category", + } +) +CHAMPS_INTERDITS = frozenset( + { + "id", + "create_uid", + "write_uid", + "create_date", + "write_date", + "sequence", + "active", + "display_name", + "__last_update", + "arch_db", + "arch_fs", + "key", + "model", + "res_model", + "res_field", + "state", + "color", + "company_id", + } +) +CHAMPS_CONNEXION = frozenset({"login", "password"}) + +# Le point de départ du mode hybride : ce qui porte des données +# personnelles dans une base Odoo ordinaire. +MODELES_PAR_DEFAUT = ( + "res.partner", + "res.users", + "res.company", + "res.bank", + "res.partner.bank", + "crm.lead", + "hr.employee", + "mail.message", + "mail.tracking.value", + "account.move", + "sale.order", + "purchase.order", + "project.task", + "calendar.event", + "survey.user_input", +) + +MOTS_PAR_DEFAUT = ( + "alouette", + "bruyere", + "cascade", + "dolmen", + "erable", + "fougere", + "givre", + "hameau", + "iris", + "jonquille", + "lichen", + "marais", + "nenuphar", + "orme", + "pinson", + "roseau", + "sureau", + "tourbe", + "varech", + "zephyr", +) + +MODES = ("whitelist", "blacklist", "hybrid") + + +def modele_interdit(modele): + """Le plancher, en une question.""" + if any(modele.startswith(p) for p in PREFIXES_INTERDITS): + return True + return modele in MODELES_INTERDITS + + +def choisir_modeles(tous, mode, whitelist=(), blacklist=(), defauts=None): + """Quels modèles anonymiser, selon le mode. + + whitelist : ceux-là et rien d'autre. + blacklist : tous SAUF ceux-là. + hybrid : la liste par défaut, plus la blanche, moins la noire — + c'est le mode utile en pratique : on part de ce qui porte + des données personnelles et on ajuste aux marges. + + Le plancher s'applique aux trois : un modèle `ir.*` ne passe par + aucun chemin, même nommé explicitement. + """ + tous = set(tous) + blanche, noire = set(whitelist), set(blacklist) + if mode == "whitelist": + choisis = blanche & tous + elif mode == "blacklist": + choisis = tous - noire + elif mode == "hybrid": + base = set(MODELES_PAR_DEFAUT if defauts is None else defauts) + choisis = ((base | blanche) - noire) & tous + else: + raise ValueError(f"{t('Unknown mode:')} {mode}") + return sorted(m for m in choisis if not modele_interdit(m)) + + +def champ_retenu(champ, inclure_connexion=False): + """Ce champ-là se remplace-t-il ? + + `champ` : dict avec model, name, ttype, pg_type, unique. + """ + if champ["name"] in CHAMPS_INTERDITS: + return False + if champ["name"] in CHAMPS_CONNEXION and not inclure_connexion: + return False + if champ["name"].endswith("_id") or champ["name"].endswith("_ids"): + # Une relation qui aurait échappé au filtre de ttype. + return False + if champ.get("checked"): + # Une contrainte CHECK dit ce que la colonne a le droit de valoir. + # Mesuré : crm_lead.probability doit rester entre 0 et 100, et un + # tirage à 1000 fait échouer l'UPDATE — donc, transaction unique + # oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour + # tirer dedans serait deviner ; on s'abstient et on le dit. + return False + if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb": + # Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut + # vivre dans un jsonb par société. Y écrire un nombre nu ferait + # échouer l'UPDATE — et donc, transaction unique oblige, TOUTE + # l'anonymisation. On s'abstient plutôt que de deviner sa forme. + return False + return champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE + + +def mots_pour(nom_champ, mots): + """La liste de mots à utiliser pour ce champ. + + `mots` peut être une simple liste — la même partout — ou un + dictionnaire par nom de champ avec une entrée `*` en repli. Le + dictionnaire permet de garder des courriels qui ressemblent à des + courriels, ce qu'une liste unique ne sait pas faire. + """ + if isinstance(mots, dict): + choix = mots.get(nom_champ) or mots.get("*") or MOTS_PAR_DEFAUT + else: + choix = mots or MOTS_PAR_DEFAUT + return tuple(str(m) for m in choix) or MOTS_PAR_DEFAUT + + +def litteral(texte): + """Un littéral SQL. Le seul endroit où du texte entre dans la requête.""" + return "'" + str(texte).replace("'", "''") + "'" + + +def expression_texte(champ, mots): + """Le SQL qui remplace un champ texte, en préservant les NULL. + + Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en + avait pas : la copie mentirait dans l'autre sens. + """ + nom = champ["name"] + liste = mots_pour(nom, mots) + # Les parenthèses ne sont pas décoratives : PostgreSQL refuse + # d'indexer un constructeur ARRAY[...] directement. + tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])" + tirage = f"{tableau}[(id % {len(liste)}) + 1]" + if champ.get("unique"): + # Deux lignes qui reçoivent le même mot feraient échouer TOUT + # l'UPDATE sur une colonne unique. + tirage = f"{tirage} || '-' || id::text" + if champ.get("pg_type") == "jsonb": + # Un objet par langue depuis Odoo 17 : on le reconstruit clé à + # clé. Écrire une chaîne par-dessus détruirait la colonne. + return ( + f"CASE WHEN {nom} IS NULL THEN NULL ELSE" + f" (SELECT jsonb_object_agg(kv.key, {tirage})" + f" FROM jsonb_each_text({nom}) AS kv) END" + ) + return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" + + +def expression_nombre(champ): + """Le SQL qui remplace un nombre : au hasard, entre 0 et 1000.""" + nom = champ["name"] + if champ["ttype"] == "integer": + tirage = "floor(random() * 1001)::integer" + else: + tirage = "round((random() * 1000)::numeric, 2)" + return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" + + +def sql_pour_table(table, champs, mots): + """Un seul UPDATE par table : toutes ses colonnes d'un coup.""" + morceaux = [] + for champ in champs: + if champ["ttype"] in TYPES_TEXTE: + morceaux.append( + f"{champ['name']} = {expression_texte(champ, mots)}" + ) + else: + morceaux.append(f"{champ['name']} = {expression_nombre(champ)}") + if not morceaux: + return None + return f"UPDATE {table} SET " + ", ".join(morceaux) + ";" + + +def table_de(modele): + """Le nom de table qu'Odoo donne à ce modèle.""" + return modele.replace(".", "_") + + +SEP = "\x1f" + +# On croise TROIS sources, et c'est la raison d'être de cette requête : +# `ir_model_fields` dit ce qu'Odoo croit (le ttype, seul capable de +# distinguer un `selection` d'un vrai texte), `pg_attribute` dit ce que +# PostgreSQL a vraiment (jsonb ou varchar), et `pg_constraint` dit ce qui +# doit rester unique. Aucune des trois ne suffit seule. +REQUETE_CHAMPS = """ +SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f' + || a.atttypid::regtype::text || '\x1f' + || CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint k + WHERE k.conrelid = c.oid + AND k.contype IN ('u', 'p') + AND a.attnum = ANY(k.conkey) + ) THEN '1' ELSE '0' END || '\x1f' + || CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint k + WHERE k.conrelid = c.oid + AND k.contype = 'c' + AND a.attnum = ANY(k.conkey) + ) THEN '1' ELSE '0' END + FROM ir_model_fields f + JOIN pg_class c ON c.relname = replace(f.model, '.', '_') + AND c.relkind = 'r' + JOIN pg_attribute a ON a.attrelid = c.oid + AND a.attname = f.name + AND a.attnum > 0 + AND NOT a.attisdropped + WHERE f.store + AND f.ttype IN ('char','text','html','integer','float','monetary') + ORDER BY f.model, f.name +""" + + +def inspect(database, config_path=None): + """Tous les champs remplaçables de la base, avec leurs trois vérités.""" + brut = lib_analyse.run_psql( + database, REQUETE_CHAMPS, config_path=config_path + ) + champs = [] + for ligne in brut.splitlines(): + parts = ligne.split(SEP) + if len(parts) != 6: + continue + champs.append( + { + "model": parts[0], + "name": parts[1], + "ttype": parts[2], + "pg_type": parts[3], + "unique": parts[4] == "1", + "checked": parts[5] == "1", + } + ) + return champs + + +def plan( + champs, + mode, + whitelist=(), + blacklist=(), + inclure_connexion=False, + mots=None, +): + """Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit. + + Rendu séparément de l'exécution pour que le mode « à blanc » montre + EXACTEMENT ce que `--apply` ferait, et non une approximation. + """ + modeles = choisir_modeles( + {c["model"] for c in champs}, mode, whitelist, blacklist + ) + retenus = set(modeles) + par_modele = {} + for champ in champs: + if champ["model"] not in retenus: + continue + if not champ_retenu(champ, inclure_connexion): + continue + par_modele.setdefault(champ["model"], []).append(champ) + etapes = [] + for modele in modeles: + liste = par_modele.get(modele) + if not liste: + continue + sql = sql_pour_table(table_de(modele), liste, mots) + if sql: + etapes.append({"model": modele, "fields": liste, "sql": sql}) + return etapes + + +def render(etapes, applique=False, verbeux=False): + """Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris.""" + if not etapes: + return f"✅ {t('Nothing to anonymise with these lists.')}" + total = sum(len(e["fields"]) for e in etapes) + tete = ( + f"🎭 {len(etapes)} {t('model(s)')}, {total} {t('column(s)')}" + f" — {t('written') if applique else t('dry run, nothing written')}" + ) + lignes = [tete, ""] + for etape in etapes: + textes = [f for f in etape["fields"] if f["ttype"] in TYPES_TEXTE] + nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE] + traduits = [f for f in textes if f["pg_type"] == "jsonb"] + uniques = [f for f in etape["fields"] if f["unique"]] + detail = f"{len(textes)} {t('text')}, {len(nombres)} {t('numeric')}" + if traduits: + detail += f", {len(traduits)} {t('translated (jsonb)')}" + if uniques: + detail += f", {len(uniques)} {t('unique')}" + lignes.append(f" {etape['model']:<34} {detail}") + if verbeux: + for champ in etape["fields"]: + lignes.append( + f" {champ['name']:<30} {champ['ttype']}" + f" / {champ['pg_type']}" + ) + if not applique: + lignes.append("") + lignes.append(f" {t('Use --apply --confirm to write.')}") + return "\n".join(lignes) + + +def charger_mots(chemin): + """Lire un fichier Python qui déclare MOTS. + + Une liste — les mêmes mots partout — ou un dictionnaire par nom de + champ avec un repli `*`. Aucun réseau, aucun modèle : des mots. + """ + if not chemin: + return None + espace = {} + with open(chemin, "r", encoding="utf-8") as handle: + exec(compile(handle.read(), chemin, "exec"), espace) # noqa: S102 + mots = espace.get("MOTS") + if not mots: + raise ValueError(f"{t('This file declares no MOTS:')} {chemin}") + return mots + + +def ecrire(database, etapes, config_path=None, timeout=900): + """Écrire les UPDATE — TOUS, ou AUCUN. None si tout a réussi. + + Une seule transaction (`-1`) et `ON_ERROR_STOP=1`. Sans cela, une + collision d'unicité au dixième modèle laisserait une base à moitié + anonymisée — c'est-à-dire une base dont plus personne ne peut dire ce + qui est vrai, et que rien ne rattrape sinon une restauration. + + On reprend `pg_env` pour la connexion — hôte, port, mot de passe lus + dans config.conf — et l'on ne lève QUE la lecture seule. La + redéclarer ici, ce serait accepter qu'elle diverge un jour. + """ + import subprocess + + env = lib_analyse.pg_env(config_path, timeout=timeout) + env["PGOPTIONS"] = f"-c statement_timeout={timeout}s" + sql = "\n".join(etape["sql"] for etape in etapes) + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-1", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tA", + "-c", + sql, + ], + capture_output=True, + text=True, + env=env, + timeout=timeout + 60, + ) + if done.returncode: + detail = (done.stderr or "").strip().splitlines() + return detail[0][:200] if detail else "psql" + return None + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=t("Replace the data of a COPY with meaningless data."), + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", help="odoo config file") + parser.add_argument("--mode", choices=MODES, default="hybrid") + parser.add_argument( + "--models", default="", help=t("comma separated, adds to the mode") + ) + parser.add_argument( + "--exclude", default="", help=t("comma separated, removed from it") + ) + parser.add_argument("--words", help=t("python file declaring MOTS")) + parser.add_argument("--include-logins", action="store_true") + parser.add_argument("--apply", action="store_true") + parser.add_argument( + "--confirm", + default="", + help=t("repeat the database name; --apply refuses without it"), + ) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args(argv) + + # Le garde-fou, AVANT toute lecture : personne ne doit découvrir en + # route qu'il a lancé cela sur la mauvaise base. + if args.apply and args.confirm != args.database: + print( + f"❌ {t('Refusing to write: --confirm must repeat')}" + f" '{args.database}'.", + file=sys.stderr, + ) + return 2 + + try: + lib_analyse.require_odoo_database( + args.database, config_path=args.config + ) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + try: + mots = charger_mots(args.words) + champs = inspect(args.database, args.config) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + etapes = plan( + champs, + args.mode, + [m.strip() for m in args.models.split(",") if m.strip()], + [m.strip() for m in args.exclude.split(",") if m.strip()], + args.include_logins, + mots, + ) + if not args.apply: + print(render(etapes, applique=False, verbeux=args.verbose)) + return 1 if etapes else 0 + + erreur = ecrire(args.database, etapes, args.config) + if erreur: + print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr) + return 2 + print(render(etapes, applique=True, verbeux=args.verbose)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/monitoring.py b/script/analyse/monitoring.py index cba79aa..fc3cbc3 100644 --- a/script/analyse/monitoring.py +++ b/script/analyse/monitoring.py @@ -68,7 +68,7 @@ ANALYSES = ( " step-by-step databases.", "script": "script/analyse/check_migration_residue.py", "kinds": (KIND_DATABASE,), - "needs_sql": "It reads pg_catalog — indexes and real tables — which" + "why_not": "It reads pg_catalog — indexes and real tables — which" " no RPC session exposes.", }, { @@ -78,13 +78,28 @@ ANALYSES = ( " administrator — read for the use you intend.", "script": "script/analyse/check_instance_state.py", "kinds": (KIND_DATABASE,), - "needs_sql": "Several checks read tables no RPC session exposes," + "why_not": "Several checks read tables no RPC session exposes," " and the lateness of a job is computed in SQL.", # Le même chiffre veut dire deux choses opposées selon qu'on # ausculte une copie ou une production : l'attente ne se devine # pas, elle se demande. "asks_expect": True, }, + { + "key": "anonymize", + "title": "Anonymise a copy", + "why": "Replace names, texts and numbers with meaningless ones so" + " the copy can be shared. No AI: words from a list, numbers drawn" + " at random, written by SQL.", + "script": "script/analyse/anonymize.py", + "kinds": (KIND_DATABASE,), + # La seule entrée qui ÉCRIT. Le refus d'une instance vivante n'a + # rien à voir avec le SQL : on ne détruit pas les données d'une + # production, même à la demande. + "why_not": "This one WRITES. It is offered only for a database" + " you restored here — never for an instance in service.", + "writes": True, + }, { "key": "cow_views", "title": "Customised views, website copies included", @@ -92,7 +107,7 @@ ANALYSES = ( " shadows a module view.", "script": "script/analyse/analyse_view_custom.py", "kinds": (KIND_DATABASE,), - "needs_sql": "Comparing a copy with the module view it hides is a" + "why_not": "Comparing a copy with the module view it hides is a" " join on arch_db, done in SQL.", }, { @@ -102,7 +117,7 @@ ANALYSES = ( " them have no column behind them.", "script": "script/analyse/analyse_custom_field.py", "kinds": (KIND_DATABASE,), - "needs_sql": "Telling a declared field from a real column means" + "why_not": "Telling a declared field from a real column means" " reading pg_attribute.", }, ) diff --git a/script/analyse/monitoring_tui.py b/script/analyse/monitoring_tui.py index 2802d43..56d3946 100644 --- a/script/analyse/monitoring_tui.py +++ b/script/analyse/monitoring_tui.py @@ -63,7 +63,7 @@ def rows(kind): analyse["key"], t(analyse["title"]), utilisable, - "" if utilisable else t(analyse["needs_sql"]), + "" if utilisable else t(analyse["why_not"]), ) ) return lignes @@ -79,7 +79,7 @@ def detail(key, kind): morceaux.append(f"▶ {t('Enter to run it.')}") else: morceaux.append(f"✖ {t('Not available for this source.')}") - morceaux.append(f" {t(analyse['needs_sql'])}") + morceaux.append(f" {t(analyse['why_not'])}") morceaux.append("") morceaux.append(f" {analyse['script']}") return "\n".join(morceaux) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 603064a..dea52b2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -9640,6 +9640,130 @@ TRANSLATIONS = { "fr": "Une instance en service", "en": "An instance in service", }, + "Anonymise a copy": { + "fr": "🎭 Anonymiser une copie", + "en": "🎭 Anonymise a copy", + }, + "Replace names, texts and numbers with meaningless ones so the copy can be shared. No AI: words from a list, numbers drawn at random, written by SQL.": { + "fr": "Remplacer noms, textes et nombres par des valeurs sans intérêt, pour pouvoir partager la copie. Sans IA : des mots pris dans une liste, des nombres tirés au hasard, écrits en SQL.", + "en": "Replace names, texts and numbers with meaningless ones so the copy can be shared. No AI: words from a list, numbers drawn at random, written by SQL.", + }, + "This one WRITES. It is offered only for a database you restored here — never for an instance in service.": { + "fr": "Celle-ci ÉCRIT. Elle n'est offerte que pour une base restaurée ici — jamais pour une instance en service.", + "en": "This one WRITES. It is offered only for a database you restored here — never for an instance in service.", + }, + "This DESTROYS the data of": { + "fr": "Ceci DÉTRUIT les données de", + "en": "This DESTROYS the data of", + }, + "there is no undo.": { + "fr": "il n'y a pas de retour en arrière.", + "en": "there is no undo.", + }, + "Type the database name to confirm (empty to cancel): ": { + "fr": "Retapez le nom de la base pour confirmer (vide pour annuler) : ", + "en": "Type the database name to confirm (empty to cancel): ", + }, + "Cancelled: nothing was written.": { + "fr": "Annulé : rien n'a été écrit.", + "en": "Cancelled: nothing was written.", + }, + "Hybrid: the default personal-data models, adjusted": { + "fr": "Hybride : les modèles à données personnelles par défaut, ajustés", + "en": "Hybrid: the default personal-data models, adjusted", + }, + "Whitelist: only the models I name": { + "fr": "Liste blanche : seulement les modèles que je nomme", + "en": "Whitelist: only the models I name", + }, + "Blacklist: every model except those I name": { + "fr": "Liste noire : tous les modèles sauf ceux que je nomme", + "en": "Blacklist: every model except those I name", + }, + "Models to ADD, comma separated (empty for none): ": { + "fr": "Modèles à AJOUTER, séparés par des virgules (vide pour aucun) : ", + "en": "Models to ADD, comma separated (empty for none): ", + }, + "Models to EXCLUDE, comma separated: ": { + "fr": "Modèles à EXCLURE, séparés par des virgules : ", + "en": "Models to EXCLUDE, comma separated: ", + }, + "A whitelist with no model would do nothing.": { + "fr": "Une liste blanche sans modèle ne ferait rien.", + "en": "A whitelist with no model would do nothing.", + }, + "Python file declaring MOTS (empty for the built-in): ": { + "fr": "Fichier Python déclarant MOTS (vide pour la liste intégrée) : ", + "en": "Python file declaring MOTS (empty for the built-in): ", + }, + "Nothing to anonymise with these lists.": { + "fr": "Rien à anonymiser avec ces listes.", + "en": "Nothing to anonymise with these lists.", + }, + "column(s)": { + "fr": "colonne(s)", + "en": "column(s)", + }, + "dry run, nothing written": { + "fr": "marche à blanc, rien n'est écrit", + "en": "dry run, nothing written", + }, + "text": { + "fr": "texte", + "en": "text", + }, + "numeric": { + "fr": "nombre", + "en": "numeric", + }, + "translated (jsonb)": { + "fr": "traduit (jsonb)", + "en": "translated (jsonb)", + }, + "unique": { + "fr": "unique", + "en": "unique", + }, + "Use --apply --confirm to write.": { + "fr": "Utiliser --apply --confirm pour écrire.", + "en": "Use --apply --confirm to write.", + }, + "Replace the data of a COPY with meaningless data.": { + "fr": "Remplacer les données d'une COPIE par des données sans intérêt.", + "en": "Replace the data of a COPY with meaningless data.", + }, + "comma separated, adds to the mode": { + "fr": "séparés par des virgules, s'ajoutent au mode", + "en": "comma separated, adds to the mode", + }, + "comma separated, removed from it": { + "fr": "séparés par des virgules, retirés du mode", + "en": "comma separated, removed from it", + }, + "python file declaring MOTS": { + "fr": "fichier python déclarant MOTS", + "en": "python file declaring MOTS", + }, + "repeat the database name; --apply refuses without it": { + "fr": "répéter le nom de la base ; --apply refuse sans lui", + "en": "repeat the database name; --apply refuses without it", + }, + "Refusing to write: --confirm must repeat": { + "fr": "Écriture refusée : --confirm doit répéter", + "en": "Refusing to write: --confirm must repeat", + }, + "Nothing was written:": { + "fr": "Rien n'a été écrit :", + "en": "Nothing was written:", + }, + "This file declares no MOTS:": { + "fr": "Ce fichier ne déclare aucun MOTS :", + "en": "This file declares no MOTS:", + }, + "Unknown mode:": { + "fr": "Mode inconnu :", + "en": "Unknown mode:", + }, } diff --git a/test/test_anonymize.py b/test/test_anonymize.py new file mode 100644 index 0000000..c7eb172 --- /dev/null +++ b/test/test_anonymize.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Anonymiser : ce qui compte, c'est ce qu'on REFUSE de toucher. + +Remplacer des mots est facile. Ce qui casse une base, c'est de croire que +« tous les champs string » veut dire quelque chose. Mesuré sur une base +réelle en 18 : 505 champs `selection` sont stockés en varchar +(`res.partner.lang`, `sale.order.invoice_status`), 2693 many2one sont des +entiers, 194 textes sont des `jsonb` par langue, et 301 contraintes +d'unicité attendent une collision. + +Trois de ces pièges ont été trouvés en LANÇANT l'outil sur une copie +jetable, pas en le relisant : PostgreSQL refuse d'indexer un +`ARRAY[...]` sans parenthèses, `res_partner.credit_limit` est un jsonb +alors qu'Odoo l'appelle `float`, et `crm_lead.probability` porte un CHECK +qui interdit 1000. Chacun aurait fait échouer l'écriture — et l'écriture +étant transactionnelle, chaque fois la base est restée intacte. Ce +fichier fige ces trois-là pour qu'ils ne reviennent pas. + +Le plancher est l'objet du premier bloc : aucun mode, aucune liste +blanche, aucune insistance ne doit permettre d'écrire dans `ir.*`. +""" + +import ast +import unittest +from pathlib import Path + +from script.analyse import anonymize as anon + +MOTEUR = ( + Path(__file__).resolve().parent.parent + / "script" + / "analyse" + / "anonymize.py" +) + + +def champ( + nom, + ttype="char", + pg="character varying", + unique=False, + checked=False, + modele="res.partner", +): + return { + "model": modele, + "name": nom, + "ttype": ttype, + "pg_type": pg, + "unique": unique, + "checked": checked, + } + + +class TestTheFloorNobodyCanLift(unittest.TestCase): + """`ir.*` reste intouchable, quel que soit le chemin.""" + + def test_ir_models_are_refused_even_when_whitelisted(self): + tous = {"ir.ui.view", "ir.model.fields", "res.partner"} + for mode in anon.MODES: + choisis = anon.choisir_modeles( + tous, mode, whitelist=list(tous), blacklist=[] + ) + self.assertNotIn("ir.ui.view", choisis, mode) + self.assertNotIn("ir.model.fields", choisis, mode) + + def test_languages_and_currencies_are_refused_too(self): + """Ce ne sont pas des données personnelles, et les casser casse + les adresses et les montants.""" + tous = {"res.lang", "res.currency", "res.country", "res.partner"} + choisis = anon.choisir_modeles(tous, "whitelist", whitelist=list(tous)) + self.assertEqual(choisis, ["res.partner"]) + + def test_a_blacklist_of_nothing_still_respects_the_floor(self): + tous = {"ir.ui.view", "res.partner", "res.lang"} + self.assertEqual( + anon.choisir_modeles(tous, "blacklist", blacklist=[]), + ["res.partner"], + ) + + +class TestWhatIsNeverReplaced(unittest.TestCase): + """Le cœur : distinguer un vrai texte d'un varchar qui n'en est pas un.""" + + def test_a_selection_stored_as_varchar_is_left_alone(self): + """505 dans la base mesurée. Y écrire un mot casse l'ORM.""" + self.assertFalse(anon.champ_retenu(champ("lang", "selection"))) + self.assertFalse( + anon.champ_retenu(champ("invoice_status", "selection")) + ) + + def test_a_many2one_is_left_alone(self): + """2693 entiers qui sont des relations.""" + self.assertFalse( + anon.champ_retenu(champ("parent_id", "many2one", "integer")) + ) + + def test_a_many2one_is_refused_by_its_type_not_only_its_name(self): + """Sur la base mesurée, tous les many2one finissent par `_id` — mais + un module maison peut en nommer un `owner`, et la convention ne + peut pas être la seule barrière. C'est le TYPE qui décide.""" + self.assertFalse( + anon.champ_retenu(champ("owner", "many2one", "integer")) + ) + self.assertFalse( + anon.champ_retenu(champ("responsable", "many2one", "integer")) + ) + + def test_anything_named_like_a_relation_is_left_alone(self): + for nom in ("company_id", "tag_ids", "partner_id"): + self.assertFalse(anon.champ_retenu(champ(nom, "integer")), nom) + + def test_a_number_living_in_a_jsonb_is_left_alone(self): + """Mesuré : res_partner.credit_limit est un float DANS un jsonb.""" + self.assertFalse( + anon.champ_retenu(champ("credit_limit", "float", "jsonb")) + ) + + def test_a_column_under_a_check_constraint_is_left_alone(self): + """Mesuré : crm_lead.probability doit rester entre 0 et 100.""" + self.assertFalse( + anon.champ_retenu( + champ("probability", "float", "numeric", checked=True) + ) + ) + + def test_technical_columns_are_left_alone(self): + for nom in ( + "id", + "create_uid", + "write_date", + "state", + "sequence", + "arch_db", + "active", + ): + self.assertFalse(anon.champ_retenu(champ(nom, "char")), nom) + + def test_logins_stay_unless_asked(self): + """Sinon personne ne peut plus ouvrir la copie qu'on anonymise.""" + self.assertFalse(anon.champ_retenu(champ("login"))) + self.assertTrue( + anon.champ_retenu(champ("login"), inclure_connexion=True) + ) + + def test_a_real_text_is_taken(self): + for ttype in ("char", "text", "html"): + self.assertTrue(anon.champ_retenu(champ("name", ttype)), ttype) + + def test_a_real_number_is_taken(self): + for ttype in ("integer", "float", "monetary"): + self.assertTrue( + anon.champ_retenu(champ("amount", ttype, "numeric")), ttype + ) + + +class TestTheSqlItWrites(unittest.TestCase): + def test_a_null_stays_null(self): + """Un NULL devenu mot créerait de la donnée là où il n'y en avait + pas : la copie mentirait dans l'autre sens.""" + sql = anon.expression_texte(champ("name"), ["a"]) + self.assertIn("IS NULL THEN NULL", sql) + self.assertIn( + "IS NULL THEN NULL", + anon.expression_nombre(champ("x", "integer", "integer")), + ) + + def test_the_array_is_parenthesised(self): + """PostgreSQL refuse d'indexer un ARRAY[...] nu — mesuré.""" + sql = anon.expression_texte(champ("name"), ["a", "b"]) + self.assertIn("(ARRAY[", sql) + self.assertNotIn("] ARRAY[", sql) + self.assertRegex(sql, r"\(ARRAY\[[^\]]*\]\)\[") + + def test_a_unique_column_gets_the_id_appended(self): + """Deux lignes au même mot feraient échouer TOUT l'UPDATE.""" + sql = anon.expression_texte(champ("ref", unique=True), ["a"]) + self.assertIn("id::text", sql) + self.assertNotIn( + "id::text", anon.expression_texte(champ("ref"), ["a"]) + ) + + def test_a_translated_column_is_rebuilt_key_by_key(self): + """Écrire une chaîne dans un jsonb détruirait la colonne.""" + sql = anon.expression_texte(champ("comment", "html", "jsonb"), ["a"]) + self.assertIn("jsonb_object_agg", sql) + self.assertIn("jsonb_each_text", sql) + + def test_a_plain_text_column_is_not_treated_as_json(self): + sql = anon.expression_texte(champ("comment", "text", "text"), ["a"]) + self.assertNotIn("jsonb", sql) + + def test_numbers_land_between_zero_and_a_thousand(self): + entier = anon.expression_nombre(champ("n", "integer", "integer")) + self.assertIn("1001", entier) + decimal = anon.expression_nombre(champ("x", "float", "numeric")) + self.assertIn("1000", decimal) + + def test_a_word_with_a_quote_cannot_break_out(self): + """Une liste de mots vient d'un fichier : elle n'est pas de confiance.""" + sql = anon.expression_texte(champ("name"), ["l'ete"]) + self.assertIn("'l''ete'", sql) + + def test_one_update_per_table_not_per_column(self): + sql = anon.sql_pour_table( + "res_partner", [champ("name"), champ("ref")], None + ) + self.assertEqual(sql.count("UPDATE"), 1) + self.assertTrue(sql.endswith(";")) + + def test_no_column_means_no_statement(self): + self.assertIsNone(anon.sql_pour_table("res_partner", [], None)) + + +class TestTheModes(unittest.TestCase): + TOUS = {"res.partner", "crm.lead", "sale.order", "ir.ui.view"} + + def test_whitelist_takes_only_what_it_names(self): + self.assertEqual( + anon.choisir_modeles(self.TOUS, "whitelist", ["crm.lead"]), + ["crm.lead"], + ) + + def test_blacklist_takes_everything_else(self): + choisis = anon.choisir_modeles( + self.TOUS, "blacklist", blacklist=["crm.lead"] + ) + self.assertNotIn("crm.lead", choisis) + self.assertIn("res.partner", choisis) + + def test_hybrid_starts_from_the_defaults_and_adjusts(self): + choisis = anon.choisir_modeles( + self.TOUS, + "hybrid", + whitelist=["sale.order"], + blacklist=["res.partner"], + ) + self.assertIn("sale.order", choisis) + self.assertIn("crm.lead", choisis) # dans les défauts + self.assertNotIn("res.partner", choisis) # retiré + + def test_an_unknown_mode_raises_rather_than_guessing(self): + with self.assertRaises(ValueError): + anon.choisir_modeles(self.TOUS, "peut-etre") + + +class TestTheWordList(unittest.TestCase): + def test_a_flat_list_is_used_everywhere(self): + self.assertEqual(anon.mots_pour("name", ["a", "b"]), ("a", "b")) + + def test_a_dictionary_can_answer_per_field(self): + mots = {"email": ["a@b.c"], "*": ["mot"]} + self.assertEqual(anon.mots_pour("email", mots), ("a@b.c",)) + self.assertEqual(anon.mots_pour("name", mots), ("mot",)) + + def test_an_empty_list_falls_back_to_the_built_in(self): + self.assertEqual(anon.mots_pour("name", []), anon.MOTS_PAR_DEFAUT) + + +class TestThereIsNoModelInTheLoop(unittest.TestCase): + """« sans passer par un GPT » : vérifié sur le code, pas sur parole.""" + + def test_the_engine_imports_nothing_that_could_call_out(self): + arbre = ast.parse(MOTEUR.read_text(encoding="utf-8")) + interdits = { + "requests", + "urllib", + "urllib3", + "http", + "httpx", + "socket", + "openai", + "anthropic", + "xmlrpc", + "json", + } + for noeud in ast.walk(arbre): + noms = [] + if isinstance(noeud, ast.Import): + noms = [a.name.split(".")[0] for a in noeud.names] + elif isinstance(noeud, ast.ImportFrom) and noeud.module: + noms = [noeud.module.split(".")[0]] + for nom in noms: + self.assertNotIn(nom, interdits, nom) + + def test_the_write_is_one_transaction(self): + """Une collision au dixième modèle laisserait une base à moitié + anonymisée, que rien ne rattrape sinon une restauration.""" + arbre = ast.parse(MOTEUR.read_text(encoding="utf-8")) + fonction = [ + n + for n in ast.walk(arbre) + if isinstance(n, ast.FunctionDef) and n.name == "ecrire" + ] + self.assertEqual(len(fonction), 1) + # Le corps SANS la docstring : le texte la mentionne, l'argument + # doit être réellement passé. + corps = [ + n + for n in fonction[0].body + if not ( + isinstance(n, ast.Expr) + and isinstance(n.value, ast.Constant) + and isinstance(n.value.value, str) + ) + ] + litteraux = { + n.value + for bloc in corps + for n in ast.walk(bloc) + if isinstance(n, ast.Constant) and isinstance(n.value, str) + } + self.assertIn("-1", litteraux) + self.assertIn("ON_ERROR_STOP=1", litteraux) + + +class TestTheRefusalToWrite(unittest.TestCase): + """Le refus doit précéder la connexion. + + Sinon ces deux tests passent au vert parce que la base n'existe pas, + et ne prouvent rien du garde. On lit donc le message rendu, et l'on + vérifie qu'aucun psql n'a été appelé. + """ + + def _refus(self, argv): + import contextlib + import io as flux + + appels = [] + vrai = anon.lib_analyse.require_odoo_database + + def espion(*a, **k): + appels.append(a) + return vrai(*a, **k) + + anon.lib_analyse.require_odoo_database = espion + sortie = flux.StringIO() + try: + with contextlib.redirect_stderr(sortie): + code = anon.main(argv) + finally: + anon.lib_analyse.require_odoo_database = vrai + return code, sortie.getvalue(), appels + + def test_apply_without_a_matching_confirm_is_refused(self): + code, message, appels = self._refus( + ["-d", "une_base", "--apply", "--confirm", "une_autre"] + ) + self.assertEqual(code, 2) + self.assertIn( + anon.t("Refusing to write: --confirm must repeat"), message + ) + self.assertEqual(appels, [], "la base a été contactée pour refuser") + + def test_apply_with_no_confirm_at_all_is_refused(self): + code, message, appels = self._refus(["-d", "une_base", "--apply"]) + self.assertEqual(code, 2) + self.assertIn( + anon.t("Refusing to write: --confirm must repeat"), message + ) + self.assertEqual(appels, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index f40ef8c..eb6e9a7 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -63,7 +63,7 @@ class TestWhatASourceAdmits(unittest.TestCase): def test_every_analysis_says_why_it_cannot_do_live(self): for analyse in monitoring.ANALYSES: - self.assertTrue(analyse["needs_sql"].strip(), analyse["key"]) + self.assertTrue(analyse["why_not"].strip(), analyse["key"]) def test_available_and_unavailable_cover_every_analysis(self): for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE):