Skip to content
Merged
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
23 changes: 18 additions & 5 deletions cli/tests/test_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,31 @@
FIXTURES_DIR = Path(__file__).parent / "fixtures"


def _assert_text_has_no_secret_plaintext(text: str, *secret_values: str) -> None:
Comment thread
clean6378-max-it marked this conversation as resolved.
"""Ensure secret plaintext never appears in an arbitrary captured string."""
for value in secret_values:
if value:
assert value not in text, (
f"secret value leaked into captured text: {value!r}"
)


def _assert_file_has_no_secret_plaintext(path: Path, *secret_values: str) -> None:
"""Ensure secret plaintext never appears in a file's contents."""
_assert_text_has_no_secret_plaintext(
path.read_text(encoding="utf-8"),
*secret_values,
)


def _assert_argv_has_no_secret_leaks(argv: list[str], *secret_values: str) -> None:
"""Ensure secret values and ``--secret`` flags never appear on argv."""
for arg in argv:
assert arg != "--secret", f"unexpected --secret flag in argv: {arg!r}"
assert not arg.startswith("--secret="), (
f"unexpected --secret= flag in argv: {arg!r}"
)
for value in secret_values:
if value:
assert value not in arg, (
f"secret value leaked into argv element: {arg!r}"
)
_assert_text_has_no_secret_plaintext(arg, *secret_values)


