noqt commited on
Commit
38403fa
·
1 Parent(s): f4e8387

fix: authenticate public release assets

Browse files
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; the full run verifies the signature and
110
- requires the signed tag commit to match the release manifest.
 
 
 
 
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, downloads and verifies the signed
194
- release assets, installs the root-controlled supervisor, downloads the pinned
195
- demo model only after the explicit acceptance flag, launches the real model,
196
- prints the kill receipt, and offers clean removal. Use `--remove` for a
 
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. For a controlled
263
- installation, use the signed Linux bundle and its `SHA256SUMS`, then run the
264
- bundled installer as root with a non-root operator. Remove every product-owned
265
- unit, socket, account and state file with:
 
 
 
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
- values[fields[1].removeprefix("*")] = fields[0].lower()
 
 
 
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 = bundle.infolist()
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
- imported = run(["/usr/bin/gpg", "--homedir", str(gpg_home), "--batch", "--import", str(key)])
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(["/usr/bin/git", "-C", str(root), "tag", "-v", tag], env=env, check=False)
290
- if verified.returncode:
291
- detail = (verified.stderr or verified.stdout).strip()
292
- raise FirstKillError(f"signed tag verification failed: {detail}")
 
 
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 release_files(tag: str, workspace: Path) -> tuple[Path, Path]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- expected = parse_checksums(sums)
309
  download(f"{base}/{bundle.name}", bundle, maximum=8 * 1024 * 1024)
310
  download(f"{base}/{key.name}", key, maximum=64 * 1024)
311
- if expected.get(bundle.name) != digest(bundle):
312
- raise FirstKillError("downloaded Linux bundle does not match release SHA256SUMS")
313
- require_regular(key, "release public key")
 
 
 
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
- allowed = {"ai-smoke", "release", "SHA256SUMS", "eggcracker-release-key.asc"}
 
 
 
 
 
 
 
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(name).decode("utf-8", errors="ignore").lower()
32
- for name in archive.namelist()
33
- if name.endswith((".py", ".md", ".toml", ".txt"))
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
- try:
54
- raw = archive.read("lumi_eggcracker/build_info.py")
55
- except KeyError as error:
56
- raise SystemExit("artifact source identity is missing") from error
 
 
 
 
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
- if set(archive.namelist()) != expected:
 
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()