diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py index 63be592..f3fbc97 100755 --- a/script/analyse/anonymize.py +++ b/script/analyse/anonymize.py @@ -198,11 +198,16 @@ def champ_retenu(champ, inclure_connexion=False): # 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. + # Une contrainte CHECK hors de portée. Mesuré, et la distinction + # compte : sur un NOMBRE toute contrainte borne la valeur — + # crm_lead.probability entre 0 et 100, credit * debit = 0 — et un + # tirage à 1000 fait échouer l'UPDATE, donc toute l'anonymisation. + # Sur du TEXTE, presque toutes ne garantissent que la non-nullité : + # `res_partner.name` n'exige d'être non nul que pour un contact, ce + # qu'un mot satisfait. Les écarter TOUTES laissait le champ le plus + # important de la base intact — une anonymisation qui n'anonymisait + # pas les noms. La requête ne lève donc ce drapeau, pour du texte, + # que sur les contraintes de FORME. 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 @@ -233,22 +238,46 @@ def litteral(texte): return "'" + str(texte).replace("'", "''") + "'" +def ident(nom): + """Un identifiant SQL, cité. + + Odoo laisse nommer un champ `user`, `order` ou `group` : ce sont des + mots réservés de PostgreSQL, et un identifiant nu fait échouer + l'analyse syntaxique — donc, transaction unique oblige, TOUTE + l'anonymisation. Mesuré en liste noire sur une base réelle : + « syntax error at or near "user" ». Les citer coûte deux caractères + et ferme la question pour tous les noms à venir. + """ + return '"' + str(nom).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) + nom = ident(champ["name"]) + liste = mots_pour(champ["name"], 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]" + tirage = f'{tableau}[("id" % {len(liste)}) + 1]' + borne = champ.get("max_len") 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" + # l'UPDATE sur une colonne unique. L'identifiant vient EN TÊTE + # quand la colonne est bornée : c'est lui qui porte l'unicité, et + # une troncature par la droite doit le laisser intact. + if borne: + tirage = f"\"id\"::text || '-' || {tirage}" + else: + tirage = f"{tirage} || '-' || \"id\"::text" + if borne: + # varchar(n) : mesuré, 13 colonnes sont bornées sur une base + # réelle, dont des codes à 1, 2 et 3 caractères. « jonquille » + # dans un varchar(3) fait échouer l'UPDATE entier. + tirage = f"left({tirage}, {borne})" 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. @@ -262,7 +291,7 @@ def expression_texte(champ, mots): def expression_nombre(champ): """Le SQL qui remplace un nombre : au hasard, entre 0 et 1000.""" - nom = champ["name"] + nom = ident(champ["name"]) if champ["ttype"] == "integer": tirage = "floor(random() * 1001)::integer" else: @@ -275,14 +304,13 @@ def sql_pour_table(table, champs, mots): morceaux = [] for champ in champs: if champ["ttype"] in TYPES_TEXTE: - morceaux.append( - f"{champ['name']} = {expression_texte(champ, mots)}" - ) + valeur = expression_texte(champ, mots) else: - morceaux.append(f"{champ['name']} = {expression_nombre(champ)}") + valeur = expression_nombre(champ) + morceaux.append(f"{ident(champ['name'])} = {valeur}") if not morceaux: return None - return f"UPDATE {table} SET " + ", ".join(morceaux) + ";" + return f"UPDATE {ident(table)} SET " + ", ".join(morceaux) + ";" def table_de(modele): @@ -311,7 +339,19 @@ SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f' WHERE k.conrelid = c.oid AND k.contype = 'c' AND a.attnum = ANY(k.conkey) - ) THEN '1' ELSE '0' END + AND ( + -- Sur un NOMBRE, toute contrainte borne la valeur : + -- `credit * debit = 0`, `amount >= 0`. On s'abstient. + f.ttype IN ('integer','float','monetary') + -- Sur du TEXTE, presque toutes ne garantissent que la + -- non-nullité, ce qu'un mot satisfait. Seules celles + -- qui contraignent la FORME sont hors de portée. + OR pg_get_constraintdef(k.oid) ~ + 'char_length|~~|jsonb_typeof|similar to' + ) + ) THEN '1' ELSE '0' END || '\x1f' + || CASE WHEN a.atttypmod > 4 + THEN (a.atttypmod - 4)::text ELSE '' END FROM ir_model_fields f JOIN pg_class c ON c.relname = replace(f.model, '.', '_') AND c.relkind = 'r' @@ -333,7 +373,7 @@ def inspect(database, config_path=None): champs = [] for ligne in brut.splitlines(): parts = ligne.split(SEP) - if len(parts) != 6: + if len(parts) != 7: continue champs.append( { @@ -343,6 +383,11 @@ def inspect(database, config_path=None): "pg_type": parts[3], "unique": parts[4] == "1", "checked": parts[5] == "1", + # varchar(n) : n, sinon None. Mesuré sur une base réelle, + # 13 colonnes sont bornées — dont des codes à 1, 2 et 3 + # caractères. Y écrire « jonquille » fait échouer tout + # l'UPDATE, et donc toute l'anonymisation. + "max_len": int(parts[6]) if parts[6].isdigit() else None, } ) return champs @@ -446,29 +491,56 @@ def ecrire(database, etapes, config_path=None, timeout=900): redéclarer ici, ce serait accepter qu'elle diverge un jour. """ import subprocess + import tempfile 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, + + # PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à + # MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base + # réelle : le mode hybride tient dans 58 Ko et passait, la liste noire + # produit 342 Ko sur 410 modèles et rendait « OSError: [Errno 7] + # Argument list too long ». Le mode qui couvre le plus est justement + # celui qui cassait. + # + # `-f` plutôt que l'entrée standard : `--single-transaction` n'est + # documenté qu'avec `-c` ou `-f`, et c'est lui qui garantit le tout + # ou rien. Le perdre en silence serait pire que le message d'erreur. + handle = tempfile.NamedTemporaryFile( + mode="w", + suffix=".sql", + prefix="el_anonymize_", + encoding="utf-8", + delete=False, ) + try: + handle.write(sql) + handle.close() + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-1", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tA", + "-f", + handle.name, + ], + capture_output=True, + text=True, + env=env, + timeout=timeout + 60, + ) + finally: + try: + os.unlink(handle.name) + except OSError: + pass if done.returncode: detail = (done.stderr or "").strip().splitlines() return detail[0][:200] if detail else "psql" diff --git a/test/test_anonymize.py b/test/test_anonymize.py index c7eb172..6da204d 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -178,9 +178,9 @@ class TestTheSqlItWrites(unittest.TestCase): 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.assertIn('"id"::text', sql) self.assertNotIn( - "id::text", anon.expression_texte(champ("ref"), ["a"]) + '"id"::text', anon.expression_texte(champ("ref"), ["a"]) ) def test_a_translated_column_is_rebuilt_key_by_key(self): @@ -366,3 +366,243 @@ class TestTheRefusalToWrite(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase): + """La panne signalée : « OSError: [Errno 7] Argument list too long ». + + Linux plafonne UN SEUL argument à MAX_ARG_STRLEN — 32 pages, soit + 131 072 octets. Mesuré sur une base réelle : le mode hybride produit + 58 Ko de SQL et passait, la liste noire en produit 342 Ko sur 410 + modèles et cassait. Le mode qui couvre le plus était celui qui + échouait, donc celui qu'aucun de mes essais n'exerçait. + + Le rendu de `render` reste borné, lui ; c'est bien l'exécution qu'il + faut regarder, et pas seulement le plan. + """ + + def _executer(self, etapes): + """Lancer `ecrire` avec un faux psql, et rendre ce qu'il a reçu.""" + import script.analyse.anonymize as module + + vu = {} + vrai_run = module.__dict__.get("subprocess") + + class FauxFait: + returncode = 0 + stdout = "" + stderr = "" + + import subprocess as vrai_subprocess + + def espion(cmd, **kwargs): + vu["cmd"] = list(cmd) + chemin = cmd[cmd.index("-f") + 1] if "-f" in cmd else None + if chemin: + with open(chemin, encoding="utf-8") as handle: + vu["fichier"] = handle.read() + vu["chemin"] = chemin + return FauxFait() + + vrai_env = anon.lib_analyse.pg_env + anon.lib_analyse.pg_env = lambda *a, **k: {"PATH": "/usr/bin"} + vrai_subprocess_run = vrai_subprocess.run + vrai_subprocess.run = espion + try: + erreur = anon.ecrire("une_base", etapes) + finally: + vrai_subprocess.run = vrai_subprocess_run + anon.lib_analyse.pg_env = vrai_env + del vrai_run + vu["erreur"] = erreur + return vu + + def _gros_plan(self, combien=400): + """Un plan de la taille de celui qui cassait.""" + etapes = [] + for index in range(combien): + champ = { + "model": "m.%d" % index, + "name": "name", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + } + etapes.append( + { + "model": champ["model"], + "fields": [champ], + "sql": anon.sql_pour_table("m_%d" % index, [champ], None), + } + ) + return etapes + + def test_no_single_argument_comes_close_to_the_kernel_limit(self): + vu = self._executer(self._gros_plan()) + plus_gros = max(len(a) for a in vu["cmd"]) + self.assertLess( + plus_gros, + 4096, + "un argument porte le SQL : c'est ce qui rendait E2BIG", + ) + + def test_the_sql_goes_through_a_file_not_through_c(self): + vu = self._executer(self._gros_plan(3)) + self.assertIn("-f", vu["cmd"]) + self.assertNotIn("-c", vu["cmd"]) + self.assertIn('UPDATE "m_0"', vu["fichier"]) + + def test_the_single_transaction_survives_the_change(self): + """`--single-transaction` n'est documenté qu'avec -c ou -f : passer + par l'entrée standard l'aurait perdu en silence.""" + vu = self._executer(self._gros_plan(2)) + self.assertIn("-1", vu["cmd"]) + self.assertIn("ON_ERROR_STOP=1", vu["cmd"]) + + def test_the_temporary_file_does_not_survive(self): + import os as vrai_os + + vu = self._executer(self._gros_plan(2)) + self.assertFalse(vrai_os.path.exists(vu["chemin"])) + + +class TestABoundedColumnIsNeverOverflowed(unittest.TestCase): + """`value too long for type character varying(3)`. + + Mesuré sur une base réelle : 13 colonnes texte portent une longueur + déclarée, dont des codes à 1, 2 et 3 caractères — `res.country.code`, + `account.journal.code`. Y écrire « jonquille » fait échouer l'UPDATE, + et comme l'écriture est transactionnelle, TOUTE l'anonymisation. + + Le mode hybride ne touchait aucune de ces colonnes ; la liste noire, + si. Le mode qui couvre le plus est celui qui cassait. + """ + + def _champ(self, **kw): + base = { + "model": "m", + "name": "code", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + base.update(kw) + return base + + def test_a_bounded_column_is_truncated(self): + sql = anon.expression_texte(self._champ(max_len=3), ["jonquille"]) + self.assertIn("left(", sql) + self.assertIn(", 3)", sql) + + def test_an_unbounded_column_is_left_alone(self): + sql = anon.expression_texte(self._champ(), ["jonquille"]) + self.assertNotIn("left(", sql) + + def test_a_bounded_unique_column_keeps_the_id_in_front(self): + """Tronquer par la droite doit laisser l'identifiant intact : + c'est lui qui porte l'unicité.""" + sql = anon.expression_texte( + self._champ(max_len=8, unique=True), ["jonquille"] + ) + self.assertIn("left(\"id\"::text || '-'", sql) + + def test_an_unbounded_unique_column_keeps_the_old_shape(self): + sql = anon.expression_texte(self._champ(unique=True), ["jonquille"]) + self.assertTrue(sql.rstrip().endswith("|| '-' || \"id\"::text END")) + + def test_the_length_is_read_from_the_database_not_guessed(self): + """`atttypmod` est la seule source : une longueur devinée serait + fausse dès qu'un module en change une.""" + self.assertIn("atttypmod", anon.REQUETE_CHAMPS) + + +class TestReservedWordsCannotBreakTheStatement(unittest.TestCase): + """`syntax error at or near "user"`. + + Odoo laisse nommer un champ `user`, `order` ou `group`. Un identifiant + nu fait alors échouer l'analyse syntaxique — et l'écriture étant + transactionnelle, c'est toute l'anonymisation qui tombe. Trouvé en + liste noire sur une base réelle, jamais en mode hybride : les quinze + modèles par défaut n'en portent aucun. + """ + + def _champ(self, nom): + return { + "model": "m", + "name": nom, + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + + def test_a_column_named_like_a_keyword_is_quoted(self): + for nom in ("user", "order", "group", "check", "references", "limit"): + sql = anon.sql_pour_table("t", [self._champ(nom)], ["a"]) + self.assertIn(f'"{nom}" =', sql, nom) + self.assertNotIn(f" {nom} =", sql, nom) + + def test_a_table_named_like_a_keyword_is_quoted(self): + sql = anon.sql_pour_table("order", [self._champ("name")], ["a"]) + self.assertTrue(sql.startswith('UPDATE "order" SET')) + + def test_the_row_identifier_is_quoted_too(self): + """Une seule règle vaut mieux que deux : tout identifiant est cité.""" + sql = anon.expression_texte(self._champ("name"), ["a", "b"]) + self.assertIn('"id"', sql) + + def test_a_quote_inside_an_identifier_cannot_escape(self): + self.assertEqual(anon.ident('a"b'), '"a""b"') + + +class TestACheckDoesNotSilenceTheMainField(unittest.TestCase): + """La règle « écarter toute colonne sous CHECK » était trop large. + + Mesuré : `res_partner.name` porte + CHECK ((type='contact' AND name IS NOT NULL) OR type<>'contact') + — une garantie de non-nullité, qu'un mot satisfait. L'écarter rendait + une anonymisation qui n'anonymisait pas les noms, en annonçant 255 + colonnes écrites. Le pire des deux mondes : silencieux et faux. + + Sur un NOMBRE la distinction s'inverse : `credit * debit = 0` et + `amount >= 0` bornent la valeur, et un tirage à 1000 les viole. + """ + + def test_the_query_tells_numbers_from_text(self): + """La règle vit dans le SQL : c'est là qu'elle se vérifie.""" + requete = anon.REQUETE_CHAMPS + self.assertIn("f.ttype IN ('integer','float','monetary')", requete) + self.assertIn("pg_get_constraintdef", requete) + + def test_only_shape_constraints_disqualify_text(self): + for motif in ("char_length", "~~", "jsonb_typeof"): + self.assertIn(motif, anon.REQUETE_CHAMPS, motif) + + def test_a_field_the_query_cleared_is_anonymised(self): + """`checked=False` doit suffire : aucune seconde barrière cachée.""" + champ = { + "model": "res.partner", + "name": "name", + "ttype": "char", + "pg_type": "character varying", + "unique": False, + "checked": False, + "max_len": None, + } + self.assertTrue(anon.champ_retenu(champ)) + + def test_a_field_the_query_flagged_is_left_alone(self): + champ = { + "model": "account.move.line", + "name": "credit", + "ttype": "monetary", + "pg_type": "numeric", + "unique": False, + "checked": True, + "max_len": None, + } + self.assertFalse(anon.champ_retenu(champ))