Spaces:
Running
Running
fix: authenticate public release assets
Browse files- CHANGELOG.md +3 -0
- QUALIFICATION.md +13 -1
- README.md +18 -10
- RELEASE_NOTES.md +4 -0
- SECURITY_MODEL.md +8 -0
- scripts/first_kill.py +180 -25
- scripts/verify_release.py +56 -9
- tests/test_first_kill.py +185 -0
- tests/test_release_archive.py +52 -0
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,9 @@
|
|
| 8 |
verification and the adaptive campaign evidence plan.
|
| 9 |
- Make the public first-kill path default to `v1.0.0` and make the support
|
| 10 |
helper use the installed, root-owned zipapp shipped by the verified bundle.
|
|
|
|
|
|
|
|
|
|
| 11 |
- Keep publication, signing, tagging and external deployment as separate
|
| 12 |
decisions.
|
| 13 |
|
|
|
|
| 8 |
verification and the adaptive campaign evidence plan.
|
| 9 |
- Make the public first-kill path default to `v1.0.0` and make the support
|
| 10 |
helper use the installed, root-owned zipapp shipped by the verified bundle.
|
| 11 |
+
- Require a detached release-checksum signature from the pinned release key and
|
| 12 |
+
reject duplicate, link, special, oversized and unsafe ZIP members before any
|
| 13 |
+
bundled installer can reach root execution.
|
| 14 |
- Keep publication, signing, tagging and external deployment as separate
|
| 15 |
decisions.
|
| 16 |
|
QUALIFICATION.md
CHANGED
|
@@ -63,7 +63,19 @@ Five release-blocking Priority-0 campaigns are mandatory:
|
|
| 63 |
5. privileged installer attacks: hostile Python import hooks, symlinked inputs,
|
| 64 |
expected-digest/manifest/version/source drift, traversal archive, partial
|
| 65 |
prior installation, pre-existing-install refusal and a pathname replacement
|
| 66 |
-
after the installer binds its artifact descriptor.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
The control-plane parser must also reject, without replacing the supervisor,
|
| 69 |
a valid sub-32-KiB JSON request containing an integer beyond the supported
|
|
|
|
| 63 |
5. privileged installer attacks: hostile Python import hooks, symlinked inputs,
|
| 64 |
expected-digest/manifest/version/source drift, traversal archive, partial
|
| 65 |
prior installation, pre-existing-install refusal and a pathname replacement
|
| 66 |
+
after the installer binds its artifact descriptor. The public bootstrap must
|
| 67 |
+
also reject an absent or invalid detached checksum signature, self-recomputed
|
| 68 |
+
unsigned checksums, duplicate/normalized-duplicate members, archive links or
|
| 69 |
+
special members and a bundle whose authenticated asset identity disagrees
|
| 70 |
+
with the signed tag source identity.
|
| 71 |
+
|
| 72 |
+
Before publication, sign the final detached checksum list with the private key
|
| 73 |
+
corresponding to fingerprint
|
| 74 |
+
`53786DEB001459956A2E1B86A3F29F7A27636DC7`, publish `SHA256SUMS.asc` beside
|
| 75 |
+
`SHA256SUMS`, the Linux bundle and `eggcracker-release-key.asc`, and rerun the
|
| 76 |
+
public first-kill asset-authentication path against those exact uploaded bytes.
|
| 77 |
+
CI build artifacts without that detached signature are build outputs, not a
|
| 78 |
+
releasable distribution.
|
| 79 |
|
| 80 |
The control-plane parser must also reject, without replacing the supervisor,
|
| 81 |
a valid sub-32-KiB JSON request containing an integer beyond the supported
|
README.md
CHANGED
|
@@ -106,8 +106,12 @@ empty installation targets, required tool availability, and that the local
|
|
| 106 |
published-release reference is an annotated tag resolving to a commit. It makes
|
| 107 |
no network request and creates no workspace, GPG home, build, installation or
|
| 108 |
service. It does not verify the tag signature, downloaded assets, functional
|
| 109 |
-
build, installation or containment
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
### Probe the containment primitive
|
| 113 |
|
|
@@ -190,10 +194,11 @@ sudo /usr/bin/python3 -I -S scripts/first_kill.py \
|
|
| 190 |
--accept-third-party-downloads
|
| 191 |
```
|
| 192 |
|
| 193 |
-
The command checks host compatibility,
|
| 194 |
-
release
|
| 195 |
-
|
| 196 |
-
|
|
|
|
| 197 |
non-interactive removal or `--keep` to inspect the installation after the
|
| 198 |
demonstration. Share a passing, refused, or confusing supported-path run through
|
| 199 |
the [redacted result form](https://github.com/noqt/Lumi-Eggcracker/issues/new?template=first_kill_result.yml).
|
|
@@ -259,10 +264,13 @@ file before attaching it to an issue or discussion.
|
|
| 259 |
|
| 260 |
## Install and remove
|
| 261 |
|
| 262 |
-
The first-kill command is the recommended campaign path.
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
```sh
|
| 268 |
sudo /usr/bin/python3 -I -S scripts/uninstall.py
|
|
|
|
| 106 |
published-release reference is an annotated tag resolving to a commit. It makes
|
| 107 |
no network request and creates no workspace, GPG home, build, installation or
|
| 108 |
service. It does not verify the tag signature, downloaded assets, functional
|
| 109 |
+
build, installation or containment. The full run verifies both the annotated
|
| 110 |
+
tag signature and the detached `SHA256SUMS.asc` signature with the pinned
|
| 111 |
+
release-key fingerprint, requires the downloaded bundle to match that signed
|
| 112 |
+
checksum list, and requires the signed tag commit to match the release
|
| 113 |
+
manifest. It rejects duplicate, link, special and unsafe archive members before
|
| 114 |
+
extraction.
|
| 115 |
|
| 116 |
### Probe the containment primitive
|
| 117 |
|
|
|
|
| 194 |
--accept-third-party-downloads
|
| 195 |
```
|
| 196 |
|
| 197 |
+
The command checks host compatibility, authenticates the tag and detached
|
| 198 |
+
release checksums, verifies the exact downloaded bundle, installs the
|
| 199 |
+
root-controlled supervisor, downloads the pinned demo model only after the
|
| 200 |
+
explicit acceptance flag, launches the real model, prints the kill receipt,
|
| 201 |
+
and offers clean removal. Use `--remove` for a
|
| 202 |
non-interactive removal or `--keep` to inspect the installation after the
|
| 203 |
demonstration. Share a passing, refused, or confusing supported-path run through
|
| 204 |
the [redacted result form](https://github.com/noqt/Lumi-Eggcracker/issues/new?template=first_kill_result.yml).
|
|
|
|
| 264 |
|
| 265 |
## Install and remove
|
| 266 |
|
| 267 |
+
The first-kill command is the recommended campaign path. A release is complete
|
| 268 |
+
only when it carries `SHA256SUMS`, its detached `SHA256SUMS.asc` signature and
|
| 269 |
+
`eggcracker-release-key.asc`; the pinned fingerprint is
|
| 270 |
+
`53786DEB001459956A2E1B86A3F29F7A27636DC7`. For a controlled manual
|
| 271 |
+
installation, verify that signature and the Linux bundle checksum before
|
| 272 |
+
running the bundled installer as root with a non-root operator. Remove every
|
| 273 |
+
product-owned unit, socket, account and state file with:
|
| 274 |
|
| 275 |
```sh
|
| 276 |
sudo /usr/bin/python3 -I -S scripts/uninstall.py
|
RELEASE_NOTES.md
CHANGED
|
@@ -10,6 +10,10 @@ lockdown.
|
|
| 10 |
The public first-kill helper defaults to `v1.0.0`. The packaged support helper
|
| 11 |
delegates to the installed root-owned zipapp, so the documented command works
|
| 12 |
from the verified Linux release bundle without importing a mutable checkout.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
The candidate is not tagged, signed or published. Its release decision is
|
| 15 |
bound to the exact commit, artifact hashes, disposable Ubuntu qualification
|
|
|
|
| 10 |
The public first-kill helper defaults to `v1.0.0`. The packaged support helper
|
| 11 |
delegates to the installed root-owned zipapp, so the documented command works
|
| 12 |
from the verified Linux release bundle without importing a mutable checkout.
|
| 13 |
+
The bootstrap now also requires a detached `SHA256SUMS.asc` signature from the
|
| 14 |
+
pinned release key and rejects duplicate, link, special, oversized and unsafe
|
| 15 |
+
ZIP members. A replaced release bundle plus self-recomputed unsigned checksums
|
| 16 |
+
can therefore no longer reach root execution.
|
| 17 |
|
| 18 |
The candidate is not tagged, signed or published. Its release decision is
|
| 19 |
bound to the exact commit, artifact hashes, disposable Ubuntu qualification
|
SECURITY_MODEL.md
CHANGED
|
@@ -23,6 +23,14 @@ remote workloads or a privileged process with a pre-connected external socket.
|
|
| 23 |
|
| 24 |
Heartbeat emission is tied to recent successful discovery completion and durable post-containment detection receipts, not merely to a live worker thread. A blocked or repeatedly failing scan, or any failure to persist a detection receipt after containment, therefore makes `doctor` report `UNSUPPORTED`, stops heartbeats and lets the independent watchdog apply its bounded fail-closed recovery. Receipt-storage health remains latched false until a root operator repairs storage and restarts the supervisor. Model descriptors and executable runtime mappings are inspected in bounded stripes, and the stripe generation is reserved in root-owned state before scanning. Every executable mapping line within the bounded 8 MiB procfs read is eligible for those stripes; the observer does not silently truncate the mapping table at a line-count prefix. A process table beyond that byte bound explicitly fails discovery health rather than silently omitting its suffix. Runtime evidence is accepted only from the running executable or an executable mapping backed by a structurally loadable ELF whose complete SHA-256 matches the qualified release pin; build IDs and symbol names are prefilters, not trust anchors. An ordinary open ELF descriptor or read-only data mapping is not runtime evidence. A deleted executable mapping may use a retained descriptor only when its kernel mount/inode identity matches that mapping. A supervisor restart advances the stripe generation instead of returning every still-live process to the first descriptor window.
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
This is not a general sandbox, universal AI detector, host intrusion detector,
|
| 27 |
malware detector, host-wide network isolator, credential isolator or EDR
|
| 28 |
replacement. An unapproved workload can evade recognition by using an
|
|
|
|
| 23 |
|
| 24 |
Heartbeat emission is tied to recent successful discovery completion and durable post-containment detection receipts, not merely to a live worker thread. A blocked or repeatedly failing scan, or any failure to persist a detection receipt after containment, therefore makes `doctor` report `UNSUPPORTED`, stops heartbeats and lets the independent watchdog apply its bounded fail-closed recovery. Receipt-storage health remains latched false until a root operator repairs storage and restarts the supervisor. Model descriptors and executable runtime mappings are inspected in bounded stripes, and the stripe generation is reserved in root-owned state before scanning. Every executable mapping line within the bounded 8 MiB procfs read is eligible for those stripes; the observer does not silently truncate the mapping table at a line-count prefix. A process table beyond that byte bound explicitly fails discovery health rather than silently omitting its suffix. Runtime evidence is accepted only from the running executable or an executable mapping backed by a structurally loadable ELF whose complete SHA-256 matches the qualified release pin; build IDs and symbol names are prefilters, not trust anchors. An ordinary open ELF descriptor or read-only data mapping is not runtime evidence. A deleted executable mapping may use a retained descriptor only when its kernel mount/inode identity matches that mapping. A supervisor restart advances the stripe generation instead of returning every still-live process to the first descriptor window.
|
| 25 |
|
| 26 |
+
The public first-kill path authenticates two separate release bindings with the
|
| 27 |
+
pinned release key: the annotated Git tag fixes the source commit, while the
|
| 28 |
+
detached `SHA256SUMS.asc` signature fixes the published binary assets. The
|
| 29 |
+
downloaded Linux bundle must match the signed checksum and its manifest/source
|
| 30 |
+
identity must match the signed tag before any bundled installer is run.
|
| 31 |
+
Duplicate, link, special, oversized and unsafe archive members are rejected
|
| 32 |
+
before extraction. An unsigned checksum file is not release authority.
|
| 33 |
+
|
| 34 |
This is not a general sandbox, universal AI detector, host intrusion detector,
|
| 35 |
malware detector, host-wide network isolator, credential isolator or EDR
|
| 36 |
replacement. An unapproved workload can evade recognition by using an
|
scripts/first_kill.py
CHANGED
|
@@ -12,6 +12,7 @@ import hashlib
|
|
| 12 |
import json
|
| 13 |
import os
|
| 14 |
import platform
|
|
|
|
| 15 |
import secrets
|
| 16 |
import shutil
|
| 17 |
import signal
|
|
@@ -23,7 +24,7 @@ import time
|
|
| 23 |
import urllib.error
|
| 24 |
import urllib.request
|
| 25 |
import zipfile
|
| 26 |
-
from pathlib import Path
|
| 27 |
from typing import Any
|
| 28 |
|
| 29 |
try:
|
|
@@ -48,6 +49,9 @@ INSTALL_TARGETS = (
|
|
| 48 |
Path("/etc/tmpfiles.d/lumi-eggcracker.conf"),
|
| 49 |
)
|
| 50 |
MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
| 51 |
DETECTIONS = Path("/var/lib/lumi-eggcracker/detections")
|
| 52 |
DEFAULT_AI_SMOKE_WORKSPACE = Path("/opt/lumi-eggcracker-ai-smoke")
|
| 53 |
QUALIFIED_LLAMA_SHA256 = "ef0b86d353638b74519079b5937b9d62b4d4c6c6cdbf68812d7898437ecc4fb5"
|
|
@@ -104,18 +108,61 @@ def parse_checksums(path: Path) -> dict[str, str]:
|
|
| 104 |
raise FirstKillError("release SHA256SUMS is unreadable") from error
|
| 105 |
for line in lines:
|
| 106 |
fields = line.split(maxsplit=1)
|
| 107 |
-
if len(fields) != 2 or len(fields[0]) != 64
|
|
|
|
|
|
|
| 108 |
raise FirstKillError("release SHA256SUMS contains an invalid line")
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
| 110 |
if not values:
|
| 111 |
raise FirstKillError("release SHA256SUMS is empty")
|
| 112 |
return values
|
| 113 |
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
def safe_extract(archive: Path, destination: Path) -> None:
|
| 116 |
try:
|
| 117 |
with zipfile.ZipFile(archive) as bundle:
|
| 118 |
-
members =
|
| 119 |
for member in members:
|
| 120 |
candidate = (destination / member.filename).resolve()
|
| 121 |
if not candidate.is_relative_to(destination.resolve()):
|
|
@@ -159,6 +206,22 @@ def require_regular(path: Path, description: str) -> None:
|
|
| 159 |
raise FirstKillError(f"{description} must be a regular file: {path}")
|
| 160 |
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
def operator_name(explicit: str | None) -> str:
|
| 163 |
if pwd is None:
|
| 164 |
raise FirstKillError("first-kill requires the POSIX passwd database")
|
|
@@ -271,49 +334,129 @@ def run_preflight(operator_value: str | None, tag: str) -> int:
|
|
| 271 |
return 0
|
| 272 |
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
def verify_tag(root: Path, tag: str, key: Path) -> str:
|
| 275 |
gpg_home = Path(tempfile.mkdtemp(prefix="eggcracker-gpg-"))
|
| 276 |
os.chmod(gpg_home, 0o700)
|
| 277 |
try:
|
| 278 |
-
|
| 279 |
-
if imported.returncode:
|
| 280 |
-
raise FirstKillError(imported.stderr.strip() or "release public key import failed")
|
| 281 |
-
shown = run(
|
| 282 |
-
["/usr/bin/gpg", "--homedir", str(gpg_home), "--batch", "--with-colons", "--show-keys", str(key)]
|
| 283 |
-
)
|
| 284 |
-
fingerprints = [line.split(":")[9].upper() for line in shown.stdout.splitlines() if line.startswith("fpr:")]
|
| 285 |
-
if RELEASE_KEY_FINGERPRINT not in fingerprints:
|
| 286 |
-
raise FirstKillError("downloaded release key fingerprint does not match the published fingerprint")
|
| 287 |
env = os.environ.copy()
|
| 288 |
env["GNUPGHOME"] = str(gpg_home)
|
| 289 |
-
verified = run(
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
| 293 |
commit = run(["/usr/bin/git", "-C", str(root), "rev-parse", f"{tag}^{{}}"])
|
| 294 |
return commit.stdout.strip()
|
| 295 |
finally:
|
| 296 |
shutil.rmtree(gpg_home, ignore_errors=True)
|
| 297 |
|
| 298 |
|
| 299 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
version = tag.removeprefix("v")
|
| 301 |
if not version.replace(".", "").isdigit() or version.count(".") != 2:
|
| 302 |
raise FirstKillError("tag must look like v1.0.0")
|
| 303 |
base = f"https://github.com/{REPOSITORY}/releases/download/{tag}"
|
| 304 |
sums = workspace / "SHA256SUMS"
|
|
|
|
| 305 |
bundle = workspace / f"lumi-eggcracker-{version}-linux.zip"
|
| 306 |
key = workspace / "eggcracker-release-key.asc"
|
| 307 |
download(f"{base}/SHA256SUMS", sums, maximum=64 * 1024)
|
| 308 |
-
|
| 309 |
download(f"{base}/{bundle.name}", bundle, maximum=8 * 1024 * 1024)
|
| 310 |
download(f"{base}/{key.name}", key, maximum=64 * 1024)
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
| 314 |
if b"BEGIN PGP PRIVATE KEY BLOCK" in key.read_bytes():
|
| 315 |
raise FirstKillError("release key asset unexpectedly contains private key material")
|
| 316 |
-
return bundle, key
|
| 317 |
|
| 318 |
|
| 319 |
def extracted_release(bundle: Path, workspace: Path) -> Path:
|
|
@@ -579,7 +722,14 @@ def prepare_workspace(path: Path) -> Path:
|
|
| 579 |
if path.exists():
|
| 580 |
if path.is_symlink() or not path.is_dir():
|
| 581 |
raise FirstKillError("--workspace must be a non-symlink directory")
|
| 582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
unexpected = [
|
| 584 |
item.name
|
| 585 |
for item in path.iterdir()
|
|
@@ -599,6 +749,7 @@ def prepare_workspace(path: Path) -> Path:
|
|
| 599 |
else:
|
| 600 |
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
| 601 |
os.chmod(path, 0o700)
|
|
|
|
| 602 |
return path
|
| 603 |
|
| 604 |
|
|
@@ -680,9 +831,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 680 |
ai_workspace_preexisting = ai_workspace.exists()
|
| 681 |
if ai_workspace.is_symlink() or (ai_workspace.exists() and not ai_workspace.is_dir()):
|
| 682 |
raise FirstKillError("--ai-workspace must be a non-symlink directory")
|
|
|
|
|
|
|
| 683 |
say(f"downloading and checking signed {args.tag} release assets")
|
| 684 |
-
bundle, key = release_files(args.tag, workspace)
|
| 685 |
commit = verify_tag(root, args.tag, key)
|
|
|
|
|
|
|
| 686 |
release_root = extracted_release(bundle, workspace)
|
| 687 |
release = manifest(release_root)
|
| 688 |
if release.get("source_commit") != commit:
|
|
|
|
| 12 |
import json
|
| 13 |
import os
|
| 14 |
import platform
|
| 15 |
+
import re
|
| 16 |
import secrets
|
| 17 |
import shutil
|
| 18 |
import signal
|
|
|
|
| 24 |
import urllib.error
|
| 25 |
import urllib.request
|
| 26 |
import zipfile
|
| 27 |
+
from pathlib import Path, PurePosixPath
|
| 28 |
from typing import Any
|
| 29 |
|
| 30 |
try:
|
|
|
|
| 49 |
Path("/etc/tmpfiles.d/lumi-eggcracker.conf"),
|
| 50 |
)
|
| 51 |
MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024
|
| 52 |
+
MAX_ARCHIVE_MEMBERS = 256
|
| 53 |
+
MAX_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
|
| 54 |
+
MAX_ARCHIVE_TOTAL_BYTES = 64 * 1024 * 1024
|
| 55 |
DETECTIONS = Path("/var/lib/lumi-eggcracker/detections")
|
| 56 |
DEFAULT_AI_SMOKE_WORKSPACE = Path("/opt/lumi-eggcracker-ai-smoke")
|
| 57 |
QUALIFIED_LLAMA_SHA256 = "ef0b86d353638b74519079b5937b9d62b4d4c6c6cdbf68812d7898437ecc4fb5"
|
|
|
|
| 108 |
raise FirstKillError("release SHA256SUMS is unreadable") from error
|
| 109 |
for line in lines:
|
| 110 |
fields = line.split(maxsplit=1)
|
| 111 |
+
if len(fields) != 2 or len(fields[0]) != 64 or any(
|
| 112 |
+
character not in "0123456789abcdefABCDEF" for character in fields[0]
|
| 113 |
+
):
|
| 114 |
raise FirstKillError("release SHA256SUMS contains an invalid line")
|
| 115 |
+
name = fields[1].removeprefix("*")
|
| 116 |
+
if not name or name in values:
|
| 117 |
+
raise FirstKillError("release SHA256SUMS contains a duplicate or empty name")
|
| 118 |
+
values[name] = fields[0].lower()
|
| 119 |
if not values:
|
| 120 |
raise FirstKillError("release SHA256SUMS is empty")
|
| 121 |
return values
|
| 122 |
|
| 123 |
|
| 124 |
+
def validated_zip_members(bundle: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
| 125 |
+
members = bundle.infolist()
|
| 126 |
+
if not members or len(members) > MAX_ARCHIVE_MEMBERS:
|
| 127 |
+
raise FirstKillError("release bundle has an invalid member count")
|
| 128 |
+
seen: set[str] = set()
|
| 129 |
+
total = 0
|
| 130 |
+
for member in members:
|
| 131 |
+
name = member.filename
|
| 132 |
+
path = PurePosixPath(name)
|
| 133 |
+
parts = path.parts
|
| 134 |
+
normalized = path.as_posix().rstrip("/")
|
| 135 |
+
if (
|
| 136 |
+
not name
|
| 137 |
+
or "\x00" in name
|
| 138 |
+
or "\\" in name
|
| 139 |
+
or path.is_absolute()
|
| 140 |
+
or not normalized
|
| 141 |
+
or any(part in ("", ".", "..") for part in parts)
|
| 142 |
+
or (len(parts[0]) >= 2 and parts[0][1] == ":")
|
| 143 |
+
):
|
| 144 |
+
raise FirstKillError("release bundle contains an unsafe path")
|
| 145 |
+
if normalized in seen:
|
| 146 |
+
raise FirstKillError("release bundle contains a duplicate path")
|
| 147 |
+
seen.add(normalized)
|
| 148 |
+
mode = (member.external_attr >> 16) & 0xFFFF
|
| 149 |
+
file_type = stat.S_IFMT(mode)
|
| 150 |
+
if member.flag_bits & 0x1 or file_type not in (0, stat.S_IFREG, stat.S_IFDIR):
|
| 151 |
+
raise FirstKillError("release bundle contains a link or special member")
|
| 152 |
+
if member.is_dir() != (file_type == stat.S_IFDIR) and file_type != 0:
|
| 153 |
+
raise FirstKillError("release bundle member type is inconsistent")
|
| 154 |
+
if member.file_size > MAX_ARCHIVE_MEMBER_BYTES:
|
| 155 |
+
raise FirstKillError("release bundle member exceeds the extraction limit")
|
| 156 |
+
total += member.file_size
|
| 157 |
+
if total > MAX_ARCHIVE_TOTAL_BYTES:
|
| 158 |
+
raise FirstKillError("release bundle exceeds the extraction limit")
|
| 159 |
+
return members
|
| 160 |
+
|
| 161 |
+
|
| 162 |
def safe_extract(archive: Path, destination: Path) -> None:
|
| 163 |
try:
|
| 164 |
with zipfile.ZipFile(archive) as bundle:
|
| 165 |
+
members = validated_zip_members(bundle)
|
| 166 |
for member in members:
|
| 167 |
candidate = (destination / member.filename).resolve()
|
| 168 |
if not candidate.is_relative_to(destination.resolve()):
|
|
|
|
| 206 |
raise FirstKillError(f"{description} must be a regular file: {path}")
|
| 207 |
|
| 208 |
|
| 209 |
+
def require_root_directory(path: Path, description: str, *, private: bool) -> None:
|
| 210 |
+
try:
|
| 211 |
+
metadata = path.lstat()
|
| 212 |
+
except OSError as error:
|
| 213 |
+
raise FirstKillError(f"{description} is missing: {path}") from error
|
| 214 |
+
forbidden_mode = 0o077 if private else 0o022
|
| 215 |
+
if (
|
| 216 |
+
path.is_symlink()
|
| 217 |
+
or not stat.S_ISDIR(metadata.st_mode)
|
| 218 |
+
or metadata.st_uid != 0
|
| 219 |
+
or stat.S_IMODE(metadata.st_mode) & forbidden_mode
|
| 220 |
+
):
|
| 221 |
+
access = "root-private" if private else "root-owned and not writable by group/other"
|
| 222 |
+
raise FirstKillError(f"{description} must be a {access} directory: {path}")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
def operator_name(explicit: str | None) -> str:
|
| 226 |
if pwd is None:
|
| 227 |
raise FirstKillError("first-kill requires the POSIX passwd database")
|
|
|
|
| 334 |
return 0
|
| 335 |
|
| 336 |
|
| 337 |
+
def import_release_key(gpg_home: Path, key: Path) -> None:
|
| 338 |
+
imported = run(
|
| 339 |
+
["/usr/bin/gpg", "--homedir", str(gpg_home), "--batch", "--import", str(key)],
|
| 340 |
+
check=False,
|
| 341 |
+
)
|
| 342 |
+
if imported.returncode:
|
| 343 |
+
raise FirstKillError(imported.stderr.strip() or "release public key import failed")
|
| 344 |
+
shown = run(
|
| 345 |
+
[
|
| 346 |
+
"/usr/bin/gpg",
|
| 347 |
+
"--homedir",
|
| 348 |
+
str(gpg_home),
|
| 349 |
+
"--batch",
|
| 350 |
+
"--with-colons",
|
| 351 |
+
"--show-keys",
|
| 352 |
+
str(key),
|
| 353 |
+
]
|
| 354 |
+
)
|
| 355 |
+
primary_fingerprints: list[str] = []
|
| 356 |
+
expect_primary_fingerprint = False
|
| 357 |
+
for line in shown.stdout.splitlines():
|
| 358 |
+
fields = line.split(":")
|
| 359 |
+
if fields[0] == "pub":
|
| 360 |
+
expect_primary_fingerprint = True
|
| 361 |
+
elif fields[0] == "fpr" and expect_primary_fingerprint:
|
| 362 |
+
primary_fingerprints.append(fields[9].upper())
|
| 363 |
+
expect_primary_fingerprint = False
|
| 364 |
+
if primary_fingerprints != [RELEASE_KEY_FINGERPRINT]:
|
| 365 |
+
raise FirstKillError(
|
| 366 |
+
"downloaded release key does not contain exactly the published primary key"
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def require_pinned_valid_signature(
|
| 371 |
+
result: subprocess.CompletedProcess[str], description: str
|
| 372 |
+
) -> None:
|
| 373 |
+
if result.returncode:
|
| 374 |
+
detail = (result.stderr or result.stdout).strip()
|
| 375 |
+
raise FirstKillError(f"{description} verification failed: {detail}")
|
| 376 |
+
fingerprints: set[str] = set()
|
| 377 |
+
marker = "[GNUPG:] VALIDSIG "
|
| 378 |
+
for line in (result.stdout + "\n" + result.stderr).splitlines():
|
| 379 |
+
position = line.find(marker)
|
| 380 |
+
if position < 0:
|
| 381 |
+
continue
|
| 382 |
+
for value in line[position + len(marker) :].split():
|
| 383 |
+
if re.fullmatch(r"[0-9A-Fa-f]{40}", value):
|
| 384 |
+
fingerprints.add(value.upper())
|
| 385 |
+
if RELEASE_KEY_FINGERPRINT not in fingerprints:
|
| 386 |
+
raise FirstKillError(f"{description} was not signed by the pinned release key")
|
| 387 |
+
|
| 388 |
+
|
| 389 |
def verify_tag(root: Path, tag: str, key: Path) -> str:
|
| 390 |
gpg_home = Path(tempfile.mkdtemp(prefix="eggcracker-gpg-"))
|
| 391 |
os.chmod(gpg_home, 0o700)
|
| 392 |
try:
|
| 393 |
+
import_release_key(gpg_home, key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
env = os.environ.copy()
|
| 395 |
env["GNUPGHOME"] = str(gpg_home)
|
| 396 |
+
verified = run(
|
| 397 |
+
["/usr/bin/git", "-C", str(root), "verify-tag", "--raw", tag],
|
| 398 |
+
env=env,
|
| 399 |
+
check=False,
|
| 400 |
+
)
|
| 401 |
+
require_pinned_valid_signature(verified, "signed tag")
|
| 402 |
commit = run(["/usr/bin/git", "-C", str(root), "rev-parse", f"{tag}^{{}}"])
|
| 403 |
return commit.stdout.strip()
|
| 404 |
finally:
|
| 405 |
shutil.rmtree(gpg_home, ignore_errors=True)
|
| 406 |
|
| 407 |
|
| 408 |
+
def verify_checksum_signature(key: Path, sums: Path, signature: Path) -> None:
|
| 409 |
+
gpg_home = Path(tempfile.mkdtemp(prefix="eggcracker-gpg-"))
|
| 410 |
+
os.chmod(gpg_home, 0o700)
|
| 411 |
+
try:
|
| 412 |
+
import_release_key(gpg_home, key)
|
| 413 |
+
verified = run(
|
| 414 |
+
[
|
| 415 |
+
"/usr/bin/gpg",
|
| 416 |
+
"--homedir",
|
| 417 |
+
str(gpg_home),
|
| 418 |
+
"--batch",
|
| 419 |
+
"--status-fd",
|
| 420 |
+
"1",
|
| 421 |
+
"--verify",
|
| 422 |
+
str(signature),
|
| 423 |
+
str(sums),
|
| 424 |
+
],
|
| 425 |
+
check=False,
|
| 426 |
+
)
|
| 427 |
+
require_pinned_valid_signature(verified, "release checksum signature")
|
| 428 |
+
finally:
|
| 429 |
+
shutil.rmtree(gpg_home, ignore_errors=True)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def verify_bundle_checksum(bundle: Path, sums: Path) -> None:
|
| 433 |
+
expected = parse_checksums(sums)
|
| 434 |
+
if expected.get(bundle.name) != digest(bundle):
|
| 435 |
+
raise FirstKillError("downloaded Linux bundle does not match signed SHA256SUMS")
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def release_files(tag: str, workspace: Path) -> tuple[Path, Path, Path, Path]:
|
| 439 |
version = tag.removeprefix("v")
|
| 440 |
if not version.replace(".", "").isdigit() or version.count(".") != 2:
|
| 441 |
raise FirstKillError("tag must look like v1.0.0")
|
| 442 |
base = f"https://github.com/{REPOSITORY}/releases/download/{tag}"
|
| 443 |
sums = workspace / "SHA256SUMS"
|
| 444 |
+
signature = workspace / "SHA256SUMS.asc"
|
| 445 |
bundle = workspace / f"lumi-eggcracker-{version}-linux.zip"
|
| 446 |
key = workspace / "eggcracker-release-key.asc"
|
| 447 |
download(f"{base}/SHA256SUMS", sums, maximum=64 * 1024)
|
| 448 |
+
download(f"{base}/SHA256SUMS.asc", signature, maximum=64 * 1024)
|
| 449 |
download(f"{base}/{bundle.name}", bundle, maximum=8 * 1024 * 1024)
|
| 450 |
download(f"{base}/{key.name}", key, maximum=64 * 1024)
|
| 451 |
+
for path, description in (
|
| 452 |
+
(sums, "release SHA256SUMS"),
|
| 453 |
+
(signature, "release checksum signature"),
|
| 454 |
+
(key, "release public key"),
|
| 455 |
+
):
|
| 456 |
+
require_regular(path, description)
|
| 457 |
if b"BEGIN PGP PRIVATE KEY BLOCK" in key.read_bytes():
|
| 458 |
raise FirstKillError("release key asset unexpectedly contains private key material")
|
| 459 |
+
return bundle, key, sums, signature
|
| 460 |
|
| 461 |
|
| 462 |
def extracted_release(bundle: Path, workspace: Path) -> Path:
|
|
|
|
| 722 |
if path.exists():
|
| 723 |
if path.is_symlink() or not path.is_dir():
|
| 724 |
raise FirstKillError("--workspace must be a non-symlink directory")
|
| 725 |
+
require_root_directory(path, "--workspace", private=True)
|
| 726 |
+
allowed = {
|
| 727 |
+
"ai-smoke",
|
| 728 |
+
"release",
|
| 729 |
+
"SHA256SUMS",
|
| 730 |
+
"SHA256SUMS.asc",
|
| 731 |
+
"eggcracker-release-key.asc",
|
| 732 |
+
}
|
| 733 |
unexpected = [
|
| 734 |
item.name
|
| 735 |
for item in path.iterdir()
|
|
|
|
| 749 |
else:
|
| 750 |
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
| 751 |
os.chmod(path, 0o700)
|
| 752 |
+
require_root_directory(path, "--workspace", private=True)
|
| 753 |
return path
|
| 754 |
|
| 755 |
|
|
|
|
| 831 |
ai_workspace_preexisting = ai_workspace.exists()
|
| 832 |
if ai_workspace.is_symlink() or (ai_workspace.exists() and not ai_workspace.is_dir()):
|
| 833 |
raise FirstKillError("--ai-workspace must be a non-symlink directory")
|
| 834 |
+
if ai_workspace_preexisting:
|
| 835 |
+
require_root_directory(ai_workspace, "--ai-workspace", private=False)
|
| 836 |
say(f"downloading and checking signed {args.tag} release assets")
|
| 837 |
+
bundle, key, sums, signature = release_files(args.tag, workspace)
|
| 838 |
commit = verify_tag(root, args.tag, key)
|
| 839 |
+
verify_checksum_signature(key, sums, signature)
|
| 840 |
+
verify_bundle_checksum(bundle, sums)
|
| 841 |
release_root = extracted_release(bundle, workspace)
|
| 842 |
release = manifest(release_root)
|
| 843 |
if release.get("source_commit") != commit:
|
scripts/verify_release.py
CHANGED
|
@@ -6,10 +6,11 @@ import argparse
|
|
| 6 |
import hashlib
|
| 7 |
import json
|
| 8 |
import re
|
|
|
|
| 9 |
import subprocess
|
| 10 |
import sys
|
| 11 |
import zipfile
|
| 12 |
-
from pathlib import Path
|
| 13 |
|
| 14 |
FORBIDDEN = (
|
| 15 |
"/mnt/" + "f/",
|
|
@@ -23,14 +24,55 @@ FORBIDDEN = (
|
|
| 23 |
"skylark" + " sentinel",
|
| 24 |
"skylark" + "-sentinel",
|
| 25 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def text_from_zip(path: Path) -> str:
|
| 29 |
with zipfile.ZipFile(path) as archive:
|
| 30 |
return "\n".join(
|
| 31 |
-
archive.read(
|
| 32 |
-
for
|
| 33 |
-
if
|
| 34 |
)
|
| 35 |
|
| 36 |
|
|
@@ -50,10 +92,14 @@ def checksums(path: Path) -> dict[str, str]:
|
|
| 50 |
|
| 51 |
def artifact_source_commit(path: Path) -> str:
|
| 52 |
with zipfile.ZipFile(path) as archive:
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
match = re.fullmatch(b'SOURCE_COMMIT = "([0-9a-f]{40})"\r?\n', raw)
|
| 58 |
if match is None:
|
| 59 |
raise SystemExit("artifact source identity is invalid")
|
|
@@ -148,7 +194,8 @@ def main() -> int:
|
|
| 148 |
)
|
| 149 |
}
|
| 150 |
with zipfile.ZipFile(args.release_bundle) as archive:
|
| 151 |
-
|
|
|
|
| 152 |
raise SystemExit("release bundle contents are inconsistent")
|
| 153 |
if (
|
| 154 |
digest_bytes(archive.read(prefix + args.artifact.name)) != manifest["sha256"]
|
|
|
|
| 6 |
import hashlib
|
| 7 |
import json
|
| 8 |
import re
|
| 9 |
+
import stat
|
| 10 |
import subprocess
|
| 11 |
import sys
|
| 12 |
import zipfile
|
| 13 |
+
from pathlib import Path, PurePosixPath
|
| 14 |
|
| 15 |
FORBIDDEN = (
|
| 16 |
"/mnt/" + "f/",
|
|
|
|
| 24 |
"skylark" + " sentinel",
|
| 25 |
"skylark" + "-sentinel",
|
| 26 |
)
|
| 27 |
+
MAX_ARCHIVE_MEMBERS = 256
|
| 28 |
+
MAX_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
|
| 29 |
+
MAX_ARCHIVE_TOTAL_BYTES = 64 * 1024 * 1024
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def validated_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
| 33 |
+
members = archive.infolist()
|
| 34 |
+
if not members or len(members) > MAX_ARCHIVE_MEMBERS:
|
| 35 |
+
raise SystemExit("release archive has an invalid member count")
|
| 36 |
+
seen: set[str] = set()
|
| 37 |
+
total = 0
|
| 38 |
+
for member in members:
|
| 39 |
+
name = member.filename
|
| 40 |
+
path = PurePosixPath(name)
|
| 41 |
+
parts = path.parts
|
| 42 |
+
normalized = path.as_posix().rstrip("/")
|
| 43 |
+
if (
|
| 44 |
+
not name
|
| 45 |
+
or "\x00" in name
|
| 46 |
+
or "\\" in name
|
| 47 |
+
or path.is_absolute()
|
| 48 |
+
or not normalized
|
| 49 |
+
or any(part in ("", ".", "..") for part in parts)
|
| 50 |
+
or (len(parts[0]) >= 2 and parts[0][1] == ":")
|
| 51 |
+
):
|
| 52 |
+
raise SystemExit("release archive contains an unsafe path")
|
| 53 |
+
if normalized in seen:
|
| 54 |
+
raise SystemExit("release archive contains a duplicate path")
|
| 55 |
+
seen.add(normalized)
|
| 56 |
+
mode = (member.external_attr >> 16) & 0xFFFF
|
| 57 |
+
file_type = stat.S_IFMT(mode)
|
| 58 |
+
if member.flag_bits & 0x1 or file_type not in (0, stat.S_IFREG, stat.S_IFDIR):
|
| 59 |
+
raise SystemExit("release archive contains a link or special member")
|
| 60 |
+
if member.is_dir() != (file_type == stat.S_IFDIR) and file_type != 0:
|
| 61 |
+
raise SystemExit("release archive member type is inconsistent")
|
| 62 |
+
if member.file_size > MAX_ARCHIVE_MEMBER_BYTES:
|
| 63 |
+
raise SystemExit("release archive member exceeds the verification limit")
|
| 64 |
+
total += member.file_size
|
| 65 |
+
if total > MAX_ARCHIVE_TOTAL_BYTES:
|
| 66 |
+
raise SystemExit("release archive exceeds the verification limit")
|
| 67 |
+
return members
|
| 68 |
|
| 69 |
|
| 70 |
def text_from_zip(path: Path) -> str:
|
| 71 |
with zipfile.ZipFile(path) as archive:
|
| 72 |
return "\n".join(
|
| 73 |
+
archive.read(member).decode("utf-8", errors="ignore").lower()
|
| 74 |
+
for member in validated_members(archive)
|
| 75 |
+
if member.filename.endswith((".py", ".md", ".toml", ".txt"))
|
| 76 |
)
|
| 77 |
|
| 78 |
|
|
|
|
| 92 |
|
| 93 |
def artifact_source_commit(path: Path) -> str:
|
| 94 |
with zipfile.ZipFile(path) as archive:
|
| 95 |
+
matches = [
|
| 96 |
+
member
|
| 97 |
+
for member in validated_members(archive)
|
| 98 |
+
if member.filename == "lumi_eggcracker/build_info.py"
|
| 99 |
+
]
|
| 100 |
+
if len(matches) != 1:
|
| 101 |
+
raise SystemExit("artifact source identity is missing")
|
| 102 |
+
raw = archive.read(matches[0])
|
| 103 |
match = re.fullmatch(b'SOURCE_COMMIT = "([0-9a-f]{40})"\r?\n', raw)
|
| 104 |
if match is None:
|
| 105 |
raise SystemExit("artifact source identity is invalid")
|
|
|
|
| 194 |
)
|
| 195 |
}
|
| 196 |
with zipfile.ZipFile(args.release_bundle) as archive:
|
| 197 |
+
members = validated_members(archive)
|
| 198 |
+
if {member.filename for member in members} != expected:
|
| 199 |
raise SystemExit("release bundle contents are inconsistent")
|
| 200 |
if (
|
| 201 |
digest_bytes(archive.read(prefix + args.artifact.name)) != manifest["sha256"]
|
tests/test_first_kill.py
CHANGED
|
@@ -5,8 +5,10 @@ import importlib.util
|
|
| 5 |
import io
|
| 6 |
import json
|
| 7 |
import os
|
|
|
|
| 8 |
import tempfile
|
| 9 |
import unittest
|
|
|
|
| 10 |
import zipfile
|
| 11 |
from pathlib import Path
|
| 12 |
from unittest import mock
|
|
@@ -38,6 +40,10 @@ class FirstKillTests(unittest.TestCase):
|
|
| 38 |
mock.patch.object(first_kill, "prepare_workspace") as prepare_workspace,
|
| 39 |
mock.patch.object(first_kill, "release_files") as release_files,
|
| 40 |
mock.patch.object(first_kill, "verify_tag") as verify_tag,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
mock.patch.object(first_kill, "install_release") as install_release,
|
| 42 |
mock.patch.object(first_kill, "run_real_smoke") as run_real_smoke,
|
| 43 |
mock.patch.object(first_kill.tempfile, "mkdtemp") as make_temporary,
|
|
@@ -52,6 +58,8 @@ class FirstKillTests(unittest.TestCase):
|
|
| 52 |
prepare_workspace,
|
| 53 |
release_files,
|
| 54 |
verify_tag,
|
|
|
|
|
|
|
| 55 |
install_release,
|
| 56 |
run_real_smoke,
|
| 57 |
make_temporary,
|
|
@@ -72,6 +80,79 @@ class FirstKillTests(unittest.TestCase):
|
|
| 72 |
first_kill.main(["--operator", "tester"])
|
| 73 |
self.assertEqual(2, raised.exception.code)
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
def test_preflight_rejects_mutation_only_flags(self) -> None:
|
| 76 |
with (
|
| 77 |
contextlib.redirect_stderr(io.StringIO()),
|
|
@@ -151,6 +232,19 @@ class FirstKillTests(unittest.TestCase):
|
|
| 151 |
path.write_text("a" * 64 + " payload.zip\n", encoding="ascii")
|
| 152 |
self.assertEqual({"payload.zip": "a" * 64}, first_kill.parse_checksums(path))
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def test_safe_extract_rejects_path_traversal(self) -> None:
|
| 155 |
with tempfile.TemporaryDirectory() as raw:
|
| 156 |
root = Path(raw)
|
|
@@ -160,6 +254,97 @@ class FirstKillTests(unittest.TestCase):
|
|
| 160 |
with self.assertRaisesRegex(first_kill.FirstKillError, "unsafe path"):
|
| 161 |
first_kill.safe_extract(archive, root / "out")
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
def test_public_key_fingerprint_is_pinned(self) -> None:
|
| 164 |
self.assertEqual(40, len(first_kill.RELEASE_KEY_FINGERPRINT))
|
| 165 |
self.assertEqual(first_kill.RELEASE_KEY_FINGERPRINT.upper(), first_kill.RELEASE_KEY_FINGERPRINT)
|
|
|
|
| 5 |
import io
|
| 6 |
import json
|
| 7 |
import os
|
| 8 |
+
import stat
|
| 9 |
import tempfile
|
| 10 |
import unittest
|
| 11 |
+
import warnings
|
| 12 |
import zipfile
|
| 13 |
from pathlib import Path
|
| 14 |
from unittest import mock
|
|
|
|
| 40 |
mock.patch.object(first_kill, "prepare_workspace") as prepare_workspace,
|
| 41 |
mock.patch.object(first_kill, "release_files") as release_files,
|
| 42 |
mock.patch.object(first_kill, "verify_tag") as verify_tag,
|
| 43 |
+
mock.patch.object(
|
| 44 |
+
first_kill, "verify_checksum_signature"
|
| 45 |
+
) as verify_checksum_signature,
|
| 46 |
+
mock.patch.object(first_kill, "verify_bundle_checksum") as verify_bundle_checksum,
|
| 47 |
mock.patch.object(first_kill, "install_release") as install_release,
|
| 48 |
mock.patch.object(first_kill, "run_real_smoke") as run_real_smoke,
|
| 49 |
mock.patch.object(first_kill.tempfile, "mkdtemp") as make_temporary,
|
|
|
|
| 58 |
prepare_workspace,
|
| 59 |
release_files,
|
| 60 |
verify_tag,
|
| 61 |
+
verify_checksum_signature,
|
| 62 |
+
verify_bundle_checksum,
|
| 63 |
install_release,
|
| 64 |
run_real_smoke,
|
| 65 |
make_temporary,
|
|
|
|
| 80 |
first_kill.main(["--operator", "tester"])
|
| 81 |
self.assertEqual(2, raised.exception.code)
|
| 82 |
|
| 83 |
+
def test_normal_run_authenticates_checksums_before_extraction_or_install(self) -> None:
|
| 84 |
+
events: list[str] = []
|
| 85 |
+
release = {
|
| 86 |
+
"artifact": "lumi-eggcracker-1.0.0.pyz",
|
| 87 |
+
"sha256": "a" * 64,
|
| 88 |
+
"source_archive": "lumi-eggcracker-1.0.0-source.zip",
|
| 89 |
+
"source_commit": TAG_COMMIT,
|
| 90 |
+
"version": "1.0.0",
|
| 91 |
+
}
|
| 92 |
+
receipt = {
|
| 93 |
+
"result": "TERMINATED",
|
| 94 |
+
"containment": {"surviving_pids": [], "root_populated": 0},
|
| 95 |
+
}
|
| 96 |
+
with (
|
| 97 |
+
mock.patch.object(first_kill, "operator_name", return_value="tester"),
|
| 98 |
+
mock.patch.object(first_kill, "compatibility"),
|
| 99 |
+
mock.patch.object(first_kill, "repository_root", return_value=Path("/checkout")),
|
| 100 |
+
mock.patch.object(
|
| 101 |
+
first_kill, "prepare_workspace", return_value=Path("/private-workspace")
|
| 102 |
+
),
|
| 103 |
+
mock.patch.object(
|
| 104 |
+
first_kill,
|
| 105 |
+
"release_files",
|
| 106 |
+
return_value=(
|
| 107 |
+
Path("/bundle.zip"),
|
| 108 |
+
Path("/key.asc"),
|
| 109 |
+
Path("/SHA256SUMS"),
|
| 110 |
+
Path("/SHA256SUMS.asc"),
|
| 111 |
+
),
|
| 112 |
+
),
|
| 113 |
+
mock.patch.object(first_kill, "verify_tag", return_value=TAG_COMMIT),
|
| 114 |
+
mock.patch.object(
|
| 115 |
+
first_kill,
|
| 116 |
+
"verify_checksum_signature",
|
| 117 |
+
side_effect=lambda *_: events.append("signature"),
|
| 118 |
+
),
|
| 119 |
+
mock.patch.object(
|
| 120 |
+
first_kill,
|
| 121 |
+
"verify_bundle_checksum",
|
| 122 |
+
side_effect=lambda *_: events.append("checksum"),
|
| 123 |
+
),
|
| 124 |
+
mock.patch.object(
|
| 125 |
+
first_kill,
|
| 126 |
+
"extracted_release",
|
| 127 |
+
side_effect=lambda *_: events.append("extract") or Path("/release"),
|
| 128 |
+
),
|
| 129 |
+
mock.patch.object(first_kill, "manifest", return_value=release),
|
| 130 |
+
mock.patch.object(first_kill, "run"),
|
| 131 |
+
mock.patch.object(
|
| 132 |
+
first_kill,
|
| 133 |
+
"install_release",
|
| 134 |
+
side_effect=lambda *_: events.append("install"),
|
| 135 |
+
),
|
| 136 |
+
mock.patch.object(
|
| 137 |
+
first_kill, "installed_workload_user", return_value="workload"
|
| 138 |
+
),
|
| 139 |
+
mock.patch.object(first_kill, "run_real_smoke", return_value=receipt),
|
| 140 |
+
mock.patch.object(first_kill, "remove_installation"),
|
| 141 |
+
contextlib.redirect_stdout(io.StringIO()),
|
| 142 |
+
):
|
| 143 |
+
result = first_kill.main(
|
| 144 |
+
[
|
| 145 |
+
"--operator",
|
| 146 |
+
"tester",
|
| 147 |
+
"--workspace",
|
| 148 |
+
"/private-workspace",
|
| 149 |
+
"--accept-third-party-downloads",
|
| 150 |
+
"--remove",
|
| 151 |
+
]
|
| 152 |
+
)
|
| 153 |
+
self.assertEqual(0, result)
|
| 154 |
+
self.assertEqual(["signature", "checksum", "extract", "install"], events)
|
| 155 |
+
|
| 156 |
def test_preflight_rejects_mutation_only_flags(self) -> None:
|
| 157 |
with (
|
| 158 |
contextlib.redirect_stderr(io.StringIO()),
|
|
|
|
| 232 |
path.write_text("a" * 64 + " payload.zip\n", encoding="ascii")
|
| 233 |
self.assertEqual({"payload.zip": "a" * 64}, first_kill.parse_checksums(path))
|
| 234 |
|
| 235 |
+
def test_checksums_reject_non_hex_and_duplicate_names(self) -> None:
|
| 236 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 237 |
+
path = Path(raw) / "SHA256SUMS"
|
| 238 |
+
path.write_text("z" * 64 + " payload.zip\n", encoding="ascii")
|
| 239 |
+
with self.assertRaisesRegex(first_kill.FirstKillError, "invalid line"):
|
| 240 |
+
first_kill.parse_checksums(path)
|
| 241 |
+
path.write_text(
|
| 242 |
+
"a" * 64 + " payload.zip\n" + "b" * 64 + " payload.zip\n",
|
| 243 |
+
encoding="ascii",
|
| 244 |
+
)
|
| 245 |
+
with self.assertRaisesRegex(first_kill.FirstKillError, "duplicate"):
|
| 246 |
+
first_kill.parse_checksums(path)
|
| 247 |
+
|
| 248 |
def test_safe_extract_rejects_path_traversal(self) -> None:
|
| 249 |
with tempfile.TemporaryDirectory() as raw:
|
| 250 |
root = Path(raw)
|
|
|
|
| 254 |
with self.assertRaisesRegex(first_kill.FirstKillError, "unsafe path"):
|
| 255 |
first_kill.safe_extract(archive, root / "out")
|
| 256 |
|
| 257 |
+
def test_safe_extract_rejects_duplicate_paths(self) -> None:
|
| 258 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 259 |
+
root = Path(raw)
|
| 260 |
+
archive = root / "duplicate.zip"
|
| 261 |
+
with warnings.catch_warnings():
|
| 262 |
+
warnings.simplefilter("ignore", UserWarning)
|
| 263 |
+
with zipfile.ZipFile(archive, "w") as bundle:
|
| 264 |
+
bundle.writestr("release/install.py", "trusted")
|
| 265 |
+
bundle.writestr("release/install.py", "hostile")
|
| 266 |
+
with self.assertRaisesRegex(first_kill.FirstKillError, "duplicate path"):
|
| 267 |
+
first_kill.safe_extract(archive, root / "out")
|
| 268 |
+
|
| 269 |
+
def test_safe_extract_rejects_symlink_members(self) -> None:
|
| 270 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 271 |
+
root = Path(raw)
|
| 272 |
+
archive = root / "link.zip"
|
| 273 |
+
member = zipfile.ZipInfo("release/install.py")
|
| 274 |
+
member.create_system = 3
|
| 275 |
+
member.external_attr = (stat.S_IFLNK | 0o777) << 16
|
| 276 |
+
with zipfile.ZipFile(archive, "w") as bundle:
|
| 277 |
+
bundle.writestr(member, "../../outside")
|
| 278 |
+
with self.assertRaisesRegex(first_kill.FirstKillError, "link or special"):
|
| 279 |
+
first_kill.safe_extract(archive, root / "out")
|
| 280 |
+
|
| 281 |
+
def test_checksum_signature_requires_valid_pinned_key_signature(self) -> None:
|
| 282 |
+
imported = mock.Mock(returncode=0, stdout="", stderr="")
|
| 283 |
+
shown = mock.Mock(
|
| 284 |
+
returncode=0,
|
| 285 |
+
stdout=(
|
| 286 |
+
"pub:::::::::\n"
|
| 287 |
+
f"fpr:::::::::{first_kill.RELEASE_KEY_FINGERPRINT}:\n"
|
| 288 |
+
),
|
| 289 |
+
stderr="",
|
| 290 |
+
)
|
| 291 |
+
verified = mock.Mock(
|
| 292 |
+
returncode=0,
|
| 293 |
+
stdout=(
|
| 294 |
+
"[GNUPG:] VALIDSIG "
|
| 295 |
+
f"{first_kill.RELEASE_KEY_FINGERPRINT} 2026 0 0 4 0 1 10 00 "
|
| 296 |
+
f"{first_kill.RELEASE_KEY_FINGERPRINT}\n"
|
| 297 |
+
),
|
| 298 |
+
stderr="",
|
| 299 |
+
)
|
| 300 |
+
with mock.patch.object(first_kill, "run", side_effect=[imported, shown, verified]) as run:
|
| 301 |
+
first_kill.verify_checksum_signature(
|
| 302 |
+
Path("/release-key.asc"),
|
| 303 |
+
Path("/SHA256SUMS"),
|
| 304 |
+
Path("/SHA256SUMS.asc"),
|
| 305 |
+
)
|
| 306 |
+
self.assertIn("--verify", run.call_args_list[-1].args[0])
|
| 307 |
+
|
| 308 |
+
def test_checksum_signature_failure_is_fatal(self) -> None:
|
| 309 |
+
imported = mock.Mock(returncode=0, stdout="", stderr="")
|
| 310 |
+
shown = mock.Mock(
|
| 311 |
+
returncode=0,
|
| 312 |
+
stdout=(
|
| 313 |
+
"pub:::::::::\n"
|
| 314 |
+
f"fpr:::::::::{first_kill.RELEASE_KEY_FINGERPRINT}:\n"
|
| 315 |
+
),
|
| 316 |
+
stderr="",
|
| 317 |
+
)
|
| 318 |
+
rejected = mock.Mock(returncode=1, stdout="", stderr="BAD signature")
|
| 319 |
+
with (
|
| 320 |
+
mock.patch.object(first_kill, "run", side_effect=[imported, shown, rejected]),
|
| 321 |
+
self.assertRaisesRegex(first_kill.FirstKillError, "signature verification failed"),
|
| 322 |
+
):
|
| 323 |
+
first_kill.verify_checksum_signature(
|
| 324 |
+
Path("/release-key.asc"),
|
| 325 |
+
Path("/SHA256SUMS"),
|
| 326 |
+
Path("/SHA256SUMS.asc"),
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
def test_release_key_bundle_cannot_add_an_attacker_primary_key(self) -> None:
|
| 330 |
+
attacker = "B" * 40
|
| 331 |
+
imported = mock.Mock(returncode=0, stdout="", stderr="")
|
| 332 |
+
shown = mock.Mock(
|
| 333 |
+
returncode=0,
|
| 334 |
+
stdout=(
|
| 335 |
+
"pub:::::::::\n"
|
| 336 |
+
f"fpr:::::::::{first_kill.RELEASE_KEY_FINGERPRINT}:\n"
|
| 337 |
+
"pub:::::::::\n"
|
| 338 |
+
f"fpr:::::::::{attacker}:\n"
|
| 339 |
+
),
|
| 340 |
+
stderr="",
|
| 341 |
+
)
|
| 342 |
+
with (
|
| 343 |
+
mock.patch.object(first_kill, "run", side_effect=[imported, shown]),
|
| 344 |
+
self.assertRaisesRegex(first_kill.FirstKillError, "exactly the published"),
|
| 345 |
+
):
|
| 346 |
+
first_kill.import_release_key(Path("/gpg-home"), Path("/release-key.asc"))
|
| 347 |
+
|
| 348 |
def test_public_key_fingerprint_is_pinned(self) -> None:
|
| 349 |
self.assertEqual(40, len(first_kill.RELEASE_KEY_FINGERPRINT))
|
| 350 |
self.assertEqual(first_kill.RELEASE_KEY_FINGERPRINT.upper(), first_kill.RELEASE_KEY_FINGERPRINT)
|
tests/test_release_archive.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
import stat
|
| 5 |
+
import tempfile
|
| 6 |
+
import unittest
|
| 7 |
+
import warnings
|
| 8 |
+
import zipfile
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
SPEC = importlib.util.spec_from_file_location(
|
| 13 |
+
"verify_release", ROOT / "scripts" / "verify_release.py"
|
| 14 |
+
)
|
| 15 |
+
if SPEC is None or SPEC.loader is None:
|
| 16 |
+
raise RuntimeError("cannot load release verifier")
|
| 17 |
+
verify_release = importlib.util.module_from_spec(SPEC)
|
| 18 |
+
SPEC.loader.exec_module(verify_release)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ReleaseArchiveTests(unittest.TestCase):
|
| 22 |
+
def test_verifier_rejects_duplicate_members(self) -> None:
|
| 23 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 24 |
+
archive_path = Path(raw) / "duplicate.zip"
|
| 25 |
+
with warnings.catch_warnings():
|
| 26 |
+
warnings.simplefilter("ignore", UserWarning)
|
| 27 |
+
with zipfile.ZipFile(archive_path, "w") as archive:
|
| 28 |
+
archive.writestr("release/install.py", "trusted")
|
| 29 |
+
archive.writestr("release/install.py", "hostile")
|
| 30 |
+
with (
|
| 31 |
+
zipfile.ZipFile(archive_path) as archive,
|
| 32 |
+
self.assertRaisesRegex(SystemExit, "duplicate path"),
|
| 33 |
+
):
|
| 34 |
+
verify_release.validated_members(archive)
|
| 35 |
+
|
| 36 |
+
def test_verifier_rejects_special_members(self) -> None:
|
| 37 |
+
with tempfile.TemporaryDirectory() as raw:
|
| 38 |
+
archive_path = Path(raw) / "special.zip"
|
| 39 |
+
member = zipfile.ZipInfo("release/install.py")
|
| 40 |
+
member.create_system = 3
|
| 41 |
+
member.external_attr = (stat.S_IFIFO | 0o600) << 16
|
| 42 |
+
with zipfile.ZipFile(archive_path, "w") as archive:
|
| 43 |
+
archive.writestr(member, "not-a-file")
|
| 44 |
+
with (
|
| 45 |
+
zipfile.ZipFile(archive_path) as archive,
|
| 46 |
+
self.assertRaisesRegex(SystemExit, "link or special"),
|
| 47 |
+
):
|
| 48 |
+
verify_release.validated_members(archive)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
unittest.main()
|