fix(ops): make release provenance gate fail closed
This commit is contained in:
@@ -106,27 +106,71 @@ def _init_git_repo(tmp_path: Path) -> tuple[Path, str]:
|
||||
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True)
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
subprocess.run(["git", "checkout", "--detach", head], cwd=repo, check=True, capture_output=True)
|
||||
return repo, head
|
||||
|
||||
|
||||
def _provenance_kwargs(repo: Path, head: str) -> dict:
|
||||
broker_log = repo.parent / "broker.log"
|
||||
broker_log.write_text("event=order_submit token=must-not-leak\n", encoding="utf-8")
|
||||
bindir = repo.parent / "bin"
|
||||
bindir.mkdir(exist_ok=True)
|
||||
trex = bindir / "trex-core"
|
||||
rabbit = bindir / "rabbitmqctl"
|
||||
trex.write_text("#!/bin/sh\nprintf '{}\\n'\n", encoding="utf-8")
|
||||
rabbit.write_text("#!/bin/sh\nprintf 'consumer\\n'\n", encoding="utf-8")
|
||||
trex.chmod(0o755)
|
||||
rabbit.chmod(0o755)
|
||||
instance = "inst-a"
|
||||
return {
|
||||
"actor": "operator-a",
|
||||
"reason": "deploy",
|
||||
"instance": instance,
|
||||
"command_class": "manual-start",
|
||||
"operation_id": "op-1",
|
||||
"phase": "pre",
|
||||
"checkout_dir": repo,
|
||||
"expected_oci_revision": head,
|
||||
"image_digest": "sha256:" + ("a" * 64),
|
||||
"runtime_files": ["tracked.txt"],
|
||||
"capture_commands": {
|
||||
"pending_slots": [
|
||||
str(trex), "slots", "--status", "pending", "--instance", instance, "--json"
|
||||
],
|
||||
"reconciliation_slots": [
|
||||
str(trex), "slots", "--status", "all", "--instance", instance, "--json"
|
||||
],
|
||||
"queue_consumers": [str(rabbit), "list_consumers"],
|
||||
},
|
||||
"broker_log_path": broker_log,
|
||||
}
|
||||
|
||||
|
||||
def test_collect_release_provenance_passes_on_clean_checkout(tmp_path):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
report = collect_release_provenance(
|
||||
actor="operator-a",
|
||||
reason="deploy",
|
||||
instance="inst-a",
|
||||
command_class="manual-start",
|
||||
checkout_dir=repo,
|
||||
expected_oci_revision=head,
|
||||
image_digest="sha256:" + ("a" * 64),
|
||||
runtime_files=["tracked.txt"],
|
||||
capture_commands={},
|
||||
broker_log_path=None,
|
||||
)
|
||||
report = collect_release_provenance(**_provenance_kwargs(repo, head))
|
||||
assert report["checkout"]["git_clean"] is True
|
||||
assert report["checkout"]["detached_head"] is True
|
||||
assert report["artifact_identity"]["oci_revision_matches_head"] is True
|
||||
assert report["runtime_file_verification"][0]["matches_expected_revision"] is True
|
||||
assert report["gate"]["passed"] is True
|
||||
assert report["operation_id"] == "op-1"
|
||||
assert report["phase"] == "pre"
|
||||
rendered = str(report)
|
||||
assert "must-not-leak" not in rendered
|
||||
assert report["state_capture"]["broker_side_effect_logs"]["matches"][0]["event"] == "order_submit"
|
||||
|
||||
|
||||
def test_collect_release_provenance_blocks_attached_checkout(tmp_path):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
subprocess.run(["git", "switch", "-c", "audit-attached"], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
report = collect_release_provenance(**_provenance_kwargs(repo, head))
|
||||
|
||||
assert report["checkout"]["git_clean"] is True
|
||||
assert report["checkout"]["detached_head"] is False
|
||||
assert report["state_capture"]["command_outputs"] == {}
|
||||
assert report["gate"]["passed"] is False
|
||||
|
||||
|
||||
def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_path):
|
||||
@@ -134,17 +178,8 @@ def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_p
|
||||
(repo / "tracked.txt").write_text("v2\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "v2"], cwd=repo, check=True, capture_output=True)
|
||||
report = collect_release_provenance(
|
||||
actor="operator-a",
|
||||
reason="deploy",
|
||||
instance="inst-a",
|
||||
command_class="manual-start",
|
||||
checkout_dir=repo,
|
||||
expected_oci_revision=old_head,
|
||||
runtime_files=["tracked.txt"],
|
||||
capture_commands={},
|
||||
broker_log_path=None,
|
||||
)
|
||||
kwargs = _provenance_kwargs(repo, old_head)
|
||||
report = collect_release_provenance(**kwargs)
|
||||
assert report["checkout"]["git_clean"] is True
|
||||
assert report["artifact_identity"]["oci_revision_matches_head"] is False
|
||||
assert report["runtime_file_verification"][0]["matches_expected_revision"] is False
|
||||
@@ -159,24 +194,18 @@ def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path):
|
||||
manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
|
||||
assert set(manifest["dirty_paths"]) == {"tracked.txt", "new.txt"}
|
||||
assert set(manifest["copied_paths"]) == {"tracked.txt", "new.txt"}
|
||||
assert (isolated / "tracked.txt").read_text(encoding="utf-8") == "dirty\n"
|
||||
assert (isolated / "new.txt").read_text(encoding="utf-8") == "new\n"
|
||||
assert (isolated / "manifest.json").is_file()
|
||||
snapshot = Path(manifest["isolate_dir"])
|
||||
assert snapshot.parent == isolated
|
||||
assert (snapshot / "tracked.txt").read_text(encoding="utf-8") == "dirty\n"
|
||||
assert (snapshot / "new.txt").read_text(encoding="utf-8") == "new\n"
|
||||
assert (snapshot / "manifest.json").is_file()
|
||||
|
||||
|
||||
def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
report = collect_release_provenance(
|
||||
actor="operator-a",
|
||||
reason="deploy",
|
||||
instance="inst-a",
|
||||
command_class="manual-start",
|
||||
checkout_dir=repo,
|
||||
expected_oci_revision=head,
|
||||
runtime_files=["../outside.txt"],
|
||||
capture_commands={},
|
||||
broker_log_path=None,
|
||||
)
|
||||
kwargs = _provenance_kwargs(repo, head)
|
||||
kwargs["runtime_files"] = ["../outside.txt"]
|
||||
report = collect_release_provenance(**kwargs)
|
||||
runtime = report["runtime_file_verification"][0]
|
||||
assert runtime["path_within_checkout"] is False
|
||||
assert runtime["matches_expected_revision"] is False
|
||||
@@ -186,13 +215,93 @@ def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path):
|
||||
def test_collect_release_provenance_reports_git_failure_context(tmp_path):
|
||||
not_repo = tmp_path / "not-repo"
|
||||
not_repo.mkdir()
|
||||
trex = tmp_path / "trex-core"
|
||||
rabbit = tmp_path / "rabbitmqctl"
|
||||
with pytest.raises(RuntimeError, match="git command failed in checkout="):
|
||||
collect_release_provenance(
|
||||
actor="operator-a",
|
||||
reason="deploy",
|
||||
instance="inst-a",
|
||||
command_class="manual-start",
|
||||
operation_id="op-1",
|
||||
phase="pre",
|
||||
checkout_dir=not_repo,
|
||||
capture_commands={},
|
||||
broker_log_path=None,
|
||||
expected_oci_revision="0" * 40,
|
||||
image_digest="sha256:" + ("a" * 64),
|
||||
runtime_files=["tracked.txt"],
|
||||
capture_commands={
|
||||
"pending_slots": [
|
||||
str(trex), "slots", "--status", "pending", "--instance", "inst-a", "--json"
|
||||
],
|
||||
"reconciliation_slots": [
|
||||
str(trex), "slots", "--status", "all", "--instance", "inst-a", "--json"
|
||||
],
|
||||
"queue_consumers": [str(rabbit), "list_consumers"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_dirty_checkout_blocks_before_capture_commands_run(tmp_path):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
marker = tmp_path / "capture-ran"
|
||||
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
|
||||
kwargs = _provenance_kwargs(repo, head)
|
||||
for command in kwargs["capture_commands"].values():
|
||||
executable = Path(command[0])
|
||||
executable.write_text(f"#!/bin/sh\ntouch {marker}\n", encoding="utf-8")
|
||||
executable.chmod(0o755)
|
||||
|
||||
report = collect_release_provenance(**kwargs)
|
||||
|
||||
assert report["gate"]["passed"] is False
|
||||
assert report["gate"]["deployment_blocked"] is True
|
||||
assert report["state_capture"]["command_outputs"] == {}
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_capture_failure_or_missing_broker_log_blocks_gate(tmp_path):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
kwargs = _provenance_kwargs(repo, head)
|
||||
trex = Path(kwargs["capture_commands"]["pending_slots"][0])
|
||||
trex.write_text("#!/bin/sh\nexit 3\n", encoding="utf-8")
|
||||
trex.chmod(0o755)
|
||||
kwargs["broker_log_path"] = tmp_path / "missing.log"
|
||||
|
||||
report = collect_release_provenance(**kwargs)
|
||||
|
||||
assert report["gate"]["passed"] is False
|
||||
assert report["gate"]["state_captures_succeeded"] is False
|
||||
assert report["gate"]["broker_log_captured"] is False
|
||||
|
||||
|
||||
def test_empty_broker_log_is_explicitly_captured_as_no_matching_events(tmp_path):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
kwargs = _provenance_kwargs(repo, head)
|
||||
Path(kwargs["broker_log_path"]).write_bytes(b"")
|
||||
|
||||
report = collect_release_provenance(**kwargs)
|
||||
|
||||
broker = report["state_capture"]["broker_side_effect_logs"]
|
||||
assert broker["capture_status"] == "captured_empty"
|
||||
assert broker["file_sha256"] == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
assert broker["matches"] == []
|
||||
assert report["gate"]["passed"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("expected_oci_revision", None),
|
||||
("image_digest", "not-a-digest"),
|
||||
("runtime_files", []),
|
||||
("capture_commands", {}),
|
||||
("instance", "../escape"),
|
||||
("reason", "password=must-not-be-recorded"),
|
||||
],
|
||||
)
|
||||
def test_required_provenance_inputs_fail_closed(tmp_path, field, value):
|
||||
repo, head = _init_git_repo(tmp_path)
|
||||
kwargs = _provenance_kwargs(repo, head)
|
||||
kwargs[field] = value
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
collect_release_provenance(**kwargs)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import release_provenance_gate
|
||||
|
||||
|
||||
def test_documented_python_invocation_loads_project_without_pythonpath():
|
||||
script = Path(release_provenance_gate.__file__).resolve()
|
||||
env = os.environ.copy()
|
||||
env.pop("PYTHONPATH", None)
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(script), "--help"],
|
||||
cwd=script.parent,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert "--expected-oci-revision" in completed.stdout
|
||||
assert script.stat().st_mode & 0o111 != 0
|
||||
|
||||
|
||||
def _init_repo(tmp_path: Path) -> tuple[Path, str]:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True)
|
||||
(repo / "runtime.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "runtime.py"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True)
|
||||
head = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=repo, check=True, capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
subprocess.run(["git", "checkout", "--detach", head], cwd=repo, check=True, capture_output=True)
|
||||
return repo, head
|
||||
|
||||
|
||||
def _fake_read_only_commands(tmp_path: Path) -> tuple[Path, Path]:
|
||||
bindir = tmp_path / "bin"
|
||||
bindir.mkdir(parents=True)
|
||||
trex = bindir / "trex-core"
|
||||
rabbit = bindir / "rabbitmqctl"
|
||||
trex.write_text("#!/bin/sh\nprintf '{}\\n'\n", encoding="utf-8")
|
||||
rabbit.write_text("#!/bin/sh\nprintf 'consumer\\n'\n", encoding="utf-8")
|
||||
trex.chmod(0o755)
|
||||
rabbit.chmod(0o755)
|
||||
return trex, rabbit
|
||||
|
||||
|
||||
def _argv(tmp_path: Path, repo: Path, head: str) -> list[str]:
|
||||
trex, rabbit = _fake_read_only_commands(tmp_path)
|
||||
broker_log = tmp_path / "broker.log"
|
||||
broker_log.write_text("event=order_submit account=must-not-leak\n", encoding="utf-8")
|
||||
return [
|
||||
"--actor", "operator-a",
|
||||
"--reason", "deploy release",
|
||||
"--instance", "inst-a",
|
||||
"--operation-id", "op-123",
|
||||
"--phase", "pre",
|
||||
"--command-class", "deploy",
|
||||
"--checkout-dir", str(repo),
|
||||
"--expected-oci-revision", head,
|
||||
"--image-digest", "sha256:" + ("a" * 64),
|
||||
"--runtime-file", "runtime.py",
|
||||
"--broker-log-path", str(broker_log),
|
||||
"--capture-command", f"pending_slots={trex} slots --status pending --instance inst-a --json",
|
||||
"--capture-command", f"reconciliation_slots={trex} slots --status all --instance inst-a --json",
|
||||
"--capture-command", f"queue_consumers={rabbit} list_consumers",
|
||||
]
|
||||
|
||||
|
||||
def test_cli_writes_unique_record_outside_checkout_and_keeps_checkout_clean(tmp_path, capsys):
|
||||
repo, head = _init_repo(tmp_path)
|
||||
argv = _argv(tmp_path, repo, head)
|
||||
|
||||
assert release_provenance_gate.main(argv) == 0
|
||||
first = Path(capsys.readouterr().out.strip())
|
||||
assert release_provenance_gate.main(argv) == 0
|
||||
second = Path(capsys.readouterr().out.strip())
|
||||
|
||||
assert first != second
|
||||
assert first.parent == tmp_path / "repo-release-provenance"
|
||||
assert first.stat().st_mode & 0o222 == 0
|
||||
assert second.stat().st_mode & 0o222 == 0
|
||||
payload = json.loads(first.read_text(encoding="utf-8"))
|
||||
assert payload["gate"]["passed"] is True
|
||||
assert "must-not-leak" not in first.read_text(encoding="utf-8")
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain"], cwd=repo, check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
assert status == ""
|
||||
|
||||
|
||||
def test_cli_dirty_checkout_is_fail_closed_and_isolated(tmp_path, capsys):
|
||||
repo, head = _init_repo(tmp_path)
|
||||
argv = _argv(tmp_path, repo, head)
|
||||
(repo / "runtime.py").write_text("dirty\n", encoding="utf-8")
|
||||
|
||||
assert release_provenance_gate.main(argv) == 2
|
||||
output = Path(capsys.readouterr().out.strip())
|
||||
payload = json.loads(output.read_text(encoding="utf-8"))
|
||||
assert output.stat().st_mode & 0o222 == 0
|
||||
assert payload["gate"]["passed"] is False
|
||||
assert payload["gate"]["deployment_blocked"] is True
|
||||
assert payload["state_capture"]["command_outputs"] == {}
|
||||
snapshot = Path(payload["dirty_checkout_isolation"]["isolate_dir"])
|
||||
assert snapshot.parent == tmp_path / "repo-release-provenance-isolation"
|
||||
assert (snapshot / "runtime.py").read_text() == "dirty\n"
|
||||
|
||||
|
||||
def test_cli_rejects_unsafe_command_and_checkout_local_output(tmp_path):
|
||||
repo, head = _init_repo(tmp_path)
|
||||
argv = _argv(tmp_path, repo, head)
|
||||
unsafe = argv.index(next(x for x in argv if x.startswith("pending_slots=")))
|
||||
argv[unsafe] = "pending_slots=trex-core cancel slot-1 --yes"
|
||||
with pytest.raises(SystemExit):
|
||||
release_provenance_gate.main(argv)
|
||||
|
||||
argv = _argv(tmp_path / "other", repo, head)
|
||||
argv.extend(["--output-dir", str(repo / "artifacts")])
|
||||
with pytest.raises(SystemExit):
|
||||
release_provenance_gate.main(argv)
|
||||
Reference in New Issue
Block a user