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