diff --git a/UPGRADES.md b/UPGRADES.md index 7e133d1a..4ed9369c 100644 --- a/UPGRADES.md +++ b/UPGRADES.md @@ -17,6 +17,52 @@ states, not the current patch inventory. ## History +## Sandbox apt trust anchor: keyring pin replaces the InRelease hash pin (2026-10-09) + +`containers/Dockerfile` pinned `KALI_APT_INRELEASE_SHA256` against the +`kali-last-snapshot` suite. That suite is a rolling pointer: Kali re-signs its +`InRelease` on every archive refresh, so the pin failed the build on each +re-sign. The archive carries no immutable dated suite, and the dated-snapshot +service at `snapshot.kali.org` is not serving. + +The durable anchor is the Kali keyring shipped by the digest-pinned base image +(`usr/share/keyrings/kali-archive-keyring.gpg`, currently +`42a247ff5a26869e3739b6d3ad125938bb5da8e7bb43ed0791d2a190cd09c64f`, holding key +`827C8569F2518CC677FECA1AED65462EC8D5E4C5`). The `deb` line names that exact +file with `[signed-by=/usr/share/keyrings/kali-archive-keyring.gpg]`, so the +keyring whose sha256 the build checks is the same file apt is told to +authenticate the index with; without `signed-by` apt would fall back to +whatever trusted key the image happens to carry. The build verifies that +file's hash and apt then verifies the index against it, fail-closed via +`/etc/apt/apt.conf.d/99lyrashield-signature-required` +(`AllowUnauthenticated`, `AllowInsecureRepositories` and +`AllowDowngradeToInsecureRepositories` all `false`). Package selection stays +pinned by the `Pin-Priority: 1001` snapshot pin and `--allow-downgrades`. + +The later `apt-get install -y kali-archive-keyring` in the same RUN does not +disturb that file: the package ships the same keyring at the same path +(`kali-archive-keyring_2025.2_all.deb` unpacks +`./usr/share/keyrings/kali-archive-keyring.gpg`, 3464 bytes, sha256 +`42a247ff5a26869e3739b6d3ad125938bb5da8e7bb43ed0791d2a190cd09c64f` — identical +to the pin). If a future package version rotated the key or changed the path, +the hash check would still pass (it runs before the install) but `signed-by` +would point at a key apt could no longer match, and `apt-get update` after the +install would fail closed rather than silently trust a different key. Refresh +the pin and the `signed-by` path together if that ever happens. + +To refresh the pin, when the base image digest advances or Kali rotates its +signing key: + +1. Read the keyring hash from the pinned base image: + `docker run --rm --entrypoint sha256sum kalilinux/kali-rolling@sha256: /usr/share/keyrings/kali-archive-keyring.gpg` +2. Update both `ARG KALI_APT_KEYRING_SHA256=` lines in `containers/Dockerfile` + to that value. +3. Rebuild the sandbox image (`ci.yml` job `docker-smoke`) and confirm the + `sha256sum -c -` step and both `apt-get update` calls still pass. + +The archive key rotation itself is the normal Kali maintenance path and needs +no code change beyond step 2. + ## DG-16 owned/upstream twin disposition (2026-09-28) CI now prints a sorted, informational CSV inventory of same-path Python modules diff --git a/containers/Dockerfile b/containers/Dockerfile index 4b9fcde7..84577029 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -9,14 +9,22 @@ FROM kalilinux/kali-rolling@sha256:f49124869e4eee549315879c3bd7ef92f23b8753fec7544537bcec1493277096 AS gobuilder ARG KALI_APT_SUITE=kali-last-snapshot -ARG KALI_APT_INRELEASE_SHA256=5bc9a9bf729d7f9a0c360afe66488379a04d809cbaa7ad4fcefe1704266f5f51 -# The pinned InRelease hash and its APT signature authenticate this immutable -# archive snapshot; the base image predates the archive's current TLS chain. -RUN printf 'deb http://archive.kali.org/kali %s main contrib non-free non-free-firmware\n' "$KALI_APT_SUITE" > /etc/apt/sources.list && \ +# Supply-chain anchor for the Kali apt index. `kali-last-snapshot` is a +# re-signed rolling pointer, so its InRelease bytes change on every archive +# refresh and cannot be pinned durably. The trust anchor is instead the pinned +# base image's own Kali keyring, whose content hash is fixed by the +# digest-pinned FROM above; the apt signature verification that keyring +# performs is enforced fail-closed in the RUN below. The `deb` line names that +# same keyring with `signed-by`, so the file the hash check below pins is +# exactly the file apt trusts to authenticate the index; without it apt would +# fall back to whatever trusted key is present in the image. +ARG KALI_APT_KEYRING_SHA256=42a247ff5a26869e3739b6d3ad125938bb5da8e7bb43ed0791d2a190cd09c64f +RUN printf 'deb [signed-by=/usr/share/keyrings/kali-archive-keyring.gpg] http://archive.kali.org/kali %s main contrib non-free non-free-firmware\n' "$KALI_APT_SUITE" > /etc/apt/sources.list && \ printf 'Package: *\nPin: release n=%s\nPin-Priority: 1001\n' "$KALI_APT_SUITE" > /etc/apt/preferences.d/kali-snapshot && \ rm -f /etc/apt/sources.list.d/* && \ + printf 'APT::Get::AllowUnauthenticated "false";\nAcquire::AllowInsecureRepositories "false";\nAcquire::AllowDowngradeToInsecureRepositories "false";\n' > /etc/apt/apt.conf.d/99lyrashield-signature-required && \ + echo "${KALI_APT_KEYRING_SHA256} /usr/share/keyrings/kali-archive-keyring.gpg" | sha256sum -c - && \ apt-get update && \ - echo "${KALI_APT_INRELEASE_SHA256} /var/lib/apt/lists/archive.kali.org_kali_dists_${KALI_APT_SUITE}_InRelease" | sha256sum -c - && \ apt-get full-upgrade -y --allow-downgrades && \ apt-get install -y kali-archive-keyring && \ apt-get update && \ @@ -46,12 +54,13 @@ FROM kalilinux/kali-rolling@sha256:f49124869e4eee549315879c3bd7ef92f23b8753fec75 LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools" ARG KALI_APT_SUITE=kali-last-snapshot -ARG KALI_APT_INRELEASE_SHA256=5bc9a9bf729d7f9a0c360afe66488379a04d809cbaa7ad4fcefe1704266f5f51 -RUN printf 'deb http://archive.kali.org/kali %s main contrib non-free non-free-firmware\n' "$KALI_APT_SUITE" > /etc/apt/sources.list && \ +ARG KALI_APT_KEYRING_SHA256=42a247ff5a26869e3739b6d3ad125938bb5da8e7bb43ed0791d2a190cd09c64f +RUN printf 'deb [signed-by=/usr/share/keyrings/kali-archive-keyring.gpg] http://archive.kali.org/kali %s main contrib non-free non-free-firmware\n' "$KALI_APT_SUITE" > /etc/apt/sources.list && \ printf 'Package: *\nPin: release n=%s\nPin-Priority: 1001\n' "$KALI_APT_SUITE" > /etc/apt/preferences.d/kali-snapshot && \ rm -f /etc/apt/sources.list.d/* && \ + printf 'APT::Get::AllowUnauthenticated "false";\nAcquire::AllowInsecureRepositories "false";\nAcquire::AllowDowngradeToInsecureRepositories "false";\n' > /etc/apt/apt.conf.d/99lyrashield-signature-required && \ + echo "${KALI_APT_KEYRING_SHA256} /usr/share/keyrings/kali-archive-keyring.gpg" | sha256sum -c - && \ apt-get update && \ - echo "${KALI_APT_INRELEASE_SHA256} /var/lib/apt/lists/archive.kali.org_kali_dists_${KALI_APT_SUITE}_InRelease" | sha256sum -c - && \ apt-get full-upgrade -y --allow-downgrades && \ apt-get install -y kali-archive-keyring && \ apt-get update diff --git a/tests/test_sandbox_dockerfile.py b/tests/test_sandbox_dockerfile.py index f5a9c7a7..477918ae 100644 --- a/tests/test_sandbox_dockerfile.py +++ b/tests/test_sandbox_dockerfile.py @@ -6,6 +6,8 @@ import re from pathlib import Path +import pytest + DOCKERFILE = Path(__file__).parents[1] / "containers" / "Dockerfile" DOCKERIGNORE = Path(__file__).parents[1] / ".dockerignore" @@ -27,12 +29,24 @@ def test_every_external_sandbox_input_is_immutable_or_hash_verified() -> None: assert "FROM kalilinux/kali-rolling@sha256:" in content assert "ARG KALI_APT_SUITE=kali-last-snapshot" in content - assert "ARG KALI_APT_INRELEASE_SHA256=" in content - kali_index_pins = re.findall( - r"^ARG KALI_APT_INRELEASE_SHA256=([0-9a-f]{64})$", content, flags=re.MULTILINE + # The Kali apt trust anchor is the keyring shipped by the digest-pinned base + # image, not the InRelease bytes. `kali-last-snapshot` is a rolling pointer + # that Kali re-signs on every archive refresh, so an index-hash pin broke the + # build on each re-sign; the keyring is the part that stays fixed. + assert "ARG KALI_APT_INRELEASE_SHA256=" not in content + kali_keyring_pins = re.findall( + r"^ARG KALI_APT_KEYRING_SHA256=([0-9a-f]{64})$", content, flags=re.MULTILINE ) - assert len(kali_index_pins) == 2 - assert kali_index_pins[0] == kali_index_pins[1] + assert len(kali_keyring_pins) == 2 + assert kali_keyring_pins[0] == kali_keyring_pins[1] + assert "sha256sum -c -" in content + # Signature verification must fail closed: no fallback to an unauthenticated + # or insecure repository, and no opt-out flags anywhere in the image. + assert 'APT::Get::AllowUnauthenticated "false"' in content + assert 'Acquire::AllowInsecureRepositories "false"' in content + assert 'Acquire::AllowDowngradeToInsecureRepositories "false"' in content + assert "--allow-unauthenticated" not in content + assert "trusted=yes" not in content assert "Pin-Priority: 1001" in content assert "apt-get full-upgrade -y --allow-downgrades" in content assert "archive.kali.org/kali" in content @@ -59,6 +73,54 @@ def test_every_external_sandbox_input_is_immutable_or_hash_verified() -> None: assert "npm install -g" not in content +KALI_KEYRING_PATH = "/usr/share/keyrings/kali-archive-keyring.gpg" +KALI_DEB_RE = re.compile(r"^.*printf 'deb (?P\[[^\]]*\])? ?[^\s']+ .*$", re.MULTILINE) + + +def assert_every_kali_deb_line_is_signed_by_the_pinned_keyring(content: str) -> None: + """Every Kali `deb` line must name the hashed keyring with `signed-by`. + + `signed-by` is what makes the file whose sha256 the build checks the same + file apt verifies the index against. Without it apt falls back to whatever + trusted key the image happens to carry, so the hash check would be pinning + a file that nothing forces apt to consult. + """ + deb_lines = [ + line + for line in content.splitlines() + if "printf 'deb " in line and "archive.kali.org/kali" in line + ] + assert len(deb_lines) == 2, f"expected one Kali deb line per stage, found {len(deb_lines)}" + for line in deb_lines: + match = KALI_DEB_RE.match(line) + assert match is not None, f"unparsable Kali deb line: {line}" + opts = match.group("opts") or "" + assert f"signed-by={KALI_KEYRING_PATH}" in opts, ( + f"Kali deb line is not signed by the pinned keyring: {line}" + ) + + +def test_every_kali_deb_line_is_signed_by_the_pinned_keyring() -> None: + content = DOCKERFILE.read_text(encoding="utf-8") + + assert_every_kali_deb_line_is_signed_by_the_pinned_keyring(content) + + # Negative control: the invariant must be load-bearing. Dropping the + # `signed-by` option from a copy of the real content has to fail it, both + # when the option is removed outright and when it points at another key. + unsigned = content.replace(f"deb [signed-by={KALI_KEYRING_PATH}] http://", "deb http://") + assert unsigned != content, "negative control did not modify the content" + with pytest.raises(AssertionError): + assert_every_kali_deb_line_is_signed_by_the_pinned_keyring(unsigned) + + wrong_key = content.replace( + f"deb [signed-by={KALI_KEYRING_PATH}] http://", + "deb [signed-by=/etc/apt/trusted.gpg.d/other.gpg] http://", + ) + with pytest.raises(AssertionError): + assert_every_kali_deb_line_is_signed_by_the_pinned_keyring(wrong_key) + + def test_caido_download_retries_transient_server_errors() -> None: content = DOCKERFILE.read_text(encoding="utf-8") download_command = next(