def _make_entry(
Expand Down
201 changes: 201 additions & 0 deletions cli/tests/test_secrets_non_leak.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""End-to-end secrets non-leak invariant tests (is-3).

Proves configured secret plaintext never appears on capture surfaces
(stdout, stderr, log file) or in persisted ExecutionSummary JSON fields
(error_message, log_file path, etc.). to_dict() does not serialize stdout/stderr.

Env delivery (executor.py:679, asserted in test_secrets_injected_into_subprocess_env)
is out of scope for this invariant: we scan capture surfaces Local CI writes
(logs, summaries, display), not child-process env dumps. /proc/<pid>/environ
visibility is the accepted tradeoff noted in executor comments.
"""
Comment thread
clean6378-max-it marked this conversation as resolved.

from __future__ import annotations

from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch

from localci.core.executor import ActCommand, JobExecutor, JobStatus
from localci.core.results import ExecutionSummary

from .test_executor import (
_assert_file_has_no_secret_plaintext,
_assert_text_has_no_secret_plaintext,
)

INVARIANT_SECRET = "localci-invariant-secret-7f3a9c2e"

# Per-result keys ExecutionSummary.to_dict() serializes (no stdout/stderr).
_SUMMARY_RESULT_JSON_KEYS = frozenset(
{
"job_id",
"matrix_index",
"matrix_name",
"status",
"exit_code",
"duration",
"image_used",
"log_file",
"error_message",
}
)


def _mock_popen_factory(
*,
returncode: int,
stdout_lines: list[str],
stderr_lines: list[str],
) -> MagicMock:
mock_process = MagicMock()
mock_process.stdout = stdout_lines
mock_process.stderr = stderr_lines
mock_process.returncode = returncode
return mock_process


class TestSecretsNonLeakInvariant:
@patch("shutil.which")
def test_execute_process_capture_surfaces_contain_no_secret_plaintext(
Comment thread
clean6378-max-it marked this conversation as resolved.
self, mock_which, tmp_path
):
"""Stdout, stderr, and on-disk log must not contain configured secret plaintext."""
mock_which.return_value = "/usr/bin/act"
executor = JobExecutor(logs_dir=tmp_path / "logs")
act_cmd = ActCommand(
workflow_file=Path("ci.yml"),
job_id="build",
secrets={"GITHUB_TOKEN": INVARIANT_SECRET},
workdir=tmp_path,
)
log_file = tmp_path / "job.log"

# Benign mock output: Local CI does not redact streams. If stdout/stderr
# contained INVARIANT_SECRET, the assertions below should fail.
mock_process = _mock_popen_factory(
Comment thread
clean6378-max-it marked this conversation as resolved.
returncode=0,
stdout_lines=["Building project...\n", "compile ok\n"],
stderr_lines=["warning: deprecated flag\n"],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

with patch(
"localci.core.executor.subprocess.Popen",
return_value=mock_process,
):
exit_code, stdout, stderr = executor._execute_process(
act_cmd,
timeout=10,
log_file=log_file,
stream_output=False,
on_output=None,
)

assert exit_code == 0
assert "compile ok" in stdout
assert "warning: deprecated flag" in stderr
_assert_text_has_no_secret_plaintext(stdout, INVARIANT_SECRET)
_assert_text_has_no_secret_plaintext(stderr, INVARIANT_SECRET)
_assert_file_has_no_secret_plaintext(log_file, INVARIANT_SECRET)
_assert_text_has_no_secret_plaintext(act_cmd.display(), INVARIANT_SECRET)

@patch("shutil.which")
def test_run_success_job_result_capture_surfaces_contain_no_secret_plaintext(
self, mock_which, tmp_path
):
"""JobResult stdout, stderr, and log_file must not contain secret plaintext."""
mock_which.return_value = "/usr/bin/act"
executor = JobExecutor(logs_dir=tmp_path / "logs")
act_cmd = ActCommand(
workflow_file=Path("ci.yml"),
job_id="build",
secrets={"GITHUB_TOKEN": INVARIANT_SECRET},
workdir=tmp_path,
)

mock_process = _mock_popen_factory(
returncode=0,
stdout_lines=["Building project...\n", "compile ok\n"],
stderr_lines=["warning: deprecated flag\n"],
)

with (
patch.object(executor, "check_act", return_value="act 0.2.68"),
patch.object(executor, "check_docker"),
patch(
"localci.core.executor.subprocess.Popen",
return_value=mock_process,
),
):
result = executor.run(
act_cmd,
matrix_index=0,
matrix_name="GCC 15: C++20",
)

assert result.status == JobStatus.PASSED
assert result.log_file is not None
assert "compile ok" in result.stdout
assert "warning: deprecated flag" in result.stderr
_assert_text_has_no_secret_plaintext(result.stdout, INVARIANT_SECRET)
_assert_text_has_no_secret_plaintext(result.stderr, INVARIANT_SECRET)
_assert_file_has_no_secret_plaintext(result.log_file, INVARIANT_SECRET)

@patch("shutil.which")
def test_execution_summary_save_contains_no_secret_plaintext(
self, mock_which, tmp_path
):
"""Failed-job error_message serialized to JSON must not contain secret plaintext.

ExecutionSummary.to_dict() omits stdout/stderr; this exercises the
failure path where error_message is populated via _extract_error().
"""
mock_which.return_value = "/usr/bin/act"
executor = JobExecutor(logs_dir=tmp_path / "logs")
act_cmd = ActCommand(
workflow_file=Path("ci.yml"),
job_id="build",
secrets={"GITHUB_TOKEN": INVARIANT_SECRET},
workdir=tmp_path,
)

mock_process = _mock_popen_factory(
returncode=1,
stdout_lines=["act: starting job\n"],
stderr_lines=["Error: act job failed\n"],
)

with (
patch.object(executor, "check_act", return_value="act 0.2.68"),
patch.object(executor, "check_docker"),
patch(
"localci.core.executor.subprocess.Popen",
return_value=mock_process,
),
):
result = executor.run(
act_cmd,
matrix_index=0,
matrix_name="GCC 15: C++20",
)

assert result.status == JobStatus.FAILED
Comment thread
clean6378-max-it marked this conversation as resolved.
assert "act: starting job" in result.stdout
assert "Error: act job failed" in result.stderr
assert result.error_message
assert "Error: act job failed" in result.error_message
_assert_text_has_no_secret_plaintext(result.error_message, INVARIANT_SECRET)
assert result.log_file is not None
_assert_file_has_no_secret_plaintext(result.log_file, INVARIANT_SECRET)

summary = ExecutionSummary(
execution_id="exec-non-leak",
started_at=datetime(2026, 7, 28, 10, 0, 0),
)
summary.results.append(result)
serialized = summary.to_dict()["results"][0]
assert set(serialized.keys()) == _SUMMARY_RESULT_JSON_KEYS
summary_path = tmp_path / "results.json"
summary.save(summary_path)

_assert_file_has_no_secret_plaintext(summary_path, INVARIANT_SECRET)
Comment thread
clean6378-max-it marked this conversation as resolved.
Loading