[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:
parent
8fc026c5c7
commit
eb28952d6c
4 changed files with 81 additions and 10 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue