From eb28952d6c22f52f78e614043440747197adb9cb Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 9 Sep 2026 04:29:46 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20execute=20:=20caviarder=20les=20cl?= =?UTF-8?q?=C3=A9s=20d'API=20et=20les=20jetons=20Bearer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le filtre ne connaissait que trois noms de variable — mot de passe, secret, jeton — donc `OPENAI_API_KEY=` partait en clair dans le terminal, dans les journaux et dans toute sortie CI qui les capture. Un jeton d'en-tête échappait aux deux règles par construction : il ne porte ni nom d'option ni nom de variable, il suit le mot « Bearer ». Le filtre couvre maintenant `API_KEY` côté variables et `Authorization: Bearer|Basic` côté en-têtes, sans casse, la valeur allant jusqu'au premier blanc. Il protège au même titre la restauration de base, qui l'appelle sur six sorties. Reste le dernier rempart et non le premier : argv est lisible par tout compte de la machine, où aucun caviardage n'atteint. --- EN --- The filter knew only three variable names — password, secret, token — so `OPENAI_API_KEY=` went out in the clear to the terminal, to the logs and to any CI output capturing them. A header token escaped both rules by construction: it carries neither an option name nor a variable name, it follows the word "Bearer". The filter now covers `API_KEY` on the variable side and `Authorization: Bearer|Basic` on the header side, case-insensitively, the value running to the first blank. It protects database restore just as much, which calls it on six outputs. It stays the last line of defence, not the first: argv is readable by every account on the machine, where no redaction reaches. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 15 ++++++-- script/todo/assistant/backends.py | 6 +-- script/todo/assistant_menu.py | 7 ++-- test/test_execute.py | 63 ++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/script/execute/execute.py b/script/execute/execute.py index 09515b8..e3ba8e7 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -46,13 +46,21 @@ _SECRET_OPTION = re.compile( re.IGNORECASE, ) _SECRET_ENV = re.compile( - r"(?P\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)" + r"(?P\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY)\w*=)" r"(?P'[^']*'|\"[^\"]*\"|\S+)" ) +# Un jeton porté par un en-tête n'a ni nom d'option ni nom de variable : il +# suit le mot « Bearer », et les deux règles ci-dessus passent à côté. Le +# schéma est nommé par la RFC 7235 et se compare sans casse, la valeur allant +# jusqu'à la fin de la ligne — un jeton ne porte pas d'espace. +_SECRET_HEADER = re.compile( + r"(?P\bAuthorization:\s*(?:Bearer|Basic)\s+)(?P\S+)", + re.IGNORECASE, +) def redact_secrets(text): - """Remplace la valeur des options et variables porteuses de secret. + """Remplace la valeur des options, variables et en-têtes de secret. Appliqué à CHAQUE affichage d'une commande. Filtrer au point d'affichage plutôt qu'à la construction est ce qui rend la garantie tenable : il n'y a @@ -62,7 +70,8 @@ def redact_secrets(text): if not text: return text text = _SECRET_OPTION.sub(lambda m: m.group("opt") + "'***'", text) - return _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text) + text = _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text) + return _SECRET_HEADER.sub(lambda m: m.group("schema") + "'***'", text) new_path = os.path.normpath( diff --git a/script/todo/assistant/backends.py b/script/todo/assistant/backends.py index bfe92de..a83884e 100644 --- a/script/todo/assistant/backends.py +++ b/script/todo/assistant/backends.py @@ -16,9 +16,9 @@ l'historique local à une session qui la garde déjà la doublerait. **La clé ne quitte jamais le processus.** Elle va à `openai.OpenAI(api_key=…)` en mémoire, jamais sur une ligne de commande ni dans une variable d'environnement : un argv se lit par n'importe quel compte local dès que -`/proc` est monté sans `hidepid`, et le masquage de -`script/execute/execute.py` ne reconnaît que `PASSWORD|PASSWD|SECRET|TOKEN`, -donc ni `OPENAI_API_KEY=` ni `Authorization: Bearer`. +`/proc` est monté sans `hidepid`, et AUCUN masquage n'atteint argv. Celui de +`script/execute/execute.py` couvre `OPENAI_API_KEY=` et `Authorization: +Bearer` dans une TRACE, ce qui est le dernier rempart et non le premier. **L'invite de `claude` part sur l'entrée standard**, jamais en positionnel, pour la même raison. `claude_argv` bâtit donc l'argv SANS l'invite, et diff --git a/script/todo/assistant_menu.py b/script/todo/assistant_menu.py index 1d45b23..e607a77 100644 --- a/script/todo/assistant_menu.py +++ b/script/todo/assistant_menu.py @@ -1342,9 +1342,10 @@ class AssistantMenuMixin: """La clé du coffre, ou une chaîne vide quand il n'en porte aucune. La clé reste en mémoire du processus : /proc expose la ligne de - commande de chaque processus à tout compte de la machine, et un - `redact_secrets` qui ne reconnaît pas « API_KEY » ne la masquerait pas - non plus dans une trace. + commande de chaque processus à tout compte de la machine, et aucun + caviardage n'atteint argv. Le filtre du dépôt masque « API_KEY » et + « Bearer » dans une trace, ce qui est le dernier rempart et non le + premier. """ kp = self.kdbx_manager.get_kdbx() if not kp: diff --git a/test/test_execute.py b/test/test_execute.py index 1794987..6f1dc64 100644 --- a/test/test_execute.py +++ b/test/test_execute.py @@ -6,7 +6,7 @@ import os import unittest from unittest.mock import patch -from script.execute.execute import Execute +from script.execute.execute import Execute, redact_secrets class TestExecuteInit(unittest.TestCase): @@ -169,5 +169,66 @@ class TestExecCommandLive(unittest.TestCase): self.assertEqual(output, []) +class TestRedactSecrets(unittest.TestCase): + """Ce qui doit disparaître d'une commande affichée, et ce qui doit rester. + + Le caviardage est le DERNIER rempart et non le premier : un secret sur + argv est déjà lisible par tout compte de la machine dans + /proc//cmdline, où aucun filtre n'atteint. Ces tests défendent donc + la trace — terminal, journal, sortie CI — et rien d'autre. + + Trois familles portent un secret, et elles ne se ressemblent pas. Une + option se reconnaît par son nom, une variable d'environnement par le + sien, et un jeton d'en-tête n'a NI l'un NI l'autre : il suit le mot + « Bearer ». Un filtre bâti sur les deux premières laisse passer la + troisième, qui est exactement celle qu'une API de modèle emploie. + + Les valeurs sont inventées, comme l'exige la règle du dépôt pour tout + exemple qui illustre un interdit. + """ + + def test_env_api_key_is_redacted(self): + sortie = redact_secrets("OPENAI_API_KEY=sk-inventeXYZ python x.py") + self.assertNotIn("sk-inventeXYZ", sortie) + self.assertIn("OPENAI_API_KEY='***'", sortie) + + def test_env_apikey_without_separator(self): + sortie = redact_secrets("MISTRAL_APIKEY=abc123 ./run") + self.assertNotIn("abc123", sortie) + + def test_bearer_header_is_redacted(self): + sortie = redact_secrets( + "curl -H 'Authorization: Bearer sk-inventeABC' http://h/v1/models" + ) + self.assertNotIn("sk-inventeABC", sortie) + self.assertIn("Authorization: Bearer '***'", sortie) + + def test_basic_header_is_redacted(self): + sortie = redact_secrets("Authorization: Basic dXNlcjpmYXV4") + self.assertNotIn("dXNlcjpmYXV4", sortie) + + def test_header_case_is_ignored(self): + sortie = redact_secrets("authorization: bearer sk-inventeDEF") + self.assertNotIn("sk-inventeDEF", sortie) + + def test_option_password_still_redacted(self): + sortie = redact_secrets("odoo --db_password 'inventeGHI'") + self.assertNotIn("inventeGHI", sortie) + + def test_option_name_survives(self): + """Le nom de l'option reste : la commande doit rester reproductible.""" + sortie = redact_secrets("odoo --db_password 'inventeJKL'") + self.assertIn("--db_password", sortie) + + def test_a_path_is_not_a_secret(self): + """Rien ne disparaît d'une commande qui ne porte aucun secret.""" + commande = "make test_unit_file F=test/test_execute.py" + self.assertEqual(redact_secrets(commande), commande) + + def test_empty_text_survives(self): + self.assertEqual(redact_secrets(""), "") + self.assertIsNone(redact_secrets(None)) + + if __name__ == "__main__": unittest.main()