build: reject ambiguous checkpoint archives
This commit is contained in:
parent
7b817f6339
commit
7131b50e2e
5 changed files with 90 additions and 12 deletions
|
|
@ -18,7 +18,8 @@ The repository now provides:
|
||||||
toolchain, rejects modified upstream source and replaces only the validated
|
toolchain, rejects modified upstream source and replaces only the validated
|
||||||
addon staging directory;
|
addon staging directory;
|
||||||
- a non-executing verifier for externally produced Windows artifacts;
|
- a non-executing verifier for externally produced Windows artifacts;
|
||||||
it requires an exact repository-to-manifest-to-ZIP addon file set;
|
it requires an exact repository-to-manifest-to-ZIP addon file set and
|
||||||
|
rejects unsafe, colliding, encrypted or linked archive entries;
|
||||||
- a checksummed acceptance-kit generator containing only allowlisted public
|
- a checksummed acceptance-kit generator containing only allowlisted public
|
||||||
synthetic inputs, with provenance binding to the repository commit and
|
synthetic inputs, with provenance binding to the repository commit and
|
||||||
non-executing artifact-verification report, including the Rayforge pin and
|
non-executing artifact-verification report, including the Rayforge pin and
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ sizes and SHA-256 digests, rejects unsafe ZIP paths, confirms the pinned
|
||||||
Rayforge commit, requires the manifest and bundle to contain exactly the
|
Rayforge commit, requires the manifest and bundle to contain exactly the
|
||||||
versioned repository addon file set with matching digests, and requires the
|
versioned repository addon file set with matching digests, and requires the
|
||||||
pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed
|
pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed
|
||||||
or additional addon files fail verification.
|
or additional addon files fail verification. Archive entries also fail on
|
||||||
|
duplicate or case-colliding Windows paths, backslashes, drive-like paths,
|
||||||
|
encryption or symbolic links.
|
||||||
The schema-1 verification output records the exact checkpoint-manifest name
|
The schema-1 verification output records the exact checkpoint-manifest name
|
||||||
and SHA-256 plus the fixed checkpoint and addon identities, so later manual
|
and SHA-256 plus the fixed checkpoint and addon identities, so later manual
|
||||||
acceptance can retain the complete artifact provenance chain.
|
acceptance can retain the complete artifact provenance chain.
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ An artifact produced elsewhere can be checked locally without execution: the
|
||||||
verifier rejects unsafe paths or changed bytes and confirms the pin plus every
|
verifier rejects unsafe paths or changed bytes and confirms the pin plus every
|
||||||
packaged addon source before the manual GUI checklist may begin. It also
|
packaged addon source before the manual GUI checklist may begin. It also
|
||||||
rejects omitted manifest entries and extra files hidden in the packaged addon
|
rejects omitted manifest entries and extra files hidden in the packaged addon
|
||||||
directory.
|
directory, plus ambiguous or unsafe Windows ZIP entries.
|
||||||
The manual checklist now also has a versioned, fail-closed report template and
|
The manual checklist now also has a versioned, fail-closed report template and
|
||||||
validator. Missing steps remain incomplete, a complete operator run remains
|
validator. Missing steps remain incomplete, a complete operator run remains
|
||||||
ready for review until a named, timezone-stamped human review is recorded,
|
ready for review until a named, timezone-stamped human review is recorded,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
@ -63,6 +64,35 @@ def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath |
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_archive_files(archive: zipfile.ZipFile) -> dict[PurePosixPath, zipfile.ZipInfo]:
|
||||||
|
files = {}
|
||||||
|
seen = set()
|
||||||
|
for info in archive.infolist():
|
||||||
|
name = info.filename
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
canonical = name.rstrip("/").casefold()
|
||||||
|
if (
|
||||||
|
not name
|
||||||
|
or "\\" in name
|
||||||
|
or "\x00" in name
|
||||||
|
or path.is_absolute()
|
||||||
|
or ".." in path.parts
|
||||||
|
or (path.parts and ":" in path.parts[0])
|
||||||
|
):
|
||||||
|
raise ValueError("bundle contains an unsafe path")
|
||||||
|
if not canonical or canonical in seen:
|
||||||
|
raise ValueError("bundle contains a duplicate or case-colliding path")
|
||||||
|
seen.add(canonical)
|
||||||
|
if info.flag_bits & 0x1:
|
||||||
|
raise ValueError("bundle contains an encrypted entry")
|
||||||
|
mode = (info.external_attr >> 16) & 0xFFFF
|
||||||
|
if stat.S_ISLNK(mode):
|
||||||
|
raise ValueError("bundle contains a symbolic link")
|
||||||
|
if not info.is_dir():
|
||||||
|
files[path] = info
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
def verify(manifest_path: Path) -> dict:
|
def verify(manifest_path: Path) -> dict:
|
||||||
manifest_path = manifest_path.resolve()
|
manifest_path = manifest_path.resolve()
|
||||||
manifest_bytes = manifest_path.read_bytes()
|
manifest_bytes = manifest_path.read_bytes()
|
||||||
|
|
@ -112,14 +142,7 @@ def verify(manifest_path: Path) -> dict:
|
||||||
raise ValueError(f"checkpoint addon source differs from repository: {relative}")
|
raise ValueError(f"checkpoint addon source differs from repository: {relative}")
|
||||||
|
|
||||||
with zipfile.ZipFile(bundle) as archive:
|
with zipfile.ZipFile(bundle) as archive:
|
||||||
files = {
|
files = _safe_archive_files(archive)
|
||||||
PurePosixPath(info.filename): info
|
|
||||||
for info in archive.infolist()
|
|
||||||
if not info.is_dir()
|
|
||||||
}
|
|
||||||
for path in files:
|
|
||||||
if path.is_absolute() or ".." in path.parts:
|
|
||||||
raise ValueError("bundle contains an unsafe path")
|
|
||||||
executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe"
|
executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe"
|
||||||
if not any(path.name == executable for path in files):
|
if not any(path.name == executable for path in files):
|
||||||
raise ValueError("bundle application executable is missing")
|
raise ValueError("bundle application executable is missing")
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import warnings
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from scripts.verify_rayforge_windows_checkpoint import (
|
from scripts.verify_rayforge_windows_checkpoint import (
|
||||||
ADDON_SOURCE,
|
ADDON_SOURCE,
|
||||||
ROOT,
|
ROOT,
|
||||||
UPSTREAM,
|
UPSTREAM,
|
||||||
|
_safe_archive_files,
|
||||||
verify,
|
verify,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,6 +26,9 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
root: Path,
|
root: Path,
|
||||||
unsafe_member: str | None = None,
|
unsafe_member: str | None = None,
|
||||||
extra_addon_file: str | None = None,
|
extra_addon_file: str | None = None,
|
||||||
|
duplicate_member: bool = False,
|
||||||
|
case_collision: bool = False,
|
||||||
|
symlink_member: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip"
|
bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip"
|
||||||
executable = bundle_name.removesuffix(".zip") + ".exe"
|
executable = bundle_name.removesuffix(".zip") + ".exe"
|
||||||
|
|
@ -50,7 +57,26 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
license_data,
|
license_data,
|
||||||
)
|
)
|
||||||
if unsafe_member:
|
if unsafe_member:
|
||||||
archive.writestr(unsafe_member, b"unsafe")
|
unsafe = zipfile.ZipInfo("placeholder")
|
||||||
|
unsafe.filename = unsafe_member
|
||||||
|
unsafe.orig_filename = unsafe_member
|
||||||
|
archive.writestr(unsafe, b"unsafe")
|
||||||
|
if duplicate_member:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", UserWarning)
|
||||||
|
archive.writestr(
|
||||||
|
f"{bundle_name[:-4]}/{executable}", b"duplicate"
|
||||||
|
)
|
||||||
|
if case_collision:
|
||||||
|
archive.writestr(
|
||||||
|
f"{bundle_name[:-4].upper()}/{executable.upper()}",
|
||||||
|
b"case collision",
|
||||||
|
)
|
||||||
|
if symlink_member:
|
||||||
|
link = zipfile.ZipInfo(f"{bundle_name[:-4]}/link")
|
||||||
|
link.create_system = 3
|
||||||
|
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||||
|
archive.writestr(link, "target")
|
||||||
installer = root / f"{bundle_name[:-4]}-installer.exe"
|
installer = root / f"{bundle_name[:-4]}-installer.exe"
|
||||||
installer.write_bytes(b"synthetic installer, never executed")
|
installer.write_bytes(b"synthetic installer, never executed")
|
||||||
license_sidecar = root / "Rayforge-LICENSE.txt"
|
license_sidecar = root / "Rayforge-LICENSE.txt"
|
||||||
|
|
@ -118,6 +144,32 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ValueError, "unsafe path"):
|
with self.assertRaisesRegex(ValueError, "unsafe path"):
|
||||||
verify(manifest)
|
verify(manifest)
|
||||||
|
|
||||||
|
def test_windows_unsafe_and_colliding_archive_paths_are_rejected(self):
|
||||||
|
unsafe = SimpleNamespace(
|
||||||
|
filename="folder\\escape.txt",
|
||||||
|
flag_bits=0,
|
||||||
|
external_attr=0,
|
||||||
|
is_dir=lambda: False,
|
||||||
|
)
|
||||||
|
archive = SimpleNamespace(infolist=lambda: [unsafe])
|
||||||
|
with self.assertRaisesRegex(ValueError, "unsafe path"):
|
||||||
|
_safe_archive_files(archive)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||||
|
manifest = self._checkpoint(Path(directory), duplicate_member=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "duplicate or case-colliding"):
|
||||||
|
verify(manifest)
|
||||||
|
|
||||||
|
manifest = self._checkpoint(Path(directory), case_collision=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "duplicate or case-colliding"):
|
||||||
|
verify(manifest)
|
||||||
|
|
||||||
|
def test_symbolic_link_archive_entry_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||||
|
manifest = self._checkpoint(Path(directory), symlink_member=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "symbolic link"):
|
||||||
|
verify(manifest)
|
||||||
|
|
||||||
def test_manifest_cannot_reference_external_artifacts(self):
|
def test_manifest_cannot_reference_external_artifacts(self):
|
||||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||||
manifest = self._checkpoint(Path(directory))
|
manifest = self._checkpoint(Path(directory))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue