Compare commits
2 Commits
ea97d37524
...
0a4ff7f1f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a4ff7f1f6 | ||
|
|
a5cafddd45 |
@ -136,7 +136,12 @@ unknown or reconciling state is not healthy.
|
||||
`release.exact-lineage-is-running` atomically binds:
|
||||
|
||||
- fixed remote `main` SHA and local `origin/main`;
|
||||
- open draft PR #19, base `main`, existing feature ref, and exact reviewed head;
|
||||
- merged PR #19 — `state=closed` with `merged=true`, base `main`, the existing
|
||||
feature ref, and the exact reviewed head — with that head additionally proven
|
||||
an ancestor of the release `main` by `git merge-base --is-ancestor`. A PR
|
||||
that is still open, closed without merging, or carrying a different head is a
|
||||
`FAIL`: after integration, only the merged terminal state proves the reviewed
|
||||
code is what runs;
|
||||
- the exact `:git-<source-SHA>-build-<n>@sha256:<digest>` image identity,
|
||||
with the source SHA equal to the release `main` SHA;
|
||||
- current Deployment generation and revision;
|
||||
|
||||
@ -225,7 +225,7 @@ def _release_lineage(targets: Targets) -> CheckSpec:
|
||||
)
|
||||
return check(
|
||||
"release.exact-lineage-is-running",
|
||||
"Remote main, reviewed PR, image, build, Flux, and running revision are identical",
|
||||
"Remote main, merged reviewed PR, image, build, Flux, and running revision agree",
|
||||
"release",
|
||||
"release_lineage",
|
||||
[
|
||||
@ -251,6 +251,15 @@ def _release_lineage(targets: Targets) -> CheckSpec:
|
||||
"rev-parse",
|
||||
f"{targets.remote}/main",
|
||||
),
|
||||
step(
|
||||
"merge-ancestry",
|
||||
OPERATOR,
|
||||
"git",
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
targets.reviewed_head_sha,
|
||||
f"{targets.remote}/main",
|
||||
),
|
||||
step(
|
||||
"deployment",
|
||||
OPERATOR,
|
||||
|
||||
@ -420,11 +420,9 @@ def evaluate_release_lineage(spec: CheckSpec, outcomes: dict[str, Outcome]) -> E
|
||||
return pr
|
||||
fields = {
|
||||
"number": expected["pr_number"],
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"state": "closed",
|
||||
"merged": True,
|
||||
"base.ref": "main",
|
||||
"base.sha": main_sha,
|
||||
"head.ref": expected["head_ref"],
|
||||
"head.sha": head_sha,
|
||||
}
|
||||
@ -437,6 +435,10 @@ def evaluate_release_lineage(spec: CheckSpec, outcomes: dict[str, Outcome]) -> E
|
||||
return Evaluation(FAIL, f"reviewed PR {path} does not match", {path: actual})
|
||||
if outcomes["build-source"].stdout.strip() != build_sha:
|
||||
return Evaluation(FAIL, "build source SHA does not match the fixed release build SHA")
|
||||
ancestry = outcomes["merge-ancestry"]
|
||||
if not ancestry.ok:
|
||||
status = FAIL if ancestry.returncode == 1 else NOT_RUN
|
||||
return Evaluation(status, "reviewed head is not a proven ancestor of remote main")
|
||||
deployment = outcomes["deployment"].stdout.strip().split("\t")
|
||||
if len(deployment) != 6:
|
||||
return Evaluation(NOT_RUN, "Deployment projection is malformed")
|
||||
|
||||
@ -134,7 +134,7 @@ def validate_targets(targets: Targets) -> list[str]:
|
||||
or targets.reviewed_head_ref != "feature/hermes-full-handoff-acceptance"
|
||||
):
|
||||
problems.append(
|
||||
"reviewed PR identity must remain fixed to draft PR #19 and its existing branch"
|
||||
"reviewed PR identity must remain fixed to PR #19 and its existing branch"
|
||||
)
|
||||
for label, value in (
|
||||
("baseline commit", targets.baseline_commit),
|
||||
|
||||
@ -22,6 +22,7 @@ from node_account_io import (
|
||||
assert_unchanged,
|
||||
atomic_write,
|
||||
backup_once,
|
||||
read_mounted_secret,
|
||||
read_regular,
|
||||
)
|
||||
|
||||
@ -214,7 +215,8 @@ def _key_identity(line: bytes) -> tuple[str, bytes] | None:
|
||||
|
||||
|
||||
def _validated_public_key(path: Path) -> tuple[bytes, tuple[str, bytes]]:
|
||||
value, _ = _read_regular(path, 16 * 1024)
|
||||
# Vault CSI projects the key as a symlink chain inside its own mount.
|
||||
value = read_mounted_secret(path, path.parent, 16 * 1024).value
|
||||
line = value.strip()
|
||||
if b"\n" in line or b"\r" in line:
|
||||
raise HardeningError("Hermes public key must contain one line")
|
||||
|
||||
@ -113,6 +113,27 @@ def read_regular(path: Path, maximum: int) -> FileSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def read_mounted_secret(path: Path, mount_root: Path, maximum: int) -> FileSnapshot:
|
||||
"""Read one bounded secret projected through mount-internal symlinks.
|
||||
|
||||
Kubernetes CSI and projected volumes present each file as a symlink into
|
||||
a hidden ``..data`` directory, which the O_NOFOLLOW read refuses. Resolve
|
||||
the full chain first, refuse any resolution that loops or escapes the
|
||||
read-only mount, then apply the unchanged hardened read to the resolved
|
||||
regular file.
|
||||
"""
|
||||
try:
|
||||
resolved = Path(os.path.realpath(path, strict=True))
|
||||
root = Path(os.path.realpath(mount_root, strict=True))
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise HardeningError(f"unresolvable mounted secret: {path.name}") from exc
|
||||
if root not in resolved.parents:
|
||||
raise HardeningError(f"mounted secret escapes its mount: {path.name}")
|
||||
return read_regular(resolved, maximum)
|
||||
|
||||
|
||||
def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None:
|
||||
"""Fail if an account file changed since planning began."""
|
||||
current = read_regular(path, maximum)
|
||||
|
||||
@ -341,6 +341,7 @@ def lineage_spec():
|
||||
"remote-main",
|
||||
"reviewed-pr",
|
||||
"build-source",
|
||||
"merge-ancestry",
|
||||
"deployment",
|
||||
"pods",
|
||||
"replicasets",
|
||||
@ -364,16 +365,18 @@ def lineage_spec():
|
||||
def lineage_outcomes():
|
||||
pull = {
|
||||
"number": 19,
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"merged": False,
|
||||
"base": {"ref": "main", "sha": MAIN},
|
||||
"state": "closed",
|
||||
"draft": False,
|
||||
"merged": True,
|
||||
# The recorded base SHA is the pre-merge base, not the current main.
|
||||
"base": {"ref": "main", "sha": "3" * 40},
|
||||
"head": {"ref": "feature/hermes-full-handoff-acceptance", "sha": HEAD},
|
||||
}
|
||||
return {
|
||||
"remote-main": outcome(stdout=f"{MAIN}\trefs/heads/main\n"),
|
||||
"reviewed-pr": outcome(stdout=json.dumps(pull)),
|
||||
"build-source": outcome(stdout=BUILD),
|
||||
"merge-ancestry": outcome(),
|
||||
"deployment": outcome(stdout=f"5\t5\t2\t2\t7\t{IMAGE}"),
|
||||
"replicasets": outcome(
|
||||
stdout=f"rs-old\t6\t0\t0\t{IMAGE}\told\nrs-live\t7\t2\t2\t{IMAGE}\thash7\n"
|
||||
@ -392,6 +395,34 @@ def test_release_lineage_atomically_binds_every_release_identity() -> None:
|
||||
assert evaluate(lineage_spec(), lineage_outcomes()).status == model.PASS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
{"state": "open", "draft": True, "merged": False},
|
||||
{"state": "closed", "merged": False},
|
||||
{"head": {"ref": "feature/hermes-full-handoff-acceptance", "sha": "9" * 40}},
|
||||
],
|
||||
ids=["still-open-draft", "closed-without-merging", "wrong-head-sha"],
|
||||
)
|
||||
def test_release_lineage_accepts_only_the_merged_terminal_pr_state(patch) -> None:
|
||||
"""After integration, only the merged PR proves the reviewed code runs."""
|
||||
outcomes = lineage_outcomes()
|
||||
pull = {**json.loads(outcomes["reviewed-pr"].stdout), **patch}
|
||||
outcomes["reviewed-pr"] = outcome(stdout=json.dumps(pull))
|
||||
|
||||
assert evaluate(lineage_spec(), outcomes).status == model.FAIL
|
||||
|
||||
|
||||
def test_release_lineage_requires_the_merged_head_to_be_an_ancestor_of_main() -> None:
|
||||
not_ancestor = lineage_outcomes()
|
||||
not_ancestor["merge-ancestry"] = outcome(returncode=1)
|
||||
assert evaluate(lineage_spec(), not_ancestor).status == model.FAIL
|
||||
|
||||
undecidable = lineage_outcomes()
|
||||
undecidable["merge-ancestry"] = outcome(returncode=128)
|
||||
assert evaluate(lineage_spec(), undecidable).status == model.NOT_RUN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "bad"),
|
||||
[
|
||||
|
||||
@ -99,6 +99,21 @@ def test_preexisting_target_key_is_removed_before_legacy_cleanup_and_reinstalled
|
||||
assert target.read_text() == key + "\n"
|
||||
|
||||
|
||||
def test_reconcile_reads_csi_projected_public_key(tmp_path: Path, monkeypatch):
|
||||
module, _originals, key, _other, public_key = _fixture(tmp_path, monkeypatch)
|
||||
data = public_key.parent / "..2026_08_18.1"
|
||||
data.mkdir()
|
||||
(data / public_key.name).write_text(key + "\n", encoding="utf-8")
|
||||
(public_key.parent / "..data").symlink_to(data.name)
|
||||
public_key.unlink()
|
||||
public_key.symlink_to(Path("..data") / public_key.name)
|
||||
|
||||
module.reconcile(public_key)
|
||||
|
||||
target = module.HOST_HOME / module.ACCOUNT / ".ssh/authorized_keys"
|
||||
assert target.read_text() == key + "\n"
|
||||
|
||||
|
||||
def test_account_database_write_preserves_mode_owner_and_xattrs(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
|
||||
@ -106,6 +106,66 @@ def test_read_regular_rejects_directory_oversize_and_changed_read(
|
||||
module.read_regular(source, 10)
|
||||
|
||||
|
||||
def test_read_mounted_secret_follows_csi_projection_inside_mount(tmp_path):
|
||||
module = _io_module()
|
||||
mount = tmp_path / "vault-secrets"
|
||||
data = mount / "..2026_08_18.1"
|
||||
data.mkdir(parents=True)
|
||||
(data / "node-ssh-public-key").write_bytes(b"ssh-ed25519 synthetic\n")
|
||||
(mount / "..data").symlink_to(data.name)
|
||||
(mount / "node-ssh-public-key").symlink_to(Path("..data") / "node-ssh-public-key")
|
||||
snapshot = module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
|
||||
assert snapshot.value == b"ssh-ed25519 synthetic\n"
|
||||
|
||||
|
||||
def test_read_mounted_secret_reads_plain_regular_file_unchanged(tmp_path):
|
||||
module = _io_module()
|
||||
mount = tmp_path / "vault-secrets"
|
||||
mount.mkdir()
|
||||
secret = mount / "node-ssh-public-key"
|
||||
secret.write_bytes(b"ssh-ed25519 synthetic\n")
|
||||
snapshot = module.read_mounted_secret(secret, mount, 64)
|
||||
assert snapshot == module.read_regular(secret, 64)
|
||||
|
||||
|
||||
def test_read_mounted_secret_refuses_escape_from_mount(tmp_path):
|
||||
module = _io_module()
|
||||
mount = tmp_path / "vault-secrets"
|
||||
mount.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_bytes(b"planted\n")
|
||||
(mount / "node-ssh-public-key").symlink_to(outside)
|
||||
with pytest.raises(module.HardeningError, match="escapes its mount"):
|
||||
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
|
||||
|
||||
|
||||
def test_read_mounted_secret_refuses_symlink_loop(tmp_path):
|
||||
module = _io_module()
|
||||
mount = tmp_path / "vault-secrets"
|
||||
mount.mkdir()
|
||||
(mount / "node-ssh-public-key").symlink_to("node-ssh-public-key")
|
||||
with pytest.raises(module.HardeningError, match="unresolvable mounted secret"):
|
||||
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
|
||||
|
||||
|
||||
def test_read_mounted_secret_reports_missing_secret_as_not_found(tmp_path):
|
||||
module = _io_module()
|
||||
mount = tmp_path / "vault-secrets"
|
||||
mount.mkdir()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
|
||||
|
||||
|
||||
def test_read_mounted_secret_refuses_non_regular_resolved_target(tmp_path):
|
||||
module = _io_module()
|
||||
mount = tmp_path / "vault-secrets"
|
||||
data = mount / "..data"
|
||||
data.mkdir(parents=True)
|
||||
(mount / "node-ssh-public-key").symlink_to("..data")
|
||||
with pytest.raises(module.HardeningError, match="unsafe regular"):
|
||||
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
|
||||
|
||||
|
||||
def test_assert_unchanged_rejects_changed_snapshot(tmp_path):
|
||||
module = _io_module()
|
||||
source = tmp_path / "passwd"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user