2026-03-10 02:49:03 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-03-11 23:15:07 -04:00
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
2026-03-10 02:49:03 -04:00
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import unittest
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
[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
2026-09-09 04:29:46 -04:00
|
|
|
from script.execute.execute import Execute, redact_secrets
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecuteInit(unittest.TestCase):
|
|
|
|
|
"""Test Execute class initialization."""
|
|
|
|
|
|
|
|
|
|
@patch("shutil.which")
|
|
|
|
|
def test_init_with_gnome_terminal(self, mock_which):
|
|
|
|
|
mock_which.side_effect = lambda x: (
|
|
|
|
|
"/usr/bin/gnome-terminal" if x == "gnome-terminal" else None
|
|
|
|
|
)
|
|
|
|
|
exe = Execute()
|
|
|
|
|
self.assertIn("gnome-terminal", exe.cmd_source_erplibre)
|
|
|
|
|
self.assertIn(".venv.erplibre", exe.cmd_source_erplibre)
|
|
|
|
|
self.assertIn("gnome-terminal", exe.cmd_source_default)
|
|
|
|
|
|
|
|
|
|
@patch("shutil.which")
|
|
|
|
|
def test_init_with_osascript(self, mock_which):
|
|
|
|
|
mock_which.side_effect = lambda x: (
|
|
|
|
|
"/usr/bin/osascript" if x == "osascript" else None
|
|
|
|
|
)
|
|
|
|
|
exe = Execute()
|
|
|
|
|
self.assertIn("osascript", exe.cmd_source_erplibre)
|
|
|
|
|
|
|
|
|
|
@patch("shutil.which", return_value=None)
|
|
|
|
|
def test_init_fallback_source(self, mock_which):
|
|
|
|
|
exe = Execute()
|
|
|
|
|
self.assertIn(".venv.erplibre/bin/activate", exe.cmd_source_erplibre)
|
|
|
|
|
self.assertEqual(exe.cmd_source_default, "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecCommandLive(unittest.TestCase):
|
|
|
|
|
"""Test exec_command_live method."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
with patch("shutil.which", return_value=None):
|
|
|
|
|
self.exe = Execute()
|
|
|
|
|
|
|
|
|
|
def test_simple_command_returns_zero(self):
|
|
|
|
|
result = self.exe.exec_command_live(
|
|
|
|
|
"echo hello",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(result, 0)
|
|
|
|
|
|
|
|
|
|
def test_failing_command_returns_nonzero(self):
|
|
|
|
|
result = self.exe.exec_command_live(
|
|
|
|
|
"exit 42",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(result, 42)
|
|
|
|
|
|
|
|
|
|
def test_return_status_and_output(self):
|
|
|
|
|
status, output = self.exe.exec_command_live(
|
|
|
|
|
"echo hello",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_output=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(status, 0)
|
|
|
|
|
self.assertEqual(output, ["hello"])
|
|
|
|
|
|
|
|
|
|
def test_return_status_and_output_multiline(self):
|
|
|
|
|
status, output = self.exe.exec_command_live(
|
|
|
|
|
"echo -e 'line1\nline2\nline3'",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_output=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(status, 0)
|
|
|
|
|
self.assertEqual(output, ["line1", "line2", "line3"])
|
|
|
|
|
|
|
|
|
|
def test_return_status_and_command(self):
|
|
|
|
|
status, cmd = self.exe.exec_command_live(
|
|
|
|
|
"echo test",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_command=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(status, 0)
|
|
|
|
|
self.assertEqual(cmd, "echo test")
|
|
|
|
|
|
|
|
|
|
def test_return_status_and_output_and_command(self):
|
|
|
|
|
status, cmd, output = self.exe.exec_command_live(
|
|
|
|
|
"echo result",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_output_and_command=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(status, 0)
|
|
|
|
|
self.assertEqual(cmd, "echo result")
|
|
|
|
|
self.assertEqual(output, ["result"])
|
|
|
|
|
|
|
|
|
|
def test_source_erplibre_prepends_activate(self):
|
|
|
|
|
status, cmd = self.exe.exec_command_live(
|
|
|
|
|
"echo test",
|
|
|
|
|
source_erplibre=True,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_command=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(".venv.erplibre/bin/activate", cmd)
|
|
|
|
|
|
|
|
|
|
def test_single_source_erplibre(self):
|
|
|
|
|
status, cmd = self.exe.exec_command_live(
|
|
|
|
|
"echo test",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
single_source_erplibre=True,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_command=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(".venv.erplibre/bin/activate", cmd)
|
|
|
|
|
self.assertIn("echo test", cmd)
|
|
|
|
|
|
|
|
|
|
def test_single_source_odoo_no_version_returns_error(self):
|
|
|
|
|
with patch("os.path.exists", return_value=False):
|
|
|
|
|
result = self.exe.exec_command_live(
|
|
|
|
|
"echo test",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
single_source_odoo=True,
|
|
|
|
|
source_odoo="",
|
|
|
|
|
quiet=True,
|
|
|
|
|
)
|
2026-08-10 03:10:50 -04:00
|
|
|
# `1`, pas `-1` : e24b185 a rendu à ce chemin la FORME que
|
|
|
|
|
# l'appelant demande (un tuple s'il en attend un) et en a profité
|
|
|
|
|
# pour donner un vrai code de sortie. Aucun appelant ne compare à
|
|
|
|
|
# -1, qui n'est d'ailleurs pas un code de sortie valide.
|
|
|
|
|
self.assertEqual(result, 1)
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_single_source_odoo_with_version(self):
|
|
|
|
|
status, cmd = self.exe.exec_command_live(
|
|
|
|
|
"echo test",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
single_source_odoo=True,
|
|
|
|
|
source_odoo="odoo18",
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_command=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(".venv.odoo18/bin/activate", cmd)
|
|
|
|
|
|
|
|
|
|
def test_new_env_passed_to_subprocess(self):
|
|
|
|
|
status, output = self.exe.exec_command_live(
|
|
|
|
|
"echo $MY_TEST_VAR",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
new_env={"MY_TEST_VAR": "test_value_123"},
|
|
|
|
|
return_status_and_output=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(status, 0)
|
|
|
|
|
self.assertEqual(output, ["test_value_123"])
|
|
|
|
|
|
|
|
|
|
def test_empty_output_command(self):
|
|
|
|
|
status, output = self.exe.exec_command_live(
|
|
|
|
|
"true",
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
quiet=True,
|
|
|
|
|
return_status_and_output=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(status, 0)
|
|
|
|
|
self.assertEqual(output, [])
|
|
|
|
|
|
|
|
|
|
|
[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
2026-09-09 04:29:46 -04:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|