CI / Tests, lint (push) Successful in 18s
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.
229 lines
8.3 KiB
Python
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
|