CodeQL raised seven high-severity alerts on this branch. Three were real, and the same secret was behind all of them: the Odoo master password, which db_restore appends to its command line as soon as the database declares one. The command itself was printed raw -- "print(arg)" -- so the password reached stdout, and any terminal capture with it. The probe output was logged raw too, and a refused attempt echoes the command it tried. Wider than that: the runner filtered the command it was about to run, but not what came back. A tool that reprints its own arguments -- "set -x", a traceback, odoo_bin.sh -- put the secret straight back into the terminal AND into the log file the sink writes. Every subprocess line now goes through the same filter as the command. The four remaining alerts sit on expressions already wrapped in redact_secrets(). CodeQL does not cross re.sub, so it cannot see the barrier; the mitigation is real and they are false positives. --- FR --- CodeQL a levé sept alertes de sévérité haute sur cette branche. Trois étaient réelles, et le même secret était derrière : le mot de passe maître d'Odoo, que db_restore ajoute à sa ligne de commande dès que la base en exige un. La commande elle-même était imprimée telle quelle — « print(arg) » — donc le mot de passe atteignait la sortie standard, et toute capture de terminal avec elle. La sortie de la sonde était journalisée brute également, et un essai refusé réaffiche la commande tentée. Plus large : le lanceur filtrait la commande qu'il allait exécuter, mais pas ce qui en revenait. Un outil qui réaffiche ses propres arguments — « set -x », une trace, odoo_bin.sh — remettait le secret dans le terminal ET dans le fichier de journal. Chaque ligne du sous-processus passe désormais par le même filtre que la commande. Les quatre alertes restantes portent sur des expressions déjà entourées de redact_secrets(). CodeQL ne franchit pas re.sub et ne voit donc pas la barrière ; la mitigation est réelle, ce sont des faux positifs. Assisted-by: Claude Opus 5
249 lines
9.3 KiB
Python
249 lines
9.3 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>' »,
|
|
# db_restore.py « --master_password=… ». 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.
|
|
#
|
|
# 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
|