fix(hermes): harden release reconciliation
This commit is contained in:
parent
708d611101
commit
636f3fcf93
@ -18,6 +18,18 @@ spec:
|
|||||||
fsGroupChangePolicy: OnRootMismatch
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
kubernetes.io/arch: arm64
|
kubernetes.io/arch: arm64
|
||||||
|
tolerations:
|
||||||
|
# Jetson kubelet/network maintenance can briefly outlast Kubernetes' five
|
||||||
|
# minute default. Keep the disposable build workspace intact long enough
|
||||||
|
# for the node to recover instead of restarting a large image expansion.
|
||||||
|
- key: node.kubernetes.io/not-ready
|
||||||
|
operator: Exists
|
||||||
|
effect: NoExecute
|
||||||
|
tolerationSeconds: 600
|
||||||
|
- key: node.kubernetes.io/unreachable
|
||||||
|
operator: Exists
|
||||||
|
effect: NoExecute
|
||||||
|
tolerationSeconds: 600
|
||||||
affinity:
|
affinity:
|
||||||
nodeAffinity:
|
nodeAffinity:
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from node_account_io import (
|
|||||||
assert_unchanged,
|
assert_unchanged,
|
||||||
atomic_write,
|
atomic_write,
|
||||||
backup_once,
|
backup_once,
|
||||||
|
open_private_temporary,
|
||||||
read_mounted_secret,
|
read_mounted_secret,
|
||||||
read_regular,
|
read_regular,
|
||||||
)
|
)
|
||||||
@ -247,7 +248,15 @@ def _directory(path: Path, *, mode: int, uid: int, gid: int) -> None:
|
|||||||
def _without_key(value: bytes, identity: tuple[str, bytes]) -> bytes:
|
def _without_key(value: bytes, identity: tuple[str, bytes]) -> bytes:
|
||||||
kept = []
|
kept = []
|
||||||
for line in value.splitlines(keepends=True):
|
for line in value.splitlines(keepends=True):
|
||||||
if _key_identity(line) != identity:
|
try:
|
||||||
|
line_identity = _key_identity(line)
|
||||||
|
except HardeningError:
|
||||||
|
# authorized_keys is shared with human operators on the legacy
|
||||||
|
# accounts. An unrelated line that this reconciler cannot prove
|
||||||
|
# valid is not authority to delete it or block all reconciliation.
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
if line_identity != identity:
|
||||||
kept.append(line)
|
kept.append(line)
|
||||||
return b"".join(kept)
|
return b"".join(kept)
|
||||||
|
|
||||||
@ -408,9 +417,9 @@ def _acl_backup_once(path: Path, value: bytes, backup_name: str | None = None) -
|
|||||||
_validate_acl_backup(existing)
|
_validate_acl_backup(existing)
|
||||||
return
|
return
|
||||||
|
|
||||||
temporary = root / f".{path.name}.acl.hermes-{os.getpid()}"
|
temporary, descriptor = open_private_temporary(
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
root / f"{path.name}.acl", 0o600
|
||||||
descriptor = os.open(temporary, flags, 0o600)
|
)
|
||||||
try:
|
try:
|
||||||
if os.write(descriptor, encoded) != len(encoded):
|
if os.write(descriptor, encoded) != len(encoded):
|
||||||
raise HardeningError("short sensitive directory ACL backup write")
|
raise HardeningError("short sensitive directory ACL backup write")
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@ -17,6 +18,7 @@ MAX_XATTR_NAME = 255
|
|||||||
MAX_XATTR_VALUE = 256 * 1024
|
MAX_XATTR_VALUE = 256 * 1024
|
||||||
MAX_XATTR_TOTAL = 1024 * 1024
|
MAX_XATTR_TOTAL = 1024 * 1024
|
||||||
LOCK_TIMEOUT_SECONDS = 15.0
|
LOCK_TIMEOUT_SECONDS = 15.0
|
||||||
|
TEMPORARY_CREATE_ATTEMPTS = 8
|
||||||
|
|
||||||
|
|
||||||
class HardeningError(RuntimeError):
|
class HardeningError(RuntimeError):
|
||||||
@ -141,11 +143,29 @@ def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None:
|
|||||||
raise HardeningError(f"concurrent host account change detected: {path.name}")
|
raise HardeningError(f"concurrent host account change detected: {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def open_private_temporary(path: Path, mode: int) -> tuple[Path, int]:
|
||||||
|
"""Create an unpredictable peer temporary without trusting stale files.
|
||||||
|
|
||||||
|
Reconciler containers commonly reuse the same low PID after a restart. A
|
||||||
|
PID-only name can therefore collide forever with a file left by a killed
|
||||||
|
prior run. The exclusive random suffix keeps old artifacts inert while
|
||||||
|
preserving the same-directory atomic-replace boundary.
|
||||||
|
"""
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
for _attempt in range(TEMPORARY_CREATE_ATTEMPTS):
|
||||||
|
temporary = path.with_name(
|
||||||
|
f".{path.name}.hermes-{os.getpid()}-{secrets.token_hex(16)}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return temporary, os.open(temporary, flags, mode)
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
raise HardeningError(f"could not allocate private temporary: {path.name}")
|
||||||
|
|
||||||
|
|
||||||
def atomic_write(path: Path, value: bytes, metadata: FileSnapshot) -> None:
|
def atomic_write(path: Path, value: bytes, metadata: FileSnapshot) -> None:
|
||||||
"""Atomically replace a file while retaining ACLs, labels, and xattrs."""
|
"""Atomically replace a file while retaining ACLs, labels, and xattrs."""
|
||||||
temporary = path.with_name(f".{path.name}.hermes-{os.getpid()}")
|
temporary, descriptor = open_private_temporary(path, metadata.mode)
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
||||||
descriptor = os.open(temporary, flags, metadata.mode)
|
|
||||||
try:
|
try:
|
||||||
if os.write(descriptor, value) != len(value):
|
if os.write(descriptor, value) != len(value):
|
||||||
raise HardeningError(f"short atomic write: {path.name}")
|
raise HardeningError(f"short atomic write: {path.name}")
|
||||||
|
|||||||
@ -54,6 +54,22 @@ def test_unrelated_modern_human_key_type_is_preserved(tmp_path: Path, monkeypatc
|
|||||||
assert path.read_text() == modern + "\n"
|
assert path.read_text() == modern + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_unrelated_authorized_key_lines_are_preserved(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
module, _originals, key, _other, public_key = _fixture(tmp_path, monkeypatch)
|
||||||
|
malformed = b"ssh-ed25519 YWJj human-key-that-must-survive\noperator-note-\xff\n"
|
||||||
|
for user in module.LEGACY_ACCOUNTS:
|
||||||
|
path = module.HOST_HOME / user / ".ssh/authorized_keys"
|
||||||
|
path.write_bytes(malformed + key.encode("ascii") + b"\n")
|
||||||
|
|
||||||
|
module.reconcile(public_key)
|
||||||
|
|
||||||
|
for user in module.LEGACY_ACCOUNTS:
|
||||||
|
path = module.HOST_HOME / user / ".ssh/authorized_keys"
|
||||||
|
assert path.read_bytes() == malformed
|
||||||
|
|
||||||
|
|
||||||
def test_crash_during_legacy_key_removal_never_installs_duplicate_key(
|
def test_crash_during_legacy_key_removal_never_installs_duplicate_key(
|
||||||
tmp_path: Path, monkeypatch
|
tmp_path: Path, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
@ -188,6 +188,50 @@ def test_atomic_write_short_write_unlinks_temporary(tmp_path, monkeypatch):
|
|||||||
assert not list(tmp_path.glob(".passwd.hermes-*"))
|
assert not list(tmp_path.glob(".passwd.hermes-*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_ignores_stale_pid_temporary(tmp_path):
|
||||||
|
module = _io_module()
|
||||||
|
target = tmp_path / "passwd"
|
||||||
|
target.write_bytes(b"old")
|
||||||
|
snapshot = module.read_regular(target, 16)
|
||||||
|
stale = tmp_path / f".passwd.hermes-{os.getpid()}"
|
||||||
|
stale.write_bytes(b"interrupted prior run")
|
||||||
|
|
||||||
|
module.atomic_write(target, b"new", snapshot)
|
||||||
|
|
||||||
|
assert target.read_bytes() == b"new"
|
||||||
|
assert stale.read_bytes() == b"interrupted prior run"
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_temporary_retries_untrusted_name_collisions(tmp_path, monkeypatch):
|
||||||
|
module = _io_module()
|
||||||
|
target = tmp_path / "passwd"
|
||||||
|
tokens = iter(("collision", "unused"))
|
||||||
|
stale = tmp_path / f".passwd.hermes-{os.getpid()}-collision"
|
||||||
|
stale.write_bytes(b"untrusted")
|
||||||
|
monkeypatch.setattr(module.secrets, "token_hex", lambda _size: next(tokens))
|
||||||
|
|
||||||
|
temporary, descriptor = module.open_private_temporary(target, 0o600)
|
||||||
|
os.close(descriptor)
|
||||||
|
try:
|
||||||
|
assert temporary.name.endswith("-unused")
|
||||||
|
assert stale.read_bytes() == b"untrusted"
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_temporary_fails_closed_after_collision_limit(tmp_path, monkeypatch):
|
||||||
|
module = _io_module()
|
||||||
|
target = tmp_path / "passwd"
|
||||||
|
stale = tmp_path / f".passwd.hermes-{os.getpid()}-collision"
|
||||||
|
stale.write_bytes(b"untrusted")
|
||||||
|
monkeypatch.setattr(module.secrets, "token_hex", lambda _size: "collision")
|
||||||
|
|
||||||
|
with pytest.raises(module.HardeningError, match="allocate private temporary"):
|
||||||
|
module.open_private_temporary(target, 0o600)
|
||||||
|
|
||||||
|
assert stale.read_bytes() == b"untrusted"
|
||||||
|
|
||||||
|
|
||||||
def test_backup_existing_is_validated_and_short_write_is_removed(tmp_path, monkeypatch):
|
def test_backup_existing_is_validated_and_short_write_is_removed(tmp_path, monkeypatch):
|
||||||
module = _io_module()
|
module = _io_module()
|
||||||
source = tmp_path / "passwd"
|
source = tmp_path / "passwd"
|
||||||
|
|||||||
@ -22,6 +22,9 @@ def test_voice_pipeline_binds_component_source_digest_and_release() -> None:
|
|||||||
assert "archiveArtifacts(" in pipeline
|
assert "archiveArtifacts(" in pipeline
|
||||||
assert '"SETFCAP"' in pipeline
|
assert '"SETFCAP"' in pipeline
|
||||||
assert "values: [titan-20]" in pipeline
|
assert "values: [titan-20]" in pipeline
|
||||||
|
assert "key: node.kubernetes.io/not-ready" in pipeline
|
||||||
|
assert "key: node.kubernetes.io/unreachable" in pipeline
|
||||||
|
assert pipeline.count("tolerationSeconds: 600") == 2
|
||||||
assert 'node-role.kubernetes.io/worker: "true"' not in pipeline
|
assert 'node-role.kubernetes.io/worker: "true"' not in pipeline
|
||||||
assert "ephemeral-storage: 10Gi" in pipeline
|
assert "ephemeral-storage: 10Gi" in pipeline
|
||||||
assert "ephemeral-storage: 20Gi" in pipeline
|
assert "ephemeral-storage: 20Gi" in pipeline
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user