feat: judge, sign and verify netOrk's images in one place (NetOrk/netork#1390)
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.
This commit is contained in:
Christian Manivong committed 2026-10-22 16:23:00 +02:00
commit d139d66c3d
27 files changed
+2085

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
"""Whether an image is what CIS asks of it (CIS Docker 1.6.0 §4, CIS Debian Linux 12).
Trivy measures what it automates, ``image_tools/cis_os.sh`` what can be seen from
inside the image, and ``cis.toml`` decides the rest. The gate is red for a
failure nobody decided, an expired exception, a control neither measured nor
decided, a decision for a control nothing knows, and a measurement not taken.
"""
from __future__ import annotations
import datetime
import json
import subprocess
import pytest
from image_tools.cis import (
Control,
Decision,
check,
from_os,
from_trivy,
judge,
load_decisions,
load_packages,
merge,
render,
)
TODAY = datetime.date(2026, 10, 21)
SUMMARY = {
"ID": "docker-cis-1.6.0",
"SummaryControls": [
{"ID": "4.1", "Name": "Ensure a user for the container has been created", "TotalFail": 1},
{"ID": "4.3", "Name": "Ensure unnecessary packages are not installed (Manual)"},
{"ID": "4.7", "Name": "Ensure update instructions are not used alone", "TotalFail": 0},
{"ID": "4.8", "Name": "Ensure setuid and setgid permissions are removed (Manual)"},
],
}
OS = (
"docker:4.3\tPASS\tUnnecessary packages are not installed (packages.allow)\t\n"
"docker:4.8\tFAIL\tsetuid and setgid permissions are removed\t/usr/bin/su /usr/bin/mount\n"
"debian:7.2.5\tPASS\tNo duplicate UIDs exist\t\n"
)
def _c(cid, result, detail=""):
return Control(cid, cid, result, detail)
class TestMeasurements:
def test_trivy_without_a_count_did_not_measure(self):
controls = {c.id: c for c in from_trivy(SUMMARY)}
assert controls["docker:4.1"].result == "fail"
assert controls["docker:4.3"].result == "unmeasured"
assert controls["docker:4.7"].result == "pass"
def test_the_os_checks_say_what_fails(self):
controls = {c.id: c for c in from_os(OS)}
assert controls["docker:4.8"].result == "fail"
assert controls["docker:4.8"].detail == "/usr/bin/su /usr/bin/mount"
assert controls["debian:7.2.5"].result == "pass"
def test_a_line_the_os_checks_should_not_print_is_an_error(self):
with pytest.raises(ValueError):
from_os("sh: 1: stat: not found\n")
def test_the_os_checks_settle_what_trivy_leaves_to_manual(self):
controls = {c.id: c for c in merge(from_trivy(SUMMARY), from_os(OS))}
assert controls["docker:4.3"].result == "pass"
assert controls["docker:4.8"].result == "fail"
assert controls["docker:4.1"].result == "fail"
assert list(controls)[:4] == ["docker:4.1", "docker:4.3", "docker:4.7", "docker:4.8"]
def test_a_failure_wins_over_a_pass(self):
(control,) = merge([_c("docker:4.8", "fail")], [_c("docker:4.8", "pass")])
assert control.result == "fail"
class TestJudge:
def test_all_passing_is_green(self):
assert judge([_c("docker:4.1", "pass")], {}, TODAY).ok
def test_a_failure_nobody_decided_is_red(self):
report = judge([_c("docker:4.1", "fail")], {}, TODAY)
assert not report.ok
assert report.controls[0].state == "fail"
def test_an_exception_keeps_a_failure_green(self):
decisions = {"docker:4.6": Decision("exception", "no service")}
report = judge([_c("docker:4.6", "fail")], decisions, TODAY)
assert report.ok
assert report.controls[0].state == "excepted"
def test_an_expired_exception_is_red(self):
decisions = {"docker:4.6": Decision("exception", "for now", datetime.date(2026, 10, 1))}
report = judge([_c("docker:4.6", "fail")], decisions, TODAY)
assert not report.ok
assert report.controls[0].state == "expired"
def test_an_exception_expiring_today_still_holds(self):
decisions = {"docker:4.6": Decision("exception", "for now", TODAY)}
assert judge([_c("docker:4.6", "fail")], decisions, TODAY).ok
def test_a_control_nothing_measures_must_be_decided(self):
assert not judge([_c("docker:4.2", "unmeasured")], {}, TODAY).ok
decisions = {"docker:4.2": Decision("manual", "pinned by digest")}
report = judge([_c("docker:4.2", "unmeasured")], decisions, TODAY)
assert report.ok
assert report.controls[0].state == "manual"
def test_manual_is_not_the_tools_word(self):
"""4.4 belongs to the matcher, whose policy is that new CVEs never block."""
decisions = {"docker:4.4": Decision("manual", "judged by the matcher")}
assert judge([_c("docker:4.4", "fail")], decisions, TODAY).ok
def test_an_exception_for_a_passing_control_is_named_stale(self):
decisions = {"docker:4.6": Decision("exception", "no service")}
report = judge([_c("docker:4.6", "pass")], decisions, TODAY)
assert report.ok
assert report.stale == ["docker:4.6"]
def test_a_decision_for_a_control_nothing_knows_is_red(self):
"""A typo in cis.toml must not silently decide nothing."""
decisions = {"docker:4.66": Decision("exception", "typo")}
report = judge([_c("docker:4.6", "pass")], decisions, TODAY)
assert not report.ok
assert report.unknown == ["docker:4.66"]
def test_nothing_measured_is_not_green(self):
assert not judge([], {}, TODAY).ok
class TestDecisions:
def test_both_kinds_with_their_reasons(self):
decisions = load_decisions(
"""
[[manual]]
id = "docker:4.2"
reason = "pinned by digest"
[[exception]]
id = "docker:4.6"
reason = "no service"
expires = 2027-01-31
"""
)
assert decisions["docker:4.2"] == Decision("manual", "pinned by digest")
assert decisions["docker:4.6"].expires == datetime.date(2027, 1, 31)
@pytest.mark.parametrize(
"text",
[
'[[manual]]\nid = "docker:4.2"\nreason = " "\n',
'[[manual]]\nid = "docker:4.2"\nreason = "a"\n[[exception]]\nid = "docker:4.2"\nreason = "b"\n',
'[[exception]]\nid = "docker:4.6"\nreason = "a"\nexpires = "soon"\n',
],
ids=["no reason", "decided twice", "expires not a date"],
)
def test_a_decision_without_substance_is_refused(self, text):
with pytest.raises(ValueError):
load_decisions(text)
class TestCheck:
def test_measured_and_judged(self):
seams = {"trivy": lambda ref: SUMMARY, "os_checks": lambda ref, pkgs: OS}
decisions = {"docker:4.8": Decision("exception", "not yet")}
report = check("img", decisions, ["apt"], TODAY, **seams)
assert not report.ok # 4.1 fails undecided
assert {c.id: c.state for c in report.controls}["docker:4.8"] == "excepted"
def test_the_packages_reach_the_os_checks(self):
seen = []
def os_checks(ref, packages):
seen.append(packages)
return OS
check("img", {}, ["apt", "bash"], TODAY, trivy=lambda r: SUMMARY, os_checks=os_checks)
assert seen == [["apt", "bash"]]
def test_a_measurement_not_taken_is_red(self):
def trivy(ref):
raise subprocess.CalledProcessError(1, "trivy")
report = check("img", {}, [], TODAY, trivy=trivy, os_checks=lambda r, p: OS)
assert not report.ok
assert "CalledProcessError" in report.error
def test_the_report_says_why():
decisions = {
"docker:4.6": Decision("exception", "base-os runs no service"),
"docker:4.2": Decision("manual", "pinned by digest"),
}
controls = [
_c("docker:4.1", "fail", "1 failed (Trivy)"),
_c("docker:4.2", "unmeasured"),
_c("docker:4.6", "fail", "1 failed (Trivy)"),
]
text = render(judge(controls, decisions, TODAY), "base-os:ci")
assert "**CIS: red**" in text and "`base-os:ci`" in text
assert "| docker:4.1 | ❌ fail |" in text and "1 failed (Trivy)" in text
assert "base-os runs no service" in text
assert "pinned by digest" in text
def test_the_report_is_json_for_the_attestation():
report = judge([_c("docker:4.1", "pass")], {}, TODAY)
data = json.loads(report.to_json())
assert data["ok"] is True
assert data["controls"][0] == {
"id": "docker:4.1",
"title": "docker:4.1",
"result": "pass",
"detail": "",
"state": "pass",
"reason": "",
}
def test_the_allow_list_is_names_without_comments_or_blank_lines():
text = "# what base-os may hold\napt\n\n bash \n# login.defs comes from login\nlogin\n"
assert load_packages(text) == ["apt", "bash", "login"]