[FIX] anonymize : passer la liste noire entière sans échec ni omission

Le SQL de la liste noire dépasse MAX_ARG_STRLEN, les 131 072 octets que Linux
impose à UN argument ; il passe par un fichier avec -f, qui garde
--single-transaction que l'entrée standard aurait perdu. Les colonnes texte à
longueur déclarée sont tronquées par left(..., n), l'identifiant en TÊTE sur
une colonne unique. Tous les identifiants sont cités : Odoo laisse nommer un
champ user ou order.

Écarter toute colonne sous CHECK laissait res_partner.name intact ; sur du
texte, seules les contraintes de FORME sont hors de portée. Vérifié sur les
quatre cas ; un UPDATE qui échoue n'écrit rien.

--- EN ---

The blacklist SQL exceeds MAX_ARG_STRLEN, the 131 072 bytes Linux allows for
ONE argument; it travels through a file with -f, which keeps
--single-transaction that stdin would have dropped. Text columns with a
declared length are truncated by left(..., n), the id FIRST on a unique column.
Every identifier is quoted: Odoo allows a field named user or order.

Skipping every column under a CHECK left res_partner.name untouched; on text,
only FORM constraints are out of reach. Checked on the four cases; an UPDATE
that fails writes nothing.

Assisted-by: Claude Opus 5
(cherry picked from commit 610f1d30414d578b49bff5649dbda82f4a2eb19f)
This commit is contained in:
Mathieu Benoit 2026-08-25 04:32:08 -04:00
parent fe52ad9974
commit 13a7bca972
2 changed files with 350 additions and 38 deletions

View file

@ -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"

View file

@ -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))