[FIX] execute : caviarder les clés d'API et les jetons Bearer

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
This commit is contained in:
Mathieu Benoit 2026-09-09 04:29:46 -04:00
parent 8fc026c5c7
commit eb28952d6c
4 changed files with 81 additions and 10 deletions

View file

@ -46,13 +46,21 @@ _SECRET_OPTION = re.compile(
re.IGNORECASE,
)
_SECRET_ENV = re.compile(
r"(?P<var>\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)"
r"(?P<var>\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY)\w*=)"
r"(?P<val>'[^']*'|\"[^\"]*\"|\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<schema>\bAuthorization:\s*(?:Bearer|Basic)\s+)(?P<val>\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(

View file

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

View file

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

View file

@ -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/<pid>/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()