mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-03 16:02:11 -04:00
fix(server-ci): verify exported image identity across Docker stores
This commit is contained in:
1 parent
1ec79ae594
commit
f53bead0a8
2 files changed
+131
-12
No files matched your search
@@ -242,10 +242,11 @@ def rootfs(config):
|
||||
|
||||
|
||||
def archive_identity(path, local):
|
||||
"""Read Docker's saved config/layers, never infer config identity from Id.
|
||||
"""Bind verified saved bytes to the inspected immutable store identity.
|
||||
|
||||
containerd save has an OCI layout envelope; its *one referenced image* must
|
||||
be a manifest, not an index. Classic Docker save has only manifest.json.
|
||||
Both stores may save an OCI envelope. Classic Id hashes raw config;
|
||||
containerd Id/Descriptor identify the manifest, whose verified descriptors
|
||||
bind config/layers. Inspect Config is reconstructed, not the saved JSON.
|
||||
No archive extraction, and all temporary image bytes are removed by caller.
|
||||
"""
|
||||
try:
|
||||
@@ -270,6 +271,7 @@ def archive_identity(path, local):
|
||||
|
||||
names = archive.getnames()
|
||||
require(len(names) == len(set(names)), "duplicate archive members")
|
||||
exported_descriptor = None
|
||||
if "index.json" in names:
|
||||
index = json_body(member("index.json").read())
|
||||
require(isinstance(index, dict) and index.get("schemaVersion") == 2 and
|
||||
@@ -279,6 +281,8 @@ def archive_identity(path, local):
|
||||
require(isinstance(descriptor, dict) and descriptor.get("mediaType") in MANIFEST_TYPES,
|
||||
"unexpected archive image index/attestation")
|
||||
manifest = image_manifest(blob(descriptor).read())
|
||||
require(manifest["mediaType"] == descriptor["mediaType"], "archive manifest media type mismatch")
|
||||
exported_descriptor = descriptor
|
||||
config_body = blob(manifest["config"]).read()
|
||||
layers = manifest["layers"]
|
||||
diffs = [layer_diff_id(blob(layer), layer.get("mediaType")) for layer in layers]
|
||||
@@ -288,12 +292,24 @@ def archive_identity(path, local):
|
||||
"ambiguous classic image archive")
|
||||
config_body = member(entries[0]["Config"]).read()
|
||||
diffs = [stream_digest(member(name)) for name in entries[0]["Layers"]]
|
||||
config_digest = content_digest(config_body)
|
||||
inspected_descriptor = local.get("Descriptor")
|
||||
if inspected_descriptor is None:
|
||||
require(config_digest == sha256(local.get("Id")),
|
||||
"saved config does not bind inspected immutable image")
|
||||
else:
|
||||
require(isinstance(inspected_descriptor, dict) and isinstance(exported_descriptor, dict) and
|
||||
type(inspected_descriptor.get("size")) is int and
|
||||
all(inspected_descriptor.get(key) == exported_descriptor.get(key)
|
||||
for key in ("digest", "size", "mediaType")) and
|
||||
exported_descriptor["digest"] == sha256(local.get("Id")),
|
||||
"saved manifest does not bind inspected immutable image")
|
||||
config = json_body(config_body)
|
||||
require(isinstance(config, dict) and config.get("os") == local.get("Os") and
|
||||
config.get("architecture") == local.get("Architecture") and config.get("config") == local.get("Config"),
|
||||
"saved config differs from inspected image")
|
||||
config.get("architecture") == local.get("Architecture") and isinstance(config.get("config"), dict),
|
||||
"saved config platform mismatch")
|
||||
require(diffs == rootfs(config) == local.get("RootFS", {}).get("Layers"), "saved layer/rootfs chain mismatch")
|
||||
return {"config_digest": content_digest(config_body), "rootfs_diff_ids": diffs}
|
||||
return {"config_digest": config_digest, "rootfs_diff_ids": diffs, "image_config": config}
|
||||
except (OSError, tarfile.TarError, KeyError, TypeError, AttributeError):
|
||||
raise ReleaseError("invalid image archive") from None
|
||||
|
||||
@@ -353,7 +369,7 @@ def readback(client, release_version, sha, local):
|
||||
config_digest == "sha256:" + hashlib.sha256(body).hexdigest(), "config blob readback mismatch")
|
||||
remote = json_body(body)
|
||||
require(isinstance(remote, dict) and remote.get("os") == "linux" and remote.get("architecture") == "amd64" and
|
||||
remote.get("config") == local["Config"], "remote platform/image config mismatch")
|
||||
remote == local.get("image_config"), "remote platform/image config mismatch")
|
||||
diffs = rootfs(remote)
|
||||
require(diffs == result["rootfs_diff_ids"] and len(manifests[0]["layers"]) == len(diffs),
|
||||
"remote rootfs chain differs from smoked image")
|
||||
|
||||
@@ -201,7 +201,7 @@ class ReadbackSafety(unittest.TestCase):
|
||||
responses[release.registry_path("blobs/" + digest(LAYER_GZIP))] = (200, {}, LAYER_GZIP)
|
||||
local = {"Id": digest(blob), "config_digest": digest(blob), "Os": "linux", "Architecture": "amd64",
|
||||
"Config": config()["config"], "RootFS": {"Type": "layers", "Layers": [digest(LAYER)]},
|
||||
"rootfs_diff_ids": [digest(LAYER)]}
|
||||
"image_config": config(), "rootfs_diff_ids": [digest(LAYER)]}
|
||||
return FakeClient(responses), local
|
||||
|
||||
def test_readback_matches_both_tags_and_full_config(self):
|
||||
@@ -221,7 +221,7 @@ class ReadbackSafety(unittest.TestCase):
|
||||
elif mode == "blob":
|
||||
client.responses[release.registry_path("blobs/" + local["Id"])] = (200, {}, b"{}")
|
||||
else:
|
||||
local["Config"]["User"] = "0"
|
||||
local["image_config"]["config"]["User"] = "0"
|
||||
with self.subTest(mode=mode), self.assertRaises(release.ReleaseError):
|
||||
release.readback(client, "1.2.3", SHA, local)
|
||||
|
||||
@@ -316,17 +316,119 @@ class ArchiveIdentity(unittest.TestCase):
|
||||
entry.size = len(body)
|
||||
archive.addfile(entry, io.BytesIO(body))
|
||||
local["Id"] = digest(manifest_body) if mode == "oci" else digest(config_body)
|
||||
if mode == "oci":
|
||||
local["Descriptor"] = copy.deepcopy(descriptor)
|
||||
return local
|
||||
|
||||
def test_archive_config_identity_on_classic_and_containerd_stores(self):
|
||||
for mode in ("classic", "oci"):
|
||||
# Classic Moby also exports an OCI envelope in newer versions; its ID
|
||||
# still hashes raw config, not the generated export manifest.
|
||||
for mode in ("classic", "classic-oci", "oci"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "image.tar"
|
||||
local = self.archive(path, mode)
|
||||
identity = release.archive_identity(path, local)
|
||||
self.assertEqual(identity["config_digest"], digest(encoded(config())))
|
||||
self.assertEqual(identity["rootfs_diff_ids"], [digest(LAYER)])
|
||||
self.assertEqual(local["Id"] == identity["config_digest"], mode == "classic")
|
||||
self.assertEqual(local["Id"] == identity["config_digest"], mode != "oci")
|
||||
|
||||
def normalized_inspect(self, local):
|
||||
# Moby v28.0.4 image.NewFromJSON decodes into container.Config and
|
||||
# images.ImageInspect returns that struct, not RawJSON. These fields
|
||||
# lack omitempty (api/types/container/config.go), so inspect emits
|
||||
# their Go zero values even when the saved OCI config omits them.
|
||||
# This is a source-backed fixture, not an invented hosted-run diff.
|
||||
local["Config"] = dict(local["Config"], Hostname="", Domainname="",
|
||||
AttachStdin=False, AttachStdout=False, AttachStderr=False,
|
||||
Tty=False, OpenStdin=False, StdinOnce=False, Env=None,
|
||||
Cmd=None, Image="", Volumes=None, WorkingDir="", OnBuild=None)
|
||||
return local
|
||||
|
||||
def test_normalized_classic_inspect_keeps_exact_saved_config_identity(self):
|
||||
for mode in ("classic", "classic-oci"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "image.tar"
|
||||
local = self.normalized_inspect(self.archive(path, mode))
|
||||
self.assertNotEqual(local["Config"], config()["config"])
|
||||
try:
|
||||
identity = release.archive_identity(path, local)
|
||||
except release.ReleaseError as error:
|
||||
self.fail("raw config identity must survive Moby inspect defaults: " + str(error))
|
||||
self.assertEqual(identity["config_digest"], local["Id"])
|
||||
self.assertEqual(identity["image_config"], config())
|
||||
local.update(identity)
|
||||
client, _ = ReadbackSafety().fixture()
|
||||
result = release.readback(client, "1.2.3", SHA, local)
|
||||
self.assertEqual(result["config_digest"], local["Id"])
|
||||
|
||||
def test_readback_uses_saved_config_not_normalized_inspect_config(self):
|
||||
client, local = ReadbackSafety().fixture()
|
||||
self.normalized_inspect(local)
|
||||
try:
|
||||
result = release.readback(client, "1.2.3", SHA, local)
|
||||
except release.ReleaseError as error:
|
||||
self.fail("remote must match saved config bytes, not reconstructed inspect: " + str(error))
|
||||
self.assertEqual(result["config_digest"], digest(encoded(config())))
|
||||
|
||||
def test_classic_export_must_hash_to_inspected_immutable_id(self):
|
||||
for mode in ("classic", "classic-oci"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "image.tar"
|
||||
local = self.archive(path, mode)
|
||||
# Same nested runtime config/platform/rootfs, different raw image
|
||||
# config (created/history/etc.) must not be accepted as the ID.
|
||||
local["Id"] = digest(encoded(dict(config(), created="2000-01-01T00:00:00Z")))
|
||||
with self.assertRaisesRegex(release.ReleaseError, "immutable image"):
|
||||
release.archive_identity(path, local)
|
||||
|
||||
def test_containerd_export_must_bind_inspected_descriptor_and_id(self):
|
||||
for field, value in (("Id", "sha256:" + "d" * 64),
|
||||
("digest", "sha256:" + "d" * 64), ("size", 1),
|
||||
("size", True), ("mediaType", release.MANIFEST_TYPES[1]),
|
||||
("Descriptor", None), ("Descriptor", {})):
|
||||
with self.subTest(field=field, value=value), tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "image.tar"
|
||||
local = self.archive(path)
|
||||
if field in ("Id", "Descriptor"):
|
||||
local[field] = value
|
||||
else:
|
||||
local["Descriptor"][field] = value
|
||||
with self.assertRaisesRegex(release.ReleaseError, "immutable image"):
|
||||
release.archive_identity(path, local)
|
||||
|
||||
def test_containerd_descriptor_cannot_use_legacy_archive_fallback(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "image.tar"
|
||||
local = self.archive(path, "classic")
|
||||
local["Descriptor"] = {"mediaType": release.MANIFEST_TYPES[0],
|
||||
"digest": local["Id"], "size": len(encoded(config()))}
|
||||
with self.assertRaisesRegex(release.ReleaseError, "immutable image"):
|
||||
release.archive_identity(path, local)
|
||||
|
||||
def test_local_identity_exports_immutable_id_and_rejects_tag_retarget(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "image.tar"
|
||||
original = self.normalized_inspect(self.archive(path, "classic"))
|
||||
|
||||
def export(*args):
|
||||
self.assertEqual(args[:4], ("docker", "image", "save", "-o"))
|
||||
self.assertEqual(args[5], original["Id"])
|
||||
pathlib.Path(args[4]).write_bytes(path.read_bytes())
|
||||
return ""
|
||||
|
||||
with mock.patch.object(release, "command", side_effect=export), \
|
||||
mock.patch.object(release, "inspect", return_value=copy.deepcopy(original)) as inspect:
|
||||
try:
|
||||
local = release.local_identity("retargetable:tag")
|
||||
except release.ReleaseError as error:
|
||||
self.fail("immutable export must verify with normalized inspect: " + str(error))
|
||||
self.assertEqual(local["image_config"], config())
|
||||
self.assertEqual(inspect.call_args_list, [mock.call("retargetable:tag")] * 2)
|
||||
changed = dict(original, Id="sha256:" + "e" * 64)
|
||||
with mock.patch.object(release, "command", side_effect=export), \
|
||||
mock.patch.object(release, "inspect", side_effect=[copy.deepcopy(original), changed]):
|
||||
with self.assertRaisesRegex(release.ReleaseError, "changed while inspecting"):
|
||||
release.local_identity("retargetable:tag")
|
||||
|
||||
def test_archive_rejects_indexes_attestations_corruption_and_wrong_rootfs(self):
|
||||
for mutation in ("index", "multiple", "blob", "rootfs"):
|
||||
@@ -388,8 +490,9 @@ class ArchiveIdentity(unittest.TestCase):
|
||||
remote = config()
|
||||
remote["rootfs"]["diff_ids"] = ["sha256:" + "c" * 64]
|
||||
blob = encoded(remote)
|
||||
# Isolate the rootfs guard after the config digest comparison.
|
||||
# Isolate the rootfs guard after the exact saved config comparison.
|
||||
local["config_digest"] = digest(blob)
|
||||
local["image_config"] = remote
|
||||
manifest = json.loads(client.responses[release.registry_path("manifests/1.2.3")][2])
|
||||
manifest["config"].update(digest=digest(blob), size=len(blob))
|
||||
body = encoded(manifest)
|
||||
|
||||
Reference in new issue
Block a user