#!/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())