Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions posit-bakery/posit_bakery/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
44 changes: 32 additions & 12 deletions posit-bakery/posit_bakery/plugins/builtin/dgoss/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]]:
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
7 changes: 4 additions & 3 deletions posit-bakery/posit_bakery/plugins/builtin/dgoss/suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions posit-bakery/posit_bakery/plugins/builtin/oras/oras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions posit-bakery/posit_bakery/plugins/builtin/wizcli/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
5 changes: 3 additions & 2 deletions posit-bakery/posit_bakery/plugins/builtin/wizcli/suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions posit-bakery/posit_bakery/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down
2 changes: 2 additions & 0 deletions posit-bakery/test/config/image/test_version_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
51 changes: 43 additions & 8 deletions posit-bakery/test/plugins/builtin/dgoss/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading