CI / Tests, lint (push) Successful in 9s
netork's release joins an arm64 image to the amd64 one its dev build already signed and attested, under a new index. ATTEST_PLATFORMS (the sign action's attest-platforms) signs the new index and every platform behind it, and attests only the ones named.
253 lines
9.6 KiB
Python
253 lines
9.6 KiB
Python
"""Signing and checking netOrk's images with cosign (CIS Docker 4.5, NetOrk/netork#1390).
|
|
|
|
Docker Content Trust is deprecated; like Docker's Hardened Images, every image
|
|
of NetOrk is signed with cosign instead and carries its CIS result and SBOM as
|
|
attestations. The registry's names stay inside: nothing goes to a public
|
|
transparency log. Each link of the chain checks the signature of the image it
|
|
builds on before it builds.
|
|
|
|
The scripts run here against a fake ``cosign`` and ``docker`` that record what
|
|
they are asked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SIGN = ROOT / "bin/sign.sh"
|
|
VERIFY = ROOT / "bin/verify-signature.sh"
|
|
VERIFY_BASE = ROOT / "bin/verify-base.sh"
|
|
INSTALL = ROOT / "bin/install-cosign.sh"
|
|
|
|
IMAGE = "registry.netork.io/netork/engine"
|
|
AMD = "sha256:" + "a" * 64
|
|
ARM = "sha256:" + "b" * 64
|
|
INDEX = "sha256:" + "e" * 64
|
|
|
|
_FAKE_COSIGN = """#!/bin/sh
|
|
echo "cosign $*" >> "$LOG"
|
|
case "$*" in *"{fail}"*) echo "no matching signatures" >&2; exit 1 ;; esac
|
|
exit 0
|
|
"""
|
|
_FAKE_DOCKER = """#!/bin/sh
|
|
case "$*" in
|
|
*--raw*) cat "$RAW" ;;
|
|
*"{{json .Image}}"*) cat "$CONFIG" ;;
|
|
*) echo "docker $*" >> "$LOG" ;;
|
|
esac
|
|
"""
|
|
|
|
|
|
def _bin(tmp_path: Path, fail: str = "@@never@@") -> dict[str, str]:
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
(bin_dir / "cosign").write_text(_FAKE_COSIGN.replace("{fail}", fail))
|
|
(bin_dir / "docker").write_text(_FAKE_DOCKER)
|
|
for tool in bin_dir.iterdir():
|
|
tool.chmod(0o755)
|
|
return {"PATH": f"{bin_dir}:{os.environ['PATH']}", "LOG": str(tmp_path / "log")}
|
|
|
|
|
|
def _log(tmp_path: Path) -> list[str]:
|
|
log = tmp_path / "log"
|
|
return log.read_text().splitlines() if log.exists() else []
|
|
|
|
|
|
def _run(script: Path, *args: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["bash", str(script), *args], env=env, capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
|
|
class TestSign:
|
|
def _attest_dir(self, tmp_path: Path, *arches: str) -> Path:
|
|
out = tmp_path / "attest"
|
|
out.mkdir()
|
|
for arch in arches:
|
|
(out / f"cis-{arch}.json").write_text("{}")
|
|
(out / f"sbom-{arch}.cdx.json").write_text("{}")
|
|
return out
|
|
|
|
def _env(self, tmp_path: Path, raw: dict, config: dict, **extra: str) -> dict[str, str]:
|
|
(tmp_path / "raw.json").write_text(json.dumps(raw))
|
|
(tmp_path / "config.json").write_text(json.dumps(config))
|
|
env = _bin(tmp_path)
|
|
env.update(
|
|
RAW=str(tmp_path / "raw.json"),
|
|
CONFIG=str(tmp_path / "config.json"),
|
|
COSIGN_PRIVATE_KEY_B64="a2V5Cg==",
|
|
COSIGN_PASSWORD="pw",
|
|
**extra,
|
|
)
|
|
return env
|
|
|
|
def test_a_single_manifest_is_signed_and_attested(self, tmp_path):
|
|
"""A netork dev image: one amd64 manifest, no index."""
|
|
env = self._env(tmp_path, {"mediaType": "x", "layers": []}, {"architecture": "amd64"})
|
|
attest = self._attest_dir(tmp_path, "amd64")
|
|
|
|
result = _run(SIGN, f"{IMAGE}:main-abc@{AMD}", str(attest), env=env)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
log = _log(tmp_path)
|
|
assert any("sign --yes --recursive" in ln and f"@{AMD}" in ln for ln in log)
|
|
attests = [ln for ln in log if " attest " in ln]
|
|
assert len(attests) == 2 and all(f"{IMAGE}@{AMD}" in ln for ln in attests)
|
|
assert f"signed and attested: {IMAGE}@{AMD} (amd64)" in result.stdout
|
|
|
|
def test_an_index_attests_every_platform(self, tmp_path):
|
|
raw = {
|
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
"manifests": [
|
|
{"digest": AMD, "platform": {"os": "linux", "architecture": "amd64"}},
|
|
{"digest": ARM, "platform": {"os": "linux", "architecture": "arm64"}},
|
|
],
|
|
}
|
|
env = self._env(tmp_path, raw, {}, EXPECT_PLATFORMS="amd64 arm64")
|
|
attest = self._attest_dir(tmp_path, "amd64", "arm64")
|
|
|
|
result = _run(SIGN, f"{IMAGE}:0.41.0@{INDEX}", str(attest), env=env)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
verified = [ln for ln in _log(tmp_path) if "verify-attestation" in ln]
|
|
assert sum(f"@{AMD}" in ln for ln in verified) == 2
|
|
assert sum(f"@{ARM}" in ln for ln in verified) == 2
|
|
|
|
def test_a_release_index_attests_only_the_platform_it_added(self, tmp_path):
|
|
"""netork's release joins arm64 to the amd64 image its dev build already
|
|
signed and attested: the new index is signed throughout, and only arm64
|
|
needs its CIS result and SBOM (ATTEST_PLATFORMS)."""
|
|
raw = {
|
|
"mediaType": "application/vnd.oci.image.index.v1+json",
|
|
"manifests": [
|
|
{"digest": AMD, "platform": {"os": "linux", "architecture": "amd64"}},
|
|
{"digest": ARM, "platform": {"os": "linux", "architecture": "arm64"}},
|
|
],
|
|
}
|
|
env = self._env(tmp_path, raw, {}, ATTEST_PLATFORMS="arm64")
|
|
attest = self._attest_dir(tmp_path, "arm64")
|
|
|
|
result = _run(SIGN, f"{IMAGE}:0.41.0@{INDEX}", str(attest), env=env)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
log = _log(tmp_path)
|
|
assert any("sign --yes --recursive" in ln and f"@{INDEX}" in ln for ln in log)
|
|
attests = [ln for ln in log if " attest " in ln]
|
|
assert len(attests) == 2 and all(f"@{ARM}" in ln for ln in attests)
|
|
assert f"signed and attested: {IMAGE}@{AMD}" not in result.stdout
|
|
assert f"signed: {IMAGE}@{AMD} (amd64)" in result.stdout
|
|
|
|
def test_a_missing_expected_platform_stops_it(self, tmp_path):
|
|
env = self._env(
|
|
tmp_path, {"mediaType": "x"}, {"architecture": "amd64"}, EXPECT_PLATFORMS="amd64 arm64"
|
|
)
|
|
|
|
result = _run(SIGN, f"{IMAGE}@{AMD}", str(self._attest_dir(tmp_path, "amd64")), env=env)
|
|
|
|
assert result.returncode != 0
|
|
assert "expected amd64 arm64" in result.stderr
|
|
assert not any(" attest " in ln for ln in _log(tmp_path))
|
|
|
|
def test_a_reference_without_a_digest_is_refused(self, tmp_path):
|
|
env = self._env(tmp_path, {}, {})
|
|
|
|
result = _run(SIGN, f"{IMAGE}:latest", str(self._attest_dir(tmp_path)), env=env)
|
|
|
|
assert result.returncode != 0 and "not pinned by digest" in result.stderr
|
|
|
|
def test_without_the_secrets_it_says_so(self, tmp_path):
|
|
env = _bin(tmp_path)
|
|
|
|
result = _run(SIGN, f"{IMAGE}@{AMD}", str(tmp_path), env=env)
|
|
|
|
assert result.returncode != 0 and "secret is missing" in result.stderr
|
|
|
|
|
|
class TestVerify:
|
|
BASE = f"registry.netork.io/netork/base-python:3.12@{AMD}"
|
|
OS = f"registry.netork.io/netork/base-os:13@{ARM}"
|
|
|
|
def _dockerfile(self, tmp_path: Path) -> Path:
|
|
path = tmp_path / "Dockerfile"
|
|
path.write_text(
|
|
f"FROM {self.BASE} AS builder\nFROM builder AS drivers\nFROM {self.OS} AS runtime\n"
|
|
)
|
|
return path
|
|
|
|
def test_every_base_of_the_dockerfile_is_verified_against_the_public_key(self, tmp_path):
|
|
result = _run(VERIFY_BASE, str(self._dockerfile(tmp_path)), env=_bin(tmp_path))
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
log = _log(tmp_path)
|
|
assert log == [
|
|
f"cosign verify --key cosign.pub --insecure-ignore-tlog {self.BASE}",
|
|
f"cosign verify --key cosign.pub --insecure-ignore-tlog {self.OS}",
|
|
]
|
|
|
|
def test_an_unsigned_base_stops_the_build(self, tmp_path):
|
|
env = _bin(tmp_path, fail="base-os")
|
|
|
|
result = _run(VERIFY_BASE, str(self._dockerfile(tmp_path)), env=env)
|
|
|
|
assert result.returncode != 0
|
|
assert f"not signed with netOrk's key: {self.OS}" in result.stderr
|
|
|
|
def test_a_base_without_a_digest_stops_the_build(self, tmp_path):
|
|
path = tmp_path / "Dockerfile"
|
|
path.write_text("FROM registry.netork.io/netork/base-os:pinned\n")
|
|
|
|
result = _run(VERIFY_BASE, str(path), env=_bin(tmp_path))
|
|
|
|
assert result.returncode != 0 and "not pinned by digest" in result.stderr
|
|
assert _log(tmp_path) == []
|
|
|
|
def test_a_reference_without_a_digest_is_never_checked(self, tmp_path):
|
|
result = _run(VERIFY, "registry.netork.io/netork/base-os:pinned", env=_bin(tmp_path))
|
|
|
|
assert result.returncode != 0 and _log(tmp_path) == []
|
|
|
|
|
|
def test_nothing_goes_to_a_public_transparency_log():
|
|
config = json.loads((ROOT / "cosign-signing.json").read_text())
|
|
text = SIGN.read_text()
|
|
|
|
for services in ("rekorTlogUrls", "caUrls", "oidcUrls", "tsaUrls"):
|
|
assert config[services] == []
|
|
signing = [
|
|
ln
|
|
for ln in text.replace("\\\n", " ").splitlines()
|
|
if re.search(r"cosign (sign|attest) ", ln)
|
|
]
|
|
assert len(signing) == 2
|
|
assert all("--signing-config cosign-signing.json" in ln for ln in signing)
|
|
|
|
|
|
def test_the_public_key_is_in_the_repository():
|
|
"""Whoever pulls an image of NetOrk verifies against it; the private half is
|
|
the COSIGN_PRIVATE_KEY_B64 secret."""
|
|
assert (ROOT / "cosign.pub").read_text().startswith("-----BEGIN PUBLIC KEY-----")
|
|
|
|
|
|
def test_the_private_key_arrives_as_one_line():
|
|
"""The runner masks a secret only where it stands whole; the PEM key's lines
|
|
did not match, and the key stood in a log (2026-10-22)."""
|
|
text = SIGN.read_text()
|
|
|
|
assert '"${COSIGN_PRIVATE_KEY_B64:?' in text and "| base64 -d)" in text
|
|
|
|
|
|
@pytest.mark.parametrize("arch", ["AMD64", "ARM64"])
|
|
def test_cosign_is_pinned_and_checked_for_both_runners(arch):
|
|
text = INSTALL.read_text()
|
|
|
|
assert re.search(r"^VERSION=v\d+\.\d+\.\d+$", text, re.MULTILINE)
|
|
assert re.search(rf"^SHA256_{arch}=[0-9a-f]{{64}}$", text, re.MULTILINE)
|
|
assert "sha256sum -c -" in text
|