erplibre/script/execute/execute.py
Mathieu Benoit 6cdbd528f3 [FIX] security: keep the master password out of the command line
Redacting what we print treats the symptom. The password was still an
argument, and /proc/<pid>/cmdline is readable by EVERY user on the
machine for as long as the command runs -- an exposure no filter reaches.

It now travels in MASTER_PWD. The probe sets it on the child it spawns;
once accepted it is placed in this process's environment, so every later
call inherits it without argv ever carrying it. /proc/<pid>/environ is
readable only by its owner.

Worth knowing for anyone reading the old code: the option was appended to
every invocation, but odoo's db command reads it in the drop branch
alone. list, restore and clone were carrying a secret they never used.

The redaction stays. It is the last line, not the first, and other
options still put secrets on command lines.

--- FR ---

Caviarder ce qu'on affiche traite le symptôme. Le mot de passe restait un
argument, et /proc/<pid>/cmdline est lisible par TOUT utilisateur de la
machine tant que la commande tourne — une exposition qu'aucun filtre
n'atteint.

Il voyage désormais dans MASTER_PWD. La sonde le pose sur l'enfant
qu'elle lance ; une fois accepté, il est placé dans l'environnement de ce
processus, si bien que tous les appels suivants en héritent sans qu'argv
le porte jamais. /proc/<pid>/environ n'est lisible que par son
propriétaire.

À savoir pour qui relit l'ancien code : l'option était ajoutée à chaque
invocation, alors que la commande db d'odoo ne la lit que dans la branche
drop. list, restore et clone portaient un secret dont ils ne faisaient
rien.

Le caviardage reste. Il est le dernier rempart, pas le premier, et
d'autres options mettent encore des secrets sur des lignes de commande.

Assisted-by: Claude Opus 5
2026-08-23 02:11:50 -04:00

