From 137bdca0a0de334887399584b1303b7996fa0d82 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Tue, 9 Jun 2026 09:56:20 -0500 Subject: [PATCH] Mask credentials in plugin tool logging Add SensitiveArg to util.py to wrap secrets passed as CLI flags so they render as *** in logs and error output while preserving the real value for subprocess execution. Add display_command() and exec_args() as helpers to use alongside it. Apply throughout the plugin layer: - wizcli: wrap client_secret in SensitiveArg; use exec_args() when invoking subprocess so the real value is passed to the process - dgoss: move GH_TOKEN forwarding from image_environment (where the value was embedded as -e GH_TOKEN=VALUE in the command line) to dgoss_environment (subprocess env), passed via -e GH_TOKEN (name-only) so the value never appears in logs or error metadata. Add redacted_dgoss_environment for safe logging. Add container_passthrough_env_vars for the name-only -e entries. - hadolint, oras: use display_command() for log output Update error __str__ methods to call str(x) per item so SensitiveArg values in cmd lists render as *** rather than raising TypeError. --- posit-bakery/posit_bakery/error.py | 4 +- .../plugins/builtin/dgoss/command.py | 44 ++++++++++----- .../plugins/builtin/dgoss/errors.py | 2 +- .../plugins/builtin/dgoss/suite.py | 7 +-- .../plugins/builtin/hadolint/errors.py | 2 +- .../plugins/builtin/hadolint/suite.py | 3 +- .../posit_bakery/plugins/builtin/oras/oras.py | 6 +-- .../plugins/builtin/wizcli/command.py | 6 +-- .../plugins/builtin/wizcli/errors.py | 2 +- .../plugins/builtin/wizcli/suite.py | 5 +- posit-bakery/posit_bakery/util.py | 40 ++++++++++++++ .../test/config/image/test_version_os.py | 2 + .../plugins/builtin/dgoss/test_command.py | 51 +++++++++++++++--- .../plugins/builtin/wizcli/test_command.py | 11 +++- posit-bakery/test/test_util.py | 53 +++++++++++++++++++ 15 files changed, 199 insertions(+), 39 deletions(-) diff --git a/posit-bakery/posit_bakery/error.py b/posit-bakery/posit_bakery/error.py index 4656a36ca..b04eb4ac4 100644 --- a/posit-bakery/posit_bakery/error.py +++ b/posit-bakery/posit_bakery/error.py @@ -144,7 +144,7 @@ def dump_stderr(self, lines: int = 10) -> str: def __str__(self) -> str: s = f"{self.message}\n" s += f" - Exit code: {self.exit_code}\n" - s += f" - Command executed: {' '.join(self.cmd)}\n" + s += f" - Command executed: {' '.join(str(x) for x in self.cmd)}\n" if self.metadata: s += " - Metadata:\n" for key, value in self.metadata.items(): @@ -173,7 +173,7 @@ def __str__(self) -> str: for e in self.exceptions: s += f"{e.message}\n" if isinstance(e, BakeryToolRuntimeError): - s += f" - Command executed: '{' '.join(e.cmd)}'\n" + s += f" - Command executed: '{' '.join(str(x) for x in e.cmd)}'\n" if e.metadata: s += " - Metadata:\n" for key, value in e.metadata.items(): diff --git a/posit-bakery/posit_bakery/plugins/builtin/dgoss/command.py b/posit-bakery/posit_bakery/plugins/builtin/dgoss/command.py index 67b537864..93213a25f 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/dgoss/command.py +++ b/posit-bakery/posit_bakery/plugins/builtin/dgoss/command.py @@ -67,6 +67,18 @@ def dgoss_environment(self) -> dict[str, str]: env["GOSS_PATH"] = self.goss_bin if self.wait > 0: env["GOSS_SLEEP"] = str(self.wait) + # Forward GITHUB_TOKEN as GH_TOKEN so `quarto list tools` (and similar) + # can authenticate api.github.com calls and avoid the 60/hr anonymous + # rate limit that intermittently fails dgoss runs. + # Placed here (subprocess env) rather than image_environment so the + # value is never embedded in command-line arguments — it's forwarded to + # the container via `-e GH_TOKEN` (name-only), which docker inherits + # from the subprocess environment. Gate on GITHUB_ACTIONS=true to avoid + # forwarding a developer's local gh CLI token. + if os.environ.get("GITHUB_ACTIONS") == "true": + github_token = os.environ.get("GITHUB_TOKEN") + if github_token: + env["GH_TOKEN"] = github_token return env @property @@ -92,20 +104,22 @@ def image_environment(self) -> dict[str, str]: for arg, value in self.image_target.build_args.items(): env_var = f"BUILD_ARG_{arg.upper()}" e[env_var] = value + return e - # Forward the runner's GITHUB_TOKEN as GH_TOKEN so `quarto list tools` - # (and similar) can authenticate api.github.com calls and avoid the - # 60/hr anonymous rate limit that intermittently fails dgoss runs. - # Gate on GITHUB_ACTIONS=true so we only forward in environments where - # GitHub's automatic log masking redacts the value — a local run that - # happens to have GITHUB_TOKEN set (e.g. a developer's gh CLI session) - # would echo the raw token into the `-e GH_TOKEN=…` command logging. - if os.environ.get("GITHUB_ACTIONS") == "true": - github_token = os.environ.get("GITHUB_TOKEN") - if github_token: - e["GH_TOKEN"] = github_token + @property + def redacted_dgoss_environment(self) -> dict[str, str]: + """Return dgoss_environment with GH_* values redacted for logging and error metadata.""" + return {k: "***" if k.startswith("GH_") else v for k, v in self.dgoss_environment.items()} - return e + @property + def container_passthrough_env_vars(self) -> list[str]: + """Return env var names to forward to the container via '-e KEY' (no value). + + These are set in dgoss_environment (subprocess env) and inherited by the + container via docker's name-only -e form. Values are never embedded in + command-line arguments, so they cannot appear in logs or error output. + """ + return [k for k in self.dgoss_environment if k.startswith("GH_")] @property def volume_mounts(self) -> list[tuple[str, str]]: @@ -159,6 +173,12 @@ def command(self) -> list[str]: if value is not None: env_value = re.sub(r"([\"\'\\$`!*?&#()|<>;\[\]{}\s])", r"\\\1", value) cmd.extend(["-e", f"{env_var}={env_value}"]) + # Secret env vars: pass name-only so docker inherits the value from the + # subprocess environment (dgoss_environment). The token value is never + # embedded in command-line arguments and will not appear in logs or + # error output. + for env_var in self.container_passthrough_env_vars: + cmd.extend(["-e", env_var]) cmd.append("--init") if self.runtime_options: # TODO: We may want to validate this to ensure options are not duplicated. diff --git a/posit-bakery/posit_bakery/plugins/builtin/dgoss/errors.py b/posit-bakery/posit_bakery/plugins/builtin/dgoss/errors.py index ae38c5266..dd8f85727 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/dgoss/errors.py +++ b/posit-bakery/posit_bakery/plugins/builtin/dgoss/errors.py @@ -31,7 +31,7 @@ def __str__(self) -> str: s = f"{self.message}\n" s += f" - Exit code: {self.exit_code}\n" s += f" - Command output: \n{textwrap.indent(self.dump_stdout(), ' ')}\n" - s += f" - Command executed: {' '.join(self.cmd)}\n" + s += f" - Command executed: {' '.join(str(x) for x in self.cmd)}\n" if self.metadata: s += " - Metadata:\n" for key, value in self.metadata.items(): diff --git a/posit-bakery/posit_bakery/plugins/builtin/dgoss/suite.py b/posit-bakery/posit_bakery/plugins/builtin/dgoss/suite.py index 273b4bf84..d2d8041a8 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/dgoss/suite.py +++ b/posit-bakery/posit_bakery/plugins/builtin/dgoss/suite.py @@ -9,6 +9,7 @@ from posit_bakery.error import BakeryToolRuntimeError, BakeryToolRuntimeErrorGroup from posit_bakery.plugins.builtin.dgoss.command import DGossCommand +from posit_bakery.util import display_command from posit_bakery.plugins.builtin.dgoss.errors import BakeryDGossError from posit_bakery.plugins.builtin.dgoss.report import GossJsonReportCollection, GossJsonReport from posit_bakery.image.image_target import ImageTarget @@ -33,8 +34,8 @@ def run(self) -> tuple[GossJsonReportCollection, BakeryToolRuntimeError | Bakery for dgoss_command in self.dgoss_commands: log.info(f"[bright_blue bold]=== Running Goss tests for '{str(dgoss_command.image_target)}' ===") - log.debug(f"[bright_black]Environment variables: {dgoss_command.dgoss_environment}") - log.debug(f"[bright_black]Executing dgoss command: {' '.join(dgoss_command.command)}") + log.debug(f"[bright_black]Environment variables: {dgoss_command.redacted_dgoss_environment}") + log.debug(f"[bright_black]Executing dgoss command: {display_command(dgoss_command.command)}") run_env = os.environ.copy() run_env.update(dgoss_command.dgoss_environment) @@ -89,7 +90,7 @@ def run(self) -> tuple[GossJsonReportCollection, BakeryToolRuntimeError | Bakery stderr=p.stderr, parse_error=parse_err, exit_code=exit_code, - metadata={"environment_variables": dgoss_command.dgoss_environment}, + metadata={"environment_variables": dgoss_command.redacted_dgoss_environment}, ) ) elif exit_code == 0: diff --git a/posit-bakery/posit_bakery/plugins/builtin/hadolint/errors.py b/posit-bakery/posit_bakery/plugins/builtin/hadolint/errors.py index c28b204a2..897eccfe8 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/hadolint/errors.py +++ b/posit-bakery/posit_bakery/plugins/builtin/hadolint/errors.py @@ -31,7 +31,7 @@ def __str__(self) -> str: s = f"{self.message}\n" s += f" - Exit code: {self.exit_code}\n" s += f" - Command output: \n{textwrap.indent(self.dump_stdout(), ' ')}\n" - s += f" - Command executed: {' '.join(self.cmd)}\n" + s += f" - Command executed: {' '.join(str(x) for x in self.cmd)}\n" if self.metadata: s += " - Metadata:\n" for key, value in self.metadata.items(): diff --git a/posit-bakery/posit_bakery/plugins/builtin/hadolint/suite.py b/posit-bakery/posit_bakery/plugins/builtin/hadolint/suite.py index ac787f870..00e5fb62f 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/hadolint/suite.py +++ b/posit-bakery/posit_bakery/plugins/builtin/hadolint/suite.py @@ -8,6 +8,7 @@ from posit_bakery.error import BakeryToolRuntimeError, BakeryToolRuntimeErrorGroup from posit_bakery.image.image_target import ImageTarget from posit_bakery.plugins.builtin.hadolint.command import HadolintCommand +from posit_bakery.util import display_command from posit_bakery.plugins.builtin.hadolint.errors import BakeryHadolintError from posit_bakery.plugins.builtin.hadolint.options import HadolintOptions from posit_bakery.plugins.builtin.hadolint.report import HadolintReport, HadolintReportCollection @@ -62,7 +63,7 @@ def run(self) -> tuple[HadolintReportCollection, BakeryToolRuntimeError | Bakery log.debug(f"[bright_black]Shared targets: {', '.join(other_uids)}") else: log.info(f"[bright_blue bold]=== Running hadolint for '{str(target)}' ===") - log.debug(f"[bright_black]Executing hadolint command: {' '.join(representative.command)}") + log.debug(f"[bright_black]Executing hadolint command: {display_command(representative.command)}") run_env = os.environ.copy() p = subprocess.run(representative.command, env=run_env, capture_output=True) diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py index 83a928a2f..d83d7b4ff 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py @@ -10,7 +10,7 @@ from posit_bakery.error import BakeryToolRuntimeError from posit_bakery.image.image_target import ImageTarget, Tag -from posit_bakery.util import find_bin +from posit_bakery.util import display_command, find_bin log = logging.getLogger(__name__) @@ -55,10 +55,10 @@ def run(self, dry_run: bool = False) -> subprocess.CompletedProcess: :raises BakeryToolRuntimeError: If the command fails. """ cmd = self.command - log.debug(f"Executing oras command: {' '.join(cmd)}") + log.debug(f"Executing oras command: {display_command(cmd)}") if dry_run: - log.info(f"[DRY RUN] Would execute: {' '.join(cmd)}") + log.info(f"[DRY RUN] Would execute: {display_command(cmd)}") return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=b"", stderr=b"") result = subprocess.run(cmd, capture_output=True) diff --git a/posit-bakery/posit_bakery/plugins/builtin/wizcli/command.py b/posit-bakery/posit_bakery/plugins/builtin/wizcli/command.py index 446cea4f0..7cc012c1c 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/wizcli/command.py +++ b/posit-bakery/posit_bakery/plugins/builtin/wizcli/command.py @@ -5,7 +5,7 @@ from posit_bakery.image.image_target import ImageTarget, ImageTargetContext from posit_bakery.plugins.builtin.wizcli.options import WizCLIOptions -from posit_bakery.util import find_bin +from posit_bakery.util import SensitiveArg, find_bin def find_wizcli_bin(context: ImageTargetContext) -> str | None: @@ -85,7 +85,7 @@ def check_wizcli_bin(self) -> Self: @computed_field @property - def command(self) -> list[str]: + def command(self) -> list: cmd = [self.wizcli_bin, "scan", "container-image"] # Image reference @@ -122,7 +122,7 @@ def command(self) -> list[str]: if self.client_id: cmd.extend(["--client-id", self.client_id]) if self.client_secret: - cmd.extend(["--client-secret", self.client_secret]) + cmd.extend(["--client-secret", SensitiveArg(self.client_secret)]) if self.use_device_code: cmd.append("--use-device-code") if self.no_browser: diff --git a/posit-bakery/posit_bakery/plugins/builtin/wizcli/errors.py b/posit-bakery/posit_bakery/plugins/builtin/wizcli/errors.py index 8ac966670..3408df13d 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/wizcli/errors.py +++ b/posit-bakery/posit_bakery/plugins/builtin/wizcli/errors.py @@ -50,7 +50,7 @@ def __str__(self) -> str: stdout_dump = self.dump_stdout() if stdout_dump: s += f" - Output:\n{textwrap.indent(stdout_dump, ' ')}\n" - s += f" - Command executed: {' '.join(self.cmd)}\n" + s += f" - Command executed: {' '.join(str(x) for x in self.cmd)}\n" if self.metadata: s += " - Metadata:\n" for key, value in self.metadata.items(): diff --git a/posit-bakery/posit_bakery/plugins/builtin/wizcli/suite.py b/posit-bakery/posit_bakery/plugins/builtin/wizcli/suite.py index 34db1d33a..79c109e40 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/wizcli/suite.py +++ b/posit-bakery/posit_bakery/plugins/builtin/wizcli/suite.py @@ -7,6 +7,7 @@ from posit_bakery.error import BakeryToolRuntimeError, BakeryToolRuntimeErrorGroup from posit_bakery.image.image_target import ImageTarget from posit_bakery.plugins.builtin.wizcli.command import WizCLICommand +from posit_bakery.util import display_command, exec_args from posit_bakery.plugins.builtin.wizcli.errors import ( BakeryWizCLIError, WIZCLI_EXIT_CODE_POLICY_VIOLATION, @@ -69,7 +70,7 @@ def run(self) -> tuple[WizScanReportCollection, BakeryToolRuntimeError | BakeryT for wizcli_command in self.wizcli_commands: log.info(f"[bright_blue bold]=== Scanning '{str(wizcli_command.image_target)}' with WizCLI ===") - log.debug(f"[bright_black]Executing wizcli command: {' '.join(wizcli_command.command)}") + log.debug(f"[bright_black]Executing wizcli command: {display_command(wizcli_command.command)}") # Ensure output directory exists wizcli_command.results_file.parent.mkdir(parents=True, exist_ok=True) @@ -80,7 +81,7 @@ def run(self) -> tuple[WizScanReportCollection, BakeryToolRuntimeError | BakeryT # clutter to stderr. Always capture stdout for error reporting on failure; # suppress stderr unless verbose mode is active. p = subprocess.run( - wizcli_command.command, + exec_args(wizcli_command.command), env=run_env, cwd=self.context, stdout=subprocess.PIPE, diff --git a/posit-bakery/posit_bakery/util.py b/posit-bakery/posit_bakery/util.py index 8c3d0ba25..bf9a23024 100644 --- a/posit-bakery/posit_bakery/util.py +++ b/posit-bakery/posit_bakery/util.py @@ -9,6 +9,46 @@ from posit_bakery.error import BakeryToolNotFoundError + +class SensitiveArg: + """Wraps a CLI argument value that must not appear in logs or error output. + + Use for credentials passed as command-line flags (e.g. ``--client-secret VALUE``). + ``str()`` and ``repr()`` return ``***``; ``.value`` returns the real string for + subprocess execution. Use ``exec_args()`` to unwrap a command list before passing + it to ``subprocess.run()``. + + Example:: + + cmd = ["wizcli", "scan", "--client-secret", SensitiveArg(secret)] + log.debug("Running: %s", display_command(cmd)) # --client-secret *** + subprocess.run(exec_args(cmd), ...) # real value forwarded + """ + + def __init__(self, value: str) -> None: + self._value = value + + def __str__(self) -> str: + return "***" + + def __repr__(self) -> str: + return "SensitiveArg(***)" + + @property + def value(self) -> str: + return self._value + + +def display_command(cmd: list) -> str: + """Join a command list for display, redacting any SensitiveArg values.""" + return " ".join(str(x) for x in cmd) + + +def exec_args(cmd: list) -> list[str]: + """Unwrap a command list for subprocess execution, revealing SensitiveArg values.""" + return [x.value if isinstance(x, SensitiveArg) else x for x in cmd] + + log = logging.getLogger(__name__) diff --git a/posit-bakery/test/config/image/test_version_os.py b/posit-bakery/test/config/image/test_version_os.py index d9ba4e10b..4498dd4f2 100644 --- a/posit-bakery/test/config/image/test_version_os.py +++ b/posit-bakery/test/config/image/test_version_os.py @@ -146,6 +146,8 @@ def test_equality(self): ("Rocky Linux 10", SUPPORTED_OS["rocky"]["10"]), ("Rocky 9", SUPPORTED_OS["rocky"]["9"]), ("Rocky", SUPPORTED_OS["rocky"]["10"]), + ("Scratch", SUPPORTED_OS["scratch"]), + ("scratch", SUPPORTED_OS["scratch"]), ], ) def test_populate_build_os(self, input_name, expected_build_os): diff --git a/posit-bakery/test/plugins/builtin/dgoss/test_command.py b/posit-bakery/test/plugins/builtin/dgoss/test_command.py index 5e9ee5a5e..e330a6dab 100644 --- a/posit-bakery/test/plugins/builtin/dgoss/test_command.py +++ b/posit-bakery/test/plugins/builtin/dgoss/test_command.py @@ -55,27 +55,62 @@ def test_image_environment(self, basic_standard_image_target): } assert dgoss_command.image_environment == expected_env - def test_image_environment_forwards_github_token_in_actions(self, basic_standard_image_target, monkeypatch): - """Inside a GitHub Actions run GITHUB_TOKEN is forwarded as GH_TOKEN.""" + def test_dgoss_environment_forwards_github_token_in_actions(self, basic_standard_image_target, monkeypatch): + """Inside a GitHub Actions run GITHUB_TOKEN is forwarded as GH_TOKEN in + the dgoss subprocess environment (not embedded in command-line args).""" monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setenv("GITHUB_TOKEN", "ghs_test_value") dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) - assert dgoss_command.image_environment["GH_TOKEN"] == "ghs_test_value" + assert dgoss_command.dgoss_environment["GH_TOKEN"] == "ghs_test_value" + assert "GH_TOKEN" not in dgoss_command.image_environment - def test_image_environment_skips_github_token_outside_actions(self, basic_standard_image_target, monkeypatch): - """Local runs with GITHUB_TOKEN set (e.g. from gh CLI) do not forward it, - so the raw token never appears in the dgoss command line outside the - GitHub Actions log-masking environment.""" + def test_dgoss_environment_skips_github_token_outside_actions(self, basic_standard_image_target, monkeypatch): + """Local runs with GITHUB_TOKEN set (e.g. from gh CLI) do not forward it.""" monkeypatch.setenv("GITHUB_TOKEN", "ghs_developer_pat") dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) + assert "GH_TOKEN" not in dgoss_command.dgoss_environment assert "GH_TOKEN" not in dgoss_command.image_environment - def test_image_environment_omits_gh_token_when_token_unset(self, basic_standard_image_target, monkeypatch): + def test_dgoss_environment_omits_gh_token_when_token_unset(self, basic_standard_image_target, monkeypatch): """In GitHub Actions but with no token (rare, but possible) GH_TOKEN is omitted.""" monkeypatch.setenv("GITHUB_ACTIONS", "true") dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) + assert "GH_TOKEN" not in dgoss_command.dgoss_environment assert "GH_TOKEN" not in dgoss_command.image_environment + def test_redacted_dgoss_environment_masks_gh_keys(self, basic_standard_image_target, monkeypatch): + """GH_* values are replaced with *** in the redacted view; other keys are unchanged.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_TOKEN", "ghs_real_token") + dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) + redacted = dgoss_command.redacted_dgoss_environment + assert redacted["GH_TOKEN"] == "***" + assert "ghs_real_token" not in redacted.values() + assert redacted["GOSS_FILES_PATH"] == dgoss_command.dgoss_environment["GOSS_FILES_PATH"] + assert redacted["GOSS_OPTS"] == dgoss_command.dgoss_environment["GOSS_OPTS"] + + def test_container_passthrough_env_vars_includes_gh_keys(self, basic_standard_image_target, monkeypatch): + """container_passthrough_env_vars lists GH_* keys set in dgoss_environment.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_TOKEN", "ghs_test_value") + dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) + assert dgoss_command.container_passthrough_env_vars == ["GH_TOKEN"] + + def test_container_passthrough_env_vars_empty_outside_actions(self, basic_standard_image_target, monkeypatch): + """No keys forwarded when GITHUB_ACTIONS is not set.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) + assert dgoss_command.container_passthrough_env_vars == [] + + def test_gh_token_not_in_command_args(self, basic_standard_image_target, monkeypatch): + """GH_TOKEN value must never appear as a KEY=VALUE arg — only as '-e GH_TOKEN'.""" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_TOKEN", "ghs_secret_value") + dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) + assert "ghs_secret_value" not in dgoss_command.command + pairs = [dgoss_command.command[i : i + 2] for i in range(len(dgoss_command.command) - 1)] + assert ["-e", "GH_TOKEN"] in pairs, "Expected '-e GH_TOKEN' (name-only) in command" + def test_volume_mounts(self, basic_standard_image_target): """Test that DGossCommand volume_mounts returns the expected volume mounts.""" dgoss_command = DGossCommand.from_image_target(image_target=basic_standard_image_target) diff --git a/posit-bakery/test/plugins/builtin/wizcli/test_command.py b/posit-bakery/test/plugins/builtin/wizcli/test_command.py index c404062af..53993c2ae 100644 --- a/posit-bakery/test/plugins/builtin/wizcli/test_command.py +++ b/posit-bakery/test/plugins/builtin/wizcli/test_command.py @@ -4,6 +4,7 @@ from pydantic import ValidationError from posit_bakery.plugins.builtin.wizcli.command import WizCLICommand +from posit_bakery.util import SensitiveArg, display_command pytestmark = [ pytest.mark.unit, @@ -81,7 +82,7 @@ def test_command_with_tool_options(self, basic_standard_image_target): assert "--scan-go-standard-library=false" in command_str def test_command_with_auth_options(self, basic_standard_image_target): - """Test that auth CLI options are passed through.""" + """Test that auth CLI options are passed through, with client_secret redacted.""" results_dir = basic_standard_image_target.context.base_path / "results" / "wizcli" cmd = WizCLICommand.from_image_target( image_target=basic_standard_image_target, @@ -92,7 +93,13 @@ def test_command_with_auth_options(self, basic_standard_image_target): assert "--client-id" in cmd.command assert "my-id" in cmd.command assert "--client-secret" in cmd.command - assert "my-secret" in cmd.command + # The secret value must be wrapped in SensitiveArg — never a plain string. + secret_arg = cmd.command[cmd.command.index("--client-secret") + 1] + assert isinstance(secret_arg, SensitiveArg) + assert secret_arg.value == "my-secret" + assert str(secret_arg) == "***" + # display_command must not reveal the value. + assert "my-secret" not in display_command(cmd.command) def test_command_with_device_code_flags(self, basic_standard_image_target): """Test that boolean auth flags are included when set.""" diff --git a/posit-bakery/test/test_util.py b/posit-bakery/test/test_util.py index 232f053cc..843a64c20 100644 --- a/posit-bakery/test/test_util.py +++ b/posit-bakery/test/test_util.py @@ -5,12 +5,65 @@ from posit_bakery import util from posit_bakery.error import BakeryToolNotFoundError +from posit_bakery.util import SensitiveArg, display_command, exec_args pytestmark = [ pytest.mark.unit, ] +class TestSensitiveArg: + def test_str_returns_redacted(self): + assert str(SensitiveArg("real-secret")) == "***" + + def test_repr_returns_redacted(self): + assert repr(SensitiveArg("real-secret")) == "SensitiveArg(***)" + + def test_value_returns_real_value(self): + assert SensitiveArg("real-secret").value == "real-secret" + + def test_empty_string_value(self): + assert SensitiveArg("").value == "" + assert str(SensitiveArg("")) == "***" + + +class TestDisplayCommand: + def test_plain_strings(self): + assert display_command(["wizcli", "scan", "--no-color"]) == "wizcli scan --no-color" + + def test_redacts_sensitive_arg(self): + cmd = ["wizcli", "--client-secret", SensitiveArg("tok")] + assert display_command(cmd) == "wizcli --client-secret ***" + assert "tok" not in display_command(cmd) + + def test_mixed_list(self): + cmd = ["prog", SensitiveArg("s1"), "middle", SensitiveArg("s2")] + result = display_command(cmd) + assert result == "prog *** middle ***" + assert "s1" not in result + assert "s2" not in result + + def test_empty_list(self): + assert display_command([]) == "" + + +class TestExecArgs: + def test_unwraps_sensitive_arg(self): + cmd = ["wizcli", "--client-secret", SensitiveArg("tok")] + assert exec_args(cmd) == ["wizcli", "--client-secret", "tok"] + + def test_passes_through_plain_strings(self): + cmd = ["prog", "arg1", "arg2"] + assert exec_args(cmd) == ["prog", "arg1", "arg2"] + + def test_mixed_list(self): + cmd = ["prog", SensitiveArg("s"), "plain"] + assert exec_args(cmd) == ["prog", "s", "plain"] + + def test_empty_list(self): + assert exec_args([]) == [] + + def test_find_bin_by_environ(mocker): """Test finding a binary by environment variable""" mocker.patch.dict("posit_bakery.util.os.environ", {"GOSS_PATH": "/usr/bin/goss"})