[FIX] analyse: ne pas écrire de mots dans les champs qui portent des ids
Odoo déclare `parent_path` en `char` mais y range un chemin d'identifiants — « 1/7/12/ » — reparsé aussitôt par int() : un mot écrit là casse le premier chargement de page. Sept modèles `_parent_store` en portent un dans une base ordinaire, et account_payment_term passe de même `days_next_month` à int(). L'anonymisation écarte les deux noms connus, puis sonde le contenu : une colonne dont chaque valeur est un chemin reste intacte, même dans un module maison. Les barres obliques sont exigées : un numéro tout en chiffres y échapperait. Vérifié sur une base de production : les sept modèles chargent, les partenaires sont anonymisés. --- EN --- Odoo declares `parent_path` as `char` but stores an identifier path in it — « 1/7/12/ » — parsed right back with int(): a word written there breaks the first page load. Seven `_parent_store` models carry one in an ordinary database, and account_payment_term likewise passes `days_next_month` to int(). Anonymisation skips the two known names, then probes the content: a column whose every value is a path is left alone, even in an in-house module. The slashes are required: a number of pure digits would escape it. Verified on a production database: the seven models load, partners are anonymised. Assisted-by: Claude Opus 5 (cherry picked from commit b47c94524d75172e7afce96e62f3f15b7ad6e225)
This commit is contained in:
parent
13a7bca972
commit
2437a9b5ea
3 changed files with 239 additions and 0 deletions
|
|
@ -106,6 +106,24 @@ CHAMPS_INTERDITS = frozenset(
|
|||
)
|
||||
CHAMPS_CONNEXION = frozenset({"login", "password"})
|
||||
|
||||
# Des `char` d'Odoo qui portent une STRUCTURE, pas du texte. Odoo les
|
||||
# reparse, et un mot y fait lever le serveur entier :
|
||||
# parent_path → `int(id) for id in parent_path.split('/')`
|
||||
# (base/models/res_company.py:117, models.py:203)
|
||||
# days_next_month → `int(self.days_next_month)`
|
||||
# (account/models/account_payment_term.py:321)
|
||||
# `parent_path` existe sur tout modèle `_parent_store` — sept dans une
|
||||
# base ordinaire. La sonde de contenu ci-dessous les retrouverait, mais
|
||||
# les nommer coûte moins cher qu'une requête et ne dépend pas des données
|
||||
# présentes le jour où l'on passe.
|
||||
CHAMPS_STRUCTURES = frozenset({"parent_path", "days_next_month"})
|
||||
|
||||
# Un chemin d'identifiants : « 1/ », « 1/7/12/ ». Le motif exige les
|
||||
# barres obliques — sans elles, un numéro de téléphone tout en chiffres
|
||||
# serait pris pour une structure et échapperait à l'anonymisation, ce
|
||||
# qui serait un défaut de confidentialité, pas de robustesse.
|
||||
MOTIF_CHEMIN = r"^[0-9]+(/[0-9]+)*/$"
|
||||
|
||||
# Le point de départ du mode hybride : ce qui porte des données
|
||||
# personnelles dans une base Odoo ordinaire.
|
||||
MODELES_PAR_DEFAUT = (
|
||||
|
|
@ -197,6 +215,8 @@ def champ_retenu(champ, inclure_connexion=False):
|
|||
if champ["name"].endswith("_id") or champ["name"].endswith("_ids"):
|
||||
# Une relation qui aurait échappé au filtre de ttype.
|
||||
return False
|
||||
if champ["name"] in CHAMPS_STRUCTURES:
|
||||
return False
|
||||
if champ.get("checked"):
|
||||
# Une contrainte CHECK hors de portée. Mesuré, et la distinction
|
||||
# compte : sur un NOMBRE toute contrainte borne la valeur —
|
||||
|
|
@ -428,6 +448,75 @@ def plan(
|
|||
return etapes
|
||||
|
||||
|
||||
def colonnes_structurees(database, etapes, config_path=None):
|
||||
"""Les colonnes texte dont TOUT le contenu est un chemin d'identifiants.
|
||||
|
||||
Le filet général, là où `CHAMPS_STRUCTURES` ne nomme que le connu :
|
||||
un module maison peut poser son propre champ de chemin, et il ne
|
||||
portera pas ce nom-là. On regarde donc ce que la colonne CONTIENT.
|
||||
|
||||
Une colonne est écartée seulement si elle est NON VIDE et que toutes
|
||||
ses valeurs sont des chemins. Un seul contre-exemple suffit à la
|
||||
garder : mieux vaut anonymiser une colonne douteuse que taire une
|
||||
donnée personnelle.
|
||||
|
||||
Une requête par TABLE, pas par colonne : sur une liste noire de 410
|
||||
modèles, la différence est de 410 allers-retours au lieu de 863.
|
||||
"""
|
||||
ecartees = {}
|
||||
for etape in etapes:
|
||||
textes = [
|
||||
champ
|
||||
for champ in etape["fields"]
|
||||
if champ["ttype"] in TYPES_TEXTE and champ["pg_type"] != "jsonb"
|
||||
]
|
||||
if not textes:
|
||||
continue
|
||||
morceaux = []
|
||||
for champ in textes:
|
||||
nom = ident(champ["name"])
|
||||
morceaux.append(
|
||||
f"count(*) FILTER (WHERE {nom} IS NOT NULL AND {nom} <> '')"
|
||||
f" || ':' || count(*) FILTER (WHERE {nom} ~ {litteral(MOTIF_CHEMIN)})"
|
||||
)
|
||||
sql = (
|
||||
"SELECT "
|
||||
+ " || '\x1f' || ".join(morceaux)
|
||||
+ f" FROM {ident(table_de(etape['model']))}"
|
||||
)
|
||||
try:
|
||||
brut = lib_analyse.run_psql(
|
||||
database, sql, config_path=config_path
|
||||
).strip()
|
||||
except Exception: # noqa: BLE001 - une table illisible ne bloque pas
|
||||
continue
|
||||
for champ, mesure in zip(textes, brut.split(SEP)):
|
||||
try:
|
||||
remplies, chemins = (int(x) for x in mesure.split(":"))
|
||||
except ValueError:
|
||||
continue
|
||||
if remplies and remplies == chemins:
|
||||
ecartees.setdefault(etape["model"], []).append(champ["name"])
|
||||
return ecartees
|
||||
|
||||
|
||||
def sans_structurees(etapes, ecartees, mots):
|
||||
"""Refaire le plan sans les colonnes que la sonde a écartées."""
|
||||
if not ecartees:
|
||||
return etapes
|
||||
propre = []
|
||||
for etape in etapes:
|
||||
exclues = set(ecartees.get(etape["model"], ()))
|
||||
gardes = [c for c in etape["fields"] if c["name"] not in exclues]
|
||||
# Pas de garde sur une liste vide : `sql_pour_table` rend None, et
|
||||
# le `if sql` ci-dessous l'écarte. Deux vérifications pour la même
|
||||
# chose se contredisent un jour.
|
||||
sql = sql_pour_table(table_de(etape["model"]), gardes, mots)
|
||||
if sql:
|
||||
propre.append({**etape, "fields": gardes, "sql": sql})
|
||||
return propre
|
||||
|
||||
|
||||
def render(etapes, applique=False, verbeux=False):
|
||||
"""Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris."""
|
||||
if not etapes:
|
||||
|
|
@ -606,6 +695,20 @@ def main(argv=None):
|
|||
args.include_logins,
|
||||
mots,
|
||||
)
|
||||
# La sonde AVANT le rendu : la marche à blanc doit montrer ce que
|
||||
# `--apply` ferait, pas une approximation plus large.
|
||||
ecartees = colonnes_structurees(args.database, etapes, args.config)
|
||||
etapes = sans_structurees(etapes, ecartees, mots)
|
||||
if ecartees:
|
||||
combien = sum(len(v) for v in ecartees.values())
|
||||
print(
|
||||
f"🧭 {combien} {t('column(s) hold identifier paths and are left')}"
|
||||
f" {t('alone:')}"
|
||||
)
|
||||
for modele in sorted(ecartees):
|
||||
print(f" {modele} : {', '.join(sorted(ecartees[modele]))}")
|
||||
print()
|
||||
|
||||
if not args.apply:
|
||||
print(render(etapes, applique=False, verbeux=args.verbose))
|
||||
return 1 if etapes else 0
|
||||
|
|
|
|||
|
|
@ -10108,6 +10108,14 @@ TRANSLATIONS = {
|
|||
"fr": "désactive pas les crons, et laisse les clés de paiement en place.",
|
||||
"en": "not disable crons, and leaves payment keys in place.",
|
||||
},
|
||||
"column(s) hold identifier paths and are left": {
|
||||
"fr": "colonne(s) portent des chemins d'identifiants et sont laissées",
|
||||
"en": "column(s) hold identifier paths and are left",
|
||||
},
|
||||
"alone:": {
|
||||
"fr": "intactes :",
|
||||
"en": "alone:",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -606,3 +606,131 @@ class TestACheckDoesNotSilenceTheMainField(unittest.TestCase):
|
|||
"max_len": None,
|
||||
}
|
||||
self.assertFalse(anon.champ_retenu(champ))
|
||||
|
||||
|
||||
class TestStructuredCharFieldsSurvive(unittest.TestCase):
|
||||
"""`ValueError: invalid literal for int() with base 10: 'bruyere'`.
|
||||
|
||||
Odoo déclare `parent_path` en `char`, mais y range un CHEMIN
|
||||
D'IDENTIFIANTS — « 1/7/12/ » — qu'il reparse :
|
||||
|
||||
int(id) for id in company.parent_path.split('/')
|
||||
base/models/res_company.py:117, models.py:203 et :221
|
||||
|
||||
Y écrire un mot fait lever le serveur au premier chargement de page.
|
||||
Mesuré : sept modèles `_parent_store` dans une base ordinaire —
|
||||
res.company, product.category, stock.location, hr.department,
|
||||
website.menu, account.analytic.plan, helpdesk.ticket.category.
|
||||
|
||||
Même chose pour `days_next_month`, qu'Odoo passe à `int()`
|
||||
(account/models/account_payment_term.py:321).
|
||||
"""
|
||||
|
||||
def _champ(self, nom):
|
||||
return {
|
||||
"model": "res.company",
|
||||
"name": nom,
|
||||
"ttype": "char",
|
||||
"pg_type": "character varying",
|
||||
"unique": False,
|
||||
"checked": False,
|
||||
"max_len": None,
|
||||
}
|
||||
|
||||
def test_the_known_parsers_are_named(self):
|
||||
self.assertIn("parent_path", anon.CHAMPS_STRUCTURES)
|
||||
self.assertIn("days_next_month", anon.CHAMPS_STRUCTURES)
|
||||
|
||||
def test_they_are_never_touched_whatever_the_model(self):
|
||||
for nom in anon.CHAMPS_STRUCTURES:
|
||||
self.assertFalse(anon.champ_retenu(self._champ(nom)), nom)
|
||||
|
||||
def test_a_phone_number_is_not_mistaken_for_a_structure(self):
|
||||
"""Le motif EXIGE les barres obliques. Sans elles, un numéro tout
|
||||
en chiffres passerait pour une structure et échapperait à
|
||||
l'anonymisation — un défaut de confidentialité, pas de robustesse.
|
||||
"""
|
||||
import re
|
||||
|
||||
motif = re.compile(anon.MOTIF_CHEMIN)
|
||||
for valeur in ("5141234567", "0", "42", "1234-5678"):
|
||||
self.assertIsNone(motif.match(valeur), valeur)
|
||||
for valeur in ("1/", "1/7/12/", "3/4/"):
|
||||
self.assertIsNotNone(motif.match(valeur), valeur)
|
||||
|
||||
def test_the_probe_asks_once_per_table_not_once_per_column(self):
|
||||
"""Sur 410 modèles, la différence est de 410 allers-retours au
|
||||
lieu de 863."""
|
||||
appels = []
|
||||
|
||||
def espion(database, sql, config_path=None):
|
||||
appels.append(sql)
|
||||
return "0:0\x1f0:0"
|
||||
|
||||
vrai = anon.lib_analyse.run_psql
|
||||
anon.lib_analyse.run_psql = espion
|
||||
etapes = [
|
||||
{
|
||||
"model": "res.company",
|
||||
"fields": [self._champ("name"), self._champ("street")],
|
||||
"sql": "x",
|
||||
}
|
||||
]
|
||||
try:
|
||||
anon.colonnes_structurees("base", etapes)
|
||||
finally:
|
||||
anon.lib_analyse.run_psql = vrai
|
||||
self.assertEqual(len(appels), 1)
|
||||
self.assertIn('"name"', appels[0])
|
||||
self.assertIn('"street"', appels[0])
|
||||
|
||||
def test_one_counter_example_is_enough_to_keep_a_column(self):
|
||||
"""Mieux vaut anonymiser une colonne douteuse que taire une
|
||||
donnée personnelle."""
|
||||
|
||||
def espion(database, sql, config_path=None):
|
||||
# 10 valeurs remplies, 9 seulement sont des chemins.
|
||||
return "10:9"
|
||||
|
||||
vrai = anon.lib_analyse.run_psql
|
||||
anon.lib_analyse.run_psql = espion
|
||||
etapes = [
|
||||
{"model": "m", "fields": [self._champ("chemin")], "sql": "x"}
|
||||
]
|
||||
try:
|
||||
ecartees = anon.colonnes_structurees("base", etapes)
|
||||
finally:
|
||||
anon.lib_analyse.run_psql = vrai
|
||||
self.assertEqual(ecartees, {})
|
||||
|
||||
def test_an_all_paths_column_is_dropped_from_the_plan(self):
|
||||
def espion(database, sql, config_path=None):
|
||||
return "10:10"
|
||||
|
||||
vrai = anon.lib_analyse.run_psql
|
||||
anon.lib_analyse.run_psql = espion
|
||||
etapes = [
|
||||
{
|
||||
"model": "m",
|
||||
"fields": [self._champ("chemin"), self._champ("nom")],
|
||||
"sql": "x",
|
||||
}
|
||||
]
|
||||
try:
|
||||
ecartees = anon.colonnes_structurees("base", etapes)
|
||||
finally:
|
||||
anon.lib_analyse.run_psql = vrai
|
||||
# Les deux colonnes rendent le même compte ici : c'est le principe
|
||||
# qu'on vérifie, pas la ligne exacte.
|
||||
self.assertIn("m", ecartees)
|
||||
propre = anon.sans_structurees(etapes, {"m": ["chemin"]}, None)
|
||||
self.assertEqual([c["name"] for c in propre[0]["fields"]], ["nom"])
|
||||
self.assertNotIn('"chemin"', propre[0]["sql"])
|
||||
|
||||
def test_a_model_entirely_dropped_leaves_no_empty_statement(self):
|
||||
etapes = [
|
||||
{"model": "m", "fields": [self._champ("chemin")], "sql": "x"}
|
||||
]
|
||||
self.assertEqual(
|
||||
anon.sans_structurees(etapes, {"m": ["chemin"]}, None), []
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue