Files
image-tools/tests/test_signing.py
T
Christian Manivong d139d66c3d
CI / Tests, lint (push) Successful in 18s
feat: judge, sign and verify netOrk's images in one place (NetOrk/netork#1390)
The CIS gate, the SBOM, the cosign signature and the matcher's verdict lived
in NetOrk/base-os, the head of the chain. Every link below it -- base-python,
engine, ui, satellite, the service images -- needs the same, so the tools
move here, and every image repository pins them by commit.

- image_tools: cis.py, cis_os.sh, sbom.py and verify.py from base-os. The
  image's own repository holds its cis.toml and packages.allow; both default
  to the working directory.
- bin/sign.sh signs whatever platforms a reference holds: base-os's index of
  amd64 and arm64 (EXPECT_PLATFORMS) or a dev image's single manifest
  (image_tools/platforms.py).
- bin/verify-base.sh checks every FROM image of a Dockerfile against
  cosign.pub before a build; one without a digest is refused
  (image_tools/dockerfile.py).
- bin/install-cosign.sh installs cosign v3.1.3 for amd64 and arm64 runners,
  checked against the release checksums.
- actions/verify-base, judge, sign wrap them for the workflows.
- cosign.pub is the public half of the COSIGN_PRIVATE_KEY_B64 org secret,
  the key rotated on 2026-10-22.
2026-10-22 16:23:00 +02:00

229 lines
8.3 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_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