atlas-iac/testing/tests/test_hermes_image_builder_adversarial.py

470 lines
16 KiB
Python

"""Adversarial boundaries for the Hermes image publisher and trigger."""
from __future__ import annotations
import importlib.util
import io
import json
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
RELEASE_SCRIPT = REPO_ROOT / "ci/scripts/hermes_image_release.py"
TRIGGER_SCRIPT = REPO_ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py"
def _load(path: Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Response(io.BytesIO):
"""Small context-managed HTTP response fixture."""
def __init__(
self,
status: int,
headers: dict[str, str] | None = None,
body: bytes = b"",
):
super().__init__(body)
self.status = status
self.headers = headers or {}
def test_kaniko_evidence_binds_digest_destination_and_unique_build() -> None:
"""Both Kaniko artifacts must describe one exact non-replayable tag."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_evidence")
revision = "a" * 40
digest = "sha256:" + "b" * 64
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-42"
assert module.validate_destination(destination, revision, "42") == (
revision,
"42",
)
assert (
module.validate_kaniko_evidence(
digest_text=f"{digest}\n",
image_text=f"{destination}@{digest}\n",
destination=destination,
)
== digest
)
@pytest.mark.parametrize(
("digest_text", "image_template"),
[
("sha256:" + "a" * 64 + "\nextra\n", "{destination}@{digest}"),
("sha256:" + "a" * 64, "{destination}@sha256:" + "b" * 64),
("sha256:" + "a" * 64, "registry.invalid/x:y@{digest}"),
("sha256:" + "a" * 64, "{destination}@{digest}\nextra"),
],
)
def test_kaniko_evidence_rejects_cross_artifact_mismatch(
digest_text: str, image_template: str
) -> None:
"""A digest, tag, repository, or cardinality mismatch stops rendering."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_mismatch")
revision = "c" * 40
digest = "sha256:" + "a" * 64
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-8"
image_text = image_template.format(destination=destination, digest=digest)
with pytest.raises(ValueError):
module.validate_kaniko_evidence(
digest_text=digest_text,
image_text=image_text,
destination=destination,
)
def test_registry_preflight_rejects_existing_tag_and_auth_failures() -> None:
"""Only an authenticated 404 permits Kaniko to claim the unique tag."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_preflight")
destination = f"{module.DEFAULT_IMAGE}:git-{'d' * 40}-build-3"
captured = {}
def missing(request, timeout):
captured["request"] = request
captured["timeout"] = timeout
return Response(404)
module.assert_tag_absent(
destination, username="robot", password="private", opener=missing
)
request = captured["request"]
assert request.method == "GET"
assert request.full_url.startswith(
"https://registry.bstein.dev/api/v2.0/projects/bstein/repositories/"
"hermes-agent/artifacts/"
)
assert "private" not in request.full_url
assert captured["timeout"] == 20
for status, message in (
(200, "already exists"),
(401, "HTTP 401"),
(503, "HTTP 503"),
):
with pytest.raises(RuntimeError, match=message):
module.assert_tag_absent(
destination,
username="robot",
password="private",
opener=lambda *_args, code=status: Response(code),
)
def test_registry_preflight_requires_exact_server_immutable_policy() -> None:
"""The already-running controller cannot publish before Flux installs the rule."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_policy_preflight")
expected = {
"disabled": False,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "git-*-build-*",
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "hermes-agent",
}
]
},
}
captured = {}
def exact(request, timeout):
captured["url"] = request.full_url
captured["timeout"] = timeout
return Response(
200,
{"X-Total-Count": "1"},
body=json.dumps([{"id": 9, **expected}]).encode(),
)
module.verify_immutable_policy(username="robot", password="private", opener=exact)
assert captured["url"].endswith(
"/projects/bstein/immutabletagrules?page=1&page_size=100"
)
assert captured["timeout"] == 20
for status, body, headers, message in (
(403, b"", {}, "HTTP 403"),
(200, b"[]", {"X-Total-Count": "0"}, "absent"),
(
200,
json.dumps([{**expected, "disabled": True}]).encode(),
{"X-Total-Count": "1"},
"not exact",
),
(200, b"not-json", {}, "invalid"),
(
200,
json.dumps([expected]).encode(),
{"X-Total-Count": "2"},
"truncated",
),
):
with pytest.raises(RuntimeError, match=message):
module.verify_immutable_policy(
username="robot",
password="private",
opener=lambda *_args, code=status, value=body, evidence=headers: Response(
code, evidence, body=value
),
)
def test_harbor_client_fails_closed_on_input_size_auth_and_json(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Malformed inputs, oversized bodies, missing auth, and bad JSON all fail."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_harbor_errors")
destination = f"{module.DEFAULT_IMAGE}:git-{'1' * 40}-build-6"
with pytest.raises(ValueError, match="destination"):
module.assert_tag_absent(
"registry.invalid/x:tag", username="robot", password="private"
)
with pytest.raises(RuntimeError, match="credentials"):
module.assert_tag_absent(destination, username="", password="private")
with pytest.raises(RuntimeError, match="size limit"):
module.assert_tag_absent(
destination,
username="robot",
password="private",
opener=lambda *_args: Response(200, body=b"x" * 1_048_577),
)
with pytest.raises(RuntimeError, match="invalid artifact JSON"):
module.verify_registry_digest(
destination,
"sha256:" + "2" * 64,
username="robot",
password="private",
opener=lambda *_args: Response(200, body=b"\xff"),
)
monkeypatch.delenv("HARBOR_USER", raising=False)
monkeypatch.delenv("HARBOR_PASSWORD", raising=False)
with pytest.raises(RuntimeError, match="unavailable"):
module._credentials()
def test_default_harbor_opener_returns_http_responses(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The real wrapper returns both normal and non-redirect HTTP responses."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_opener")
request = module.urllib.request.Request("https://registry.bstein.dev/test")
class SuccessOpener:
def open(self, _request, timeout):
assert timeout == 7
return Response(204)
monkeypatch.setattr(
module.urllib.request, "build_opener", lambda *_handlers: SuccessOpener()
)
assert module._registry_request(request, 7).status == 204
class ErrorOpener:
def open(self, _request, timeout):
assert timeout == 8
raise module.urllib.error.HTTPError(
_request.full_url, 404, "missing", {}, None
)
monkeypatch.setattr(
module.urllib.request, "build_opener", lambda *_handlers: ErrorOpener()
)
assert module._registry_request(request, 8).code == 404
@pytest.mark.parametrize(
("status", "artifact", "message"),
[
(404, {}, "HTTP 404"),
(200, {}, "omitted"),
(200, {"digest": "sha256:" + "2" * 64}, "does not match"),
(200, {"digest": "latest"}, "omitted"),
],
)
def test_registry_verification_rejects_missing_or_mismatched_digest(
status: int, artifact: dict[str, str], message: str
) -> None:
"""Rendering requires an independent exact Harbor digest response."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_registry")
digest = "sha256:" + "1" * 64
destination = f"{module.DEFAULT_IMAGE}:git-{'e' * 40}-build-4"
body = json.dumps(artifact).encode("utf-8")
with pytest.raises(RuntimeError, match=message):
module.verify_registry_digest(
destination,
digest,
username="robot",
password="private",
opener=lambda *_args: Response(status, body=body),
)
def test_registry_verification_accepts_exact_pushed_digest() -> None:
"""An exact authenticated Harbor digest allows artifact rendering."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_registry_ok")
digest = "sha256:" + "4" * 64
destination = f"{module.DEFAULT_IMAGE}:git-{'f' * 40}-build-5"
with pytest.raises(RuntimeError, match="expected tag"):
module.verify_registry_digest(
destination,
digest,
username="robot",
password="private",
opener=lambda *_args: Response(
200,
body=json.dumps(
{"digest": digest, "tags": [{"name": "different-tag"}]}
).encode("utf-8"),
),
)
module.verify_registry_digest(
destination,
digest,
username="robot",
password="private",
opener=lambda *_args: Response(
200,
body=json.dumps(
{
"digest": digest,
"tags": [
{
"name": destination.rsplit(":", 1)[1],
"immutable": True,
}
],
}
).encode("utf-8"),
),
)
def test_registry_verification_rejects_tag_without_server_immutability() -> None:
"""Digest evidence is insufficient unless Harbor reports the build tag immutable."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_registry_mutable")
digest = "sha256:" + "7" * 64
destination = f"{module.DEFAULT_IMAGE}:git-{'8' * 40}-build-19"
captured = {}
def opener(request, _timeout):
captured["url"] = request.full_url
return Response(
200,
body=json.dumps(
{
"digest": digest,
"tags": [
{
"name": destination.rsplit(":", 1)[1],
"immutable": False,
}
],
}
).encode(),
)
with pytest.raises(RuntimeError, match="immutable"):
module.verify_registry_digest(
destination,
digest,
username="robot",
password="private",
opener=opener,
)
assert captured["url"].endswith("?with_immutable_status=true")
def test_release_main_preflight_uses_fixed_credentials_and_destination(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The preflight CLI validates and checks exactly the requested build tag."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_main_absent")
revision = "3" * 40
destination = f"{module.DEFAULT_IMAGE}:git-{revision}-build-12"
captured = {}
monkeypatch.setenv("HARBOR_USER", "robot")
monkeypatch.setenv("HARBOR_PASSWORD", "private")
def assert_absent(value, **credentials):
captured["destination"] = value
captured["credentials"] = credentials
def verify_policy(**credentials):
captured["policy_credentials"] = credentials
monkeypatch.setattr(module, "assert_tag_absent", assert_absent)
monkeypatch.setattr(module, "verify_immutable_policy", verify_policy)
monkeypatch.setattr(
"sys.argv",
[
"hermes_image_release.py",
"assert-absent",
"--source-revision",
revision,
"--build-number",
"12",
"--destination",
destination,
],
)
assert module.main() == 0
assert captured == {
"destination": destination,
"credentials": {"username": "robot", "password": "private"},
"policy_credentials": {"username": "robot", "password": "private"},
}
def test_renderer_skips_a_matching_entry_without_a_digest() -> None:
"""Only the one complete image entry is changed when an earlier entry drifts."""
module = _load(RELEASE_SCRIPT, "hermes_image_release_manifest_scan")
digest = "sha256:" + "5" * 64
source = (
"images:\n"
f" - name: {module.DEFAULT_IMAGE}\n"
" newTag: ignored\n"
" - name: example.invalid/other\n"
" newTag: stable\n"
f" - name: {module.DEFAULT_IMAGE}\n"
" digest: sha256:" + "0" * 64 + "\n"
)
assert module.render_kustomization(source, digest).endswith(f"{digest}\n")
@pytest.mark.parametrize("status", [200, 202, 204, 301, 302, 307, 308])
def test_trigger_rejects_non_plugin_success_status(tmp_path: Path, status: int) -> None:
"""Generic proxy successes and redirects are not proof of a queued build."""
module = _load(TRIGGER_SCRIPT, f"jenkins_trigger_status_{status}")
token = tmp_path / "token"
token.write_text("private\n", encoding="utf-8")
response = lambda *_args, **_kwargs: Response( # noqa: E731
status, {"Location": "https://ci.bstein.dev/queue/item/9/"}
)
with pytest.raises(RuntimeError, match=f"HTTP {status}"):
module.trigger_build("a" * 40, token_file=token, opener=response)
@pytest.mark.parametrize(
"location",
[
"",
"https://evil.invalid/queue/item/9/",
"http://ci.bstein.dev/queue/item/9/",
"https://ci.bstein.dev/job/hermes-agent-image/9/",
"https://ci.bstein.dev/queue/item/9/?token=leak",
"https://ci.bstein.dev/queue/item/9/#fragment",
"https://ci.bstein.dev/queue/item/not-a-number/",
],
)
def test_trigger_rejects_missing_malformed_or_cross_origin_queue_location(
tmp_path: Path, location: str
) -> None:
"""A real accepted status still needs the exact same-origin queue resource."""
module = _load(TRIGGER_SCRIPT, "jenkins_trigger_location")
token = tmp_path / "token"
token.write_text("private\n", encoding="utf-8")
response = lambda *_args, **_kwargs: Response( # noqa: E731
201, {"Location": location}
)
with pytest.raises(RuntimeError, match="Location"):
module.trigger_build("b" * 40, token_file=token, opener=response)
def test_trigger_uses_https_and_accepts_exact_relative_queue_path(
tmp_path: Path,
) -> None:
"""A relative plugin Location is resolved only against the fixed HTTPS origin."""
module = _load(TRIGGER_SCRIPT, "jenkins_trigger_origin")
token = tmp_path / "token"
token.write_text("private\n", encoding="utf-8")
captured = {}
def opener(request, timeout):
captured["url"] = request.full_url
captured["timeout"] = timeout
return Response(201, {"Location": "/queue/item/11/"})
result = module.trigger_build("c" * 40, token_file=token, opener=opener)
assert captured["url"].startswith("https://ci.bstein.dev/")
assert captured["timeout"] == 20
assert result["queue_path"] == "/queue/item/11/"