254 lines
9.6 KiB
Python

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import datetime
import logging
import os
import re
import shutil
import subprocess
import sys
import time
try:
import humanize
except ModuleNotFoundError as e:
humanize = None
VENV_ERPLIBRE = ".venv.erplibre"
# Une commande construite ailleurs peut porter un secret en clair : todo.py et
# kdbx_manager.py y mettent « --default_password_auth '<mot de passe KeePass>' ».
# Cette commande est affichée avant et après l'exécution, et journalisée en
# erreur : le secret finissait donc dans le terminal, dans les journaux et dans
# toute sortie CI qui les capture.
#
# Ce filtre reste le dernier rempart, pas le premier : un secret n'a rien à
# faire sur argv, que /proc/<pid>/cmdline expose à tout utilisateur de la
# machine et qu'aucun caviardage n'atteint. db_restore.py est passé à
# MASTER_PWD dans l'environnement pour cette raison.
#
# On caviarde la VALEUR, jamais le nom de l'option : la commande reste lisible et
# reproductible, il ne manque que ce qui ne doit pas être lu.
_SECRET_OPTION = re.compile(
r"(?P<opt>--?[\w-]*"
r"(?:password|passwd|pwd|secret|token|api[-_]?key)[\w-]*"
r"(?:\s+|=))"
r"(?P<val>'[^']*'|\"[^\"]*\"|\S+)",
re.IGNORECASE,
)
_SECRET_ENV = re.compile(
r"(?P<var>\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)"
r"(?P<val>'[^']*'|\"[^\"]*\"|\S+)"
)
def redact_secrets(text):
"""Remplace la valeur des options et variables porteuses 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
qu'une poignée de sorties ici, alors que les commandes se construisent
partout dans le dépôt.
"""
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)
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
sys.path.append(new_path)
logging.basicConfig(
format=(
"%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]"
" %(message)s"
),
datefmt="%Y-%m-%d:%H:%M:%S",
level=logging.INFO,
)
_logger = logging.getLogger(__name__)
class Execute:
def __init__(self) -> None:
self.cmd_source_erplibre: str = ""
self.cmd_source_default: str = ""
exec_path_gnome_terminal = shutil.which("gnome-terminal")
if exec_path_gnome_terminal:
self.cmd_source_erplibre = (
f"gnome-terminal -- bash -c 'source"
f" ./{VENV_ERPLIBRE}/bin/activate;%s'"
)
self.cmd_source_default = "gnome-terminal -- bash -c '" f"%s'"
else:
exec_path_tell = shutil.which("osascript")
if exec_path_tell:
self.cmd_source_erplibre = (
"osascript -e 'tell application \"Terminal\"'"
)
self.cmd_source_erplibre += " -e 'tell application \"System Events\" to keystroke \"t\" using {command down}' -e 'delay 0.1' -e 'do script \""
self.cmd_source_erplibre += f"cd {os.getcwd()}; source ./{VENV_ERPLIBRE}/bin/activate; %s\" in front window'"
self.cmd_source_erplibre += " -e 'end tell'"
else:
self.cmd_source_erplibre = (
f"source ./{VENV_ERPLIBRE}/bin/activate;%s"
)
def exec_command_live(
self,
command: str,
source_erplibre: bool = True,
quiet: bool = False,
single_source_erplibre: bool = False,
new_window: bool = False,
single_source_odoo: bool = False,
source_odoo: str = "",
new_env: dict | None = None,
return_status_and_command: bool = False,
return_status_and_output: bool = False,
return_status_and_output_and_command: bool = False,
) -> (
int
| tuple[int, str]
| tuple[int, list[str]]
| tuple[int, str, list[str]]
):
"""
Execute a command and display its output live.
Args:
command (str): The command to execute.
"""
my_env = os.environ.copy()
if new_env:
my_env.update(new_env)
process_start_time = time.time()
exit_code = None
if source_erplibre:
# command = f"source ./{VENV_ERPLIBRE}/bin/activate && " + command
# cmd = (
# f"gnome-terminal --tab -- bash -c 'source"
# f" ./{VENV_ERPLIBRE}/bin/activate;{command}'"
# )
command = self.cmd_source_erplibre % command
# os.system(f"./script/terminal/open_terminal.sh {command}")
elif single_source_erplibre:
command = f"source ./{VENV_ERPLIBRE}/bin/activate && %s" % command
elif single_source_odoo:
if not source_odoo and os.path.exists("./.erplibre-version"):
with open("./.erplibre-version") as f:
source_odoo = f.read()
if not source_odoo:
_logger.error(
"You cannot execute Odoo command if no version is"
f" installed. Command : {redact_secrets(command)}"
)
# Return the SAME shape the caller asked for. A bare int here
# made callers doing « status, cmd = exec_command_live(...) »
# crash with ValueError instead of seeing the failure.
if return_status_and_output_and_command:
return 1, command, []
if return_status_and_command:
return 1, command
if return_status_and_output:
return 1, []
return 1
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
if new_window and self.cmd_source_default:
command = self.cmd_source_default % command
if not quiet:
print("🏠 ⬇ Execute command :\n")
print(redact_secrets(command))
output_lines = []
try:
process = subprocess.Popen(
command,
shell=True,
executable="/bin/bash",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Disable buffering for live output
universal_newlines=True, # Handle line breaks correctly
env=my_env,
)
sink = getattr(self, "log_sink", None)
while True:
line = process.stdout.readline()
if not line:
break
# La sortie du sous-processus passe par le meme filtre que
# la commande : un outil qui reaffiche ses propres arguments
# (« set -x », une trace, odoo_bin.sh) y remettrait le secret
# que la ligne 165 venait d'ecarter.
line = redact_secrets(line)
if not quiet:
print(line, end="")
if sink:
# Chaque ligne passe DÉJÀ ici : c'est le seul endroit où
# journaliser sans rien changer à ce que le terminal
# montre. Une erreur d'écriture ne doit jamais faire
# échouer la commande qu'on est en train de suivre.
try:
sink.write(line)
except Exception:
sink = None
if (
return_status_and_output
or return_status_and_output_and_command
):
# Remove last \n char
output_lines.append(
line.removesuffix("\r\n")
.removesuffix("\n")
.removesuffix("\r")
)
process.wait()
exit_code = process.returncode
if process.returncode != 0 and not quiet:
print("Command returned error code:" f" {process.returncode}")
# An exception MUST report a failure. exit_code stays None otherwise,
# and None is falsy: callers testing « if not status: » would mark the
# step as done, and « if status and wait_at_error » would skip the error
# prompt. A crashed command was therefore recorded as a success.
except FileNotFoundError:
exit_code = 1
if not quiet:
print(f"Error: Command '{redact_secrets(command)}' not found.")
except Exception as e:
exit_code = 1
if not quiet:
print(f"An error occurred: {redact_secrets(str(e))}")
process_end_time = time.time()
duration_sec = process_end_time - process_start_time
if humanize:
duration_delta = datetime.timedelta(seconds=duration_sec)
human_time = humanize.precisedelta(duration_delta)
if not quiet:
print(f"🏠 ⬆ Executed ({human_time}) :\n")
else:
if not quiet:
print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :\n")
if not quiet:
print(redact_secrets(command))
print()
if return_status_and_output_and_command:
return exit_code, command, output_lines
if return_status_and_command:
return exit_code, command
if return_status_and_output:
return exit_code, output_lines
return exit_code