zipcheck 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {zipcheck-0.2.0 → zipcheck-0.3.0}/CHANGELOG.md +24 -1
  2. {zipcheck-0.2.0 → zipcheck-0.3.0}/Dockerfile +1 -1
  3. {zipcheck-0.2.0 → zipcheck-0.3.0}/PKG-INFO +108 -30
  4. {zipcheck-0.2.0 → zipcheck-0.3.0}/README.md +104 -29
  5. {zipcheck-0.2.0 → zipcheck-0.3.0}/SECURITY.md +14 -6
  6. {zipcheck-0.2.0 → zipcheck-0.3.0}/docs/production.md +15 -7
  7. {zipcheck-0.2.0 → zipcheck-0.3.0}/packaging/aur/PKGBUILD +2 -2
  8. {zipcheck-0.2.0 → zipcheck-0.3.0}/pyproject.toml +4 -0
  9. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/__init__.py +1 -1
  10. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/cli.py +56 -1
  11. zipcheck-0.3.0/src/zipcheck/decrypt.py +169 -0
  12. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/engine.py +249 -142
  13. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/zipfmt.py +4 -1
  14. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/limits.py +6 -1
  15. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/verify.py +126 -56
  16. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck.egg-info/PKG-INFO +108 -30
  17. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck.egg-info/SOURCES.txt +2 -0
  18. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck.egg-info/requires.txt +4 -0
  19. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/helpers.py +18 -0
  20. zipcheck-0.3.0/tests/test_password.py +196 -0
  21. {zipcheck-0.2.0 → zipcheck-0.3.0}/.dockerignore +0 -0
  22. {zipcheck-0.2.0 → zipcheck-0.3.0}/CONTRIBUTING.md +0 -0
  23. {zipcheck-0.2.0 → zipcheck-0.3.0}/LICENSE +0 -0
  24. {zipcheck-0.2.0 → zipcheck-0.3.0}/MANIFEST.in +0 -0
  25. {zipcheck-0.2.0 → zipcheck-0.3.0}/docs/metadata-map.md +0 -0
  26. {zipcheck-0.2.0 → zipcheck-0.3.0}/fuzz/fuzz_scan.py +0 -0
  27. {zipcheck-0.2.0 → zipcheck-0.3.0}/fuzz/generate_seeds.py +0 -0
  28. {zipcheck-0.2.0 → zipcheck-0.3.0}/packaging/aur/.SRCINFO +0 -0
  29. {zipcheck-0.2.0 → zipcheck-0.3.0}/packaging/aur/refresh.sh +0 -0
  30. {zipcheck-0.2.0 → zipcheck-0.3.0}/setup.cfg +0 -0
  31. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/__main__.py +0 -0
  32. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/detect.py +0 -0
  33. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/__init__.py +0 -0
  34. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/bzip2fmt.py +0 -0
  35. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/gzipfmt.py +0 -0
  36. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/probe.py +0 -0
  37. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/rarfmt.py +0 -0
  38. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/sevenzipfmt.py +0 -0
  39. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/tarfmt.py +0 -0
  40. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/xzfmt.py +0 -0
  41. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/formats/zstdfmt.py +0 -0
  42. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/model.py +0 -0
  43. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/py.typed +0 -0
  44. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck/reader.py +0 -0
  45. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck.egg-info/dependency_links.txt +0 -0
  46. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck.egg-info/entry_points.txt +0 -0
  47. {zipcheck-0.2.0 → zipcheck-0.3.0}/src/zipcheck.egg-info/top_level.txt +0 -0
  48. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/__init__.py +0 -0
  49. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/conftest.py +0 -0
  50. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_cli.py +0 -0
  51. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_other_formats.py +0 -0
  52. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_property.py +0 -0
  53. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_robustness.py +0 -0
  54. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_tar.py +0 -0
  55. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_verify.py +0 -0
  56. {zipcheck-0.2.0 → zipcheck-0.3.0}/tests/test_zip.py +0 -0
  57. {zipcheck-0.2.0 → zipcheck-0.3.0}/tools/benchmark.py +0 -0
  58. {zipcheck-0.2.0 → zipcheck-0.3.0}/tools/generate_samples.py +0 -0
@@ -6,6 +6,28 @@ and this project uses [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-25
10
+
11
+ ### Added
12
+
13
+ - Password support for encrypted ZIPs: `--password`, `--password-file` and
14
+ `--ask-password` decrypt WinZip AES and legacy ZipCrypto members during the
15
+ scan, so nested encrypted bombs are followed and reported instead of landing
16
+ in `unknown`.
17
+ - Password-aware content verification: encrypted ZIP members are decrypted into
18
+ the counting sink and participate in the understated/overstated and
19
+ `VERIFIED_EXPANSION` checks.
20
+ - Optional `aes` extra (`pyzipper`) for AES decryption; ZipCrypto works with the
21
+ standard library alone. The bundled Docker image installs the extra, and the
22
+ AUR package lists `python-pyzipper` as an optional dependency.
23
+
24
+ ### Security
25
+
26
+ - Passwords are kept out of `Limits` representations, findings, notes and JSON
27
+ output.
28
+ - A wrong password, a missing AES backend, or an unsupported encryption method
29
+ fails closed: `unknown` in static mode, unverified under `--production`.
30
+
9
31
  ## [0.2.0] - 2026-10-24
10
32
 
11
33
  ### Added
@@ -62,7 +84,8 @@ and this project uses [Semantic Versioning](https://semver.org/).
62
84
  - A 7-level nested chain no longer scans `clean` when the depth limit truncates
63
85
  inspection.
64
86
 
65
- [Unreleased]: https://github.com/spaxe-dev/zipcheck/compare/v0.2.0...HEAD
87
+ [Unreleased]: https://github.com/spaxe-dev/zipcheck/compare/v0.3.0...HEAD
88
+ [0.3.0]: https://github.com/spaxe-dev/zipcheck/releases/tag/v0.3.0
66
89
  [0.2.0]: https://github.com/spaxe-dev/zipcheck/releases/tag/v0.2.0
67
90
 
68
91
  ## [0.1.0] - 2026-10-24
@@ -18,7 +18,7 @@ WORKDIR /app
18
18
  COPY pyproject.toml README.md LICENSE /app/
19
19
  COPY src /app/src
20
20
 
21
- RUN pip install --no-cache-dir .
21
+ RUN pip install --no-cache-dir ".[aes]"
22
22
 
23
23
  RUN useradd --create-home --shell /usr/sbin/nologin scanner
24
24
  USER scanner
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zipcheck
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Static zip-bomb and archive-bomb detector that never extracts anything
5
5
  Author: zipcheck contributors
6
6
  License-Expression: MIT
@@ -29,9 +29,12 @@ Classifier: Typing :: Typed
29
29
  Requires-Python: >=3.10
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
+ Provides-Extra: aes
33
+ Requires-Dist: pyzipper>=0.3.6; extra == "aes"
32
34
  Provides-Extra: dev
33
35
  Requires-Dist: hypothesis>=6; extra == "dev"
34
36
  Requires-Dist: mypy>=1.11; extra == "dev"
37
+ Requires-Dist: pyzipper>=0.3.6; extra == "dev"
35
38
  Requires-Dist: pytest>=8; extra == "dev"
36
39
  Requires-Dist: pytest-cov>=5; extra == "dev"
37
40
  Requires-Dist: ruff>=0.6; extra == "dev"
@@ -84,6 +87,7 @@ $ zipcheck suspicious.zip
84
87
  - [Usage](#usage)
85
88
  - [Detection rules](#detection-rules)
86
89
  - [Content verification](#content-verification)
90
+ - [Encrypted ZIPs](#encrypted-zips)
87
91
  - [How each format is measured](#how-each-format-is-measured)
88
92
  - [Nested archives and deep bombs](#nested-archives-and-deep-bombs)
89
93
  - [Safety model](#safety-model)
@@ -140,10 +144,11 @@ examples.
140
144
  ## What it detects
141
145
 
142
146
  - **ZIP / ZIP64** — declared expansion, per-entry impossible ratios, overlapping
143
- or shared entries, encrypted archive members, member count, contradictory
144
- local/central headers, entries hidden from the EOCD count, and a bounded
145
- stream-tail probe that catches a compressed stream extending past a forged
146
- compressed size when it leaves an unexplained gap.
147
+ or shared entries, encrypted archive members (decrypted and followed when a
148
+ password is supplied), member count, contradictory local/central headers,
149
+ entries hidden from the EOCD count, and a bounded stream-tail probe that
150
+ catches a compressed stream extending past a forged compressed size when it
151
+ leaves an unexplained gap.
147
152
  - **TAR** — huge declared members, sparse members, truncated archives, PAX and
148
153
  GNU long names, nested archives.
149
154
  - **gzip** — every stream is measured with a bounded multi-member probe, so
@@ -159,11 +164,12 @@ examples.
159
164
  unknown.
160
165
  - **Nested archives** — ZIP, TAR, gzip, bzip2 and xz are followed up to a
161
166
  configurable depth. Archive-like members inside 7z/RAR, encrypted archive-like
162
- members, and chains stopped by a cap are reported `unknown` instead of being
163
- silently skipped.
167
+ members without a password, and chains stopped by a cap are reported `unknown`
168
+ instead of being silently skipped.
164
169
  - **Content verification** — optional bounded decompression to a counting sink
165
170
  catches headers that understate the real size (ZIP stored/deflate/bzip2/LZMA,
166
- gzip, bzip2, xz/LZMA; TAR plan validation).
171
+ gzip, bzip2, xz/LZMA; TAR plan validation), including encrypted ZIP members
172
+ when a password is provided.
167
173
 
168
174
  Every result is one of: `clean`, `suspicious`, `dangerous`, `unknown`.
169
175
  **`unknown` is not a safe verdict** — it means the scanner could not prove
@@ -179,17 +185,23 @@ anything either way.
179
185
 
180
186
  ## Install
181
187
 
182
- Requires Python 3.10+. No runtime dependencies.
188
+ Requires Python 3.10+. There are no required runtime dependencies.
189
+
190
+ WinZip AES-encrypted ZIPs need the optional `aes` extra, which installs
191
+ [`pyzipper`](https://pypi.org/project/pyzipper/). Legacy ZipCrypto archives work
192
+ without it.
183
193
 
184
194
  ```bash
185
195
  pip install zipcheck
196
+ pip install 'zipcheck[aes]' # + AES-encrypted ZIP support
186
197
 
187
198
  # isolated CLI install (recommended for command-line use)
188
199
  pipx install zipcheck
200
+ pipx install 'zipcheck[aes]'
189
201
  # or
190
- uv tool install zipcheck
202
+ uv tool install 'zipcheck[aes]'
191
203
 
192
- # Arch Linux
204
+ # Arch Linux (python-pyzipper is an optional dependency for AES)
193
205
  yay -S zipcheck
194
206
  ```
195
207
 
@@ -198,11 +210,11 @@ From a checkout:
198
210
  ```bash
199
211
  git clone https://github.com/spaxe-dev/zipcheck
200
212
  cd zipcheck
201
- python -m pip install .
213
+ python -m pip install '.[aes]'
202
214
  PYTHONPATH=src python -m zipcheck file.rar # run without installing
203
215
  ```
204
216
 
205
- Container:
217
+ Container (the bundled image includes the AES extra):
206
218
 
207
219
  ```bash
208
220
  docker build -t zipcheck .
@@ -240,6 +252,12 @@ zipcheck -v archive.7z
240
252
 
241
253
  # production pre-flight: measure real output and fail closed
242
254
  zipcheck --production --verify-limit 256MiB --timeout 30 --json upload.zip
255
+
256
+ # encrypted ZIP: prompt for the password (not visible in shell history)
257
+ zipcheck --ask-password --production secret.zip
258
+
259
+ # encrypted ZIP in a script: read the password from a file
260
+ zipcheck --password-file /run/secrets/zip-password --json secret.zip
243
261
  ```
244
262
 
245
263
  Options:
@@ -257,6 +275,9 @@ Options:
257
275
  | `--verify-limit SIZE` | `512MiB` | Maximum output produced by verification |
258
276
  | `--require-verified` | off | Make unverifiable files `unknown` (implies `--verify`) |
259
277
  | `--production` | off | Fail-closed preset: implies `--verify --require-verified` |
278
+ | `--password TEXT` | none | Password for encrypted ZIP members (visible in shell history/process list) |
279
+ | `--password-file PATH` | none | Read the ZIP password from the first line of a file |
280
+ | `--ask-password` | off | Prompt for the ZIP password on the terminal |
260
281
  | `--ratio FLOAT` | `100` | Expansion ratio considered suspicious |
261
282
  | `--dangerous-ratio FLOAT` | `1000` | Expansion ratio considered dangerous |
262
283
  | `--min-size SIZE` | `16MiB` | Ignore ratios below this declared output |
@@ -301,7 +322,7 @@ verdict; `HIGH`/`CRITICAL` → *dangerous*, `MEDIUM` → *suspicious*.
301
322
  | `ENTRY_COUNT_MISMATCH` | HIGH / MEDIUM | The central directory contains more (HIGH) or fewer (MEDIUM) entries than the EOCD counts |
302
323
  | `NESTED_BOMB` / `NESTED_SUSPICIOUS` | HIGH / MEDIUM | A nested archive has that verdict |
303
324
  | `INSPECTION_INCOMPLETE` | MEDIUM | Nested inspection was incomplete (caps, truncation, opaque or unknown nested members); static verdict is `unknown` |
304
- | `ENCRYPTED_NESTED_ARCHIVE` | MEDIUM | Encrypted members look like archives and cannot be recursed into |
325
+ | `ENCRYPTED_NESTED_ARCHIVE` | MEDIUM | Encrypted members look like archives and no password was supplied |
305
326
  | `ISIZE_UNDERSTATED` | LOW | The measured gzip output exceeds the final member's `ISIZE` trailer |
306
327
  | `METADATA_UNDERSTATED` | HIGH (ZIP) / MEDIUM (streams) | Real decompressed output exceeds the declared size |
307
328
  | `METADATA_OVERSTATED` | MEDIUM | Declared size exceeds the real output |
@@ -347,10 +368,50 @@ When verification was requested but did not cover the whole file, an `UNVERIFIED
347
368
  - `--production` → shorthand for `--verify --require-verified`.
348
369
 
349
370
  This measures content; it is **not** a sandbox. The capped decompression still
350
- runs inside your process and consumes CPU and memory. Encrypted members, 7z/RAR
351
- member data, and methods without a stdlib decoder remain unverifiable and fail
352
- closed. The enforceable production boundary is still isolated extraction with
353
- OS quotas — see [docs/production.md](docs/production.md).
371
+ runs inside your process and consumes CPU and memory. Encrypted members without
372
+ a password, 7z/RAR member data, and methods without a stdlib decoder remain
373
+ unverifiable and fail closed. The enforceable production boundary is still
374
+ isolated extraction with OS quotas — see [docs/production.md](docs/production.md).
375
+
376
+ ---
377
+
378
+ ## Encrypted ZIPs
379
+
380
+ ZIP encryption hides member **content**, not the central directory: names,
381
+ sizes, compression methods and the encryption flag stay readable. That is why a
382
+ password-protected bomb still shows up as a ZIP and can be measured statically;
383
+ what it hides is the *real* expansion and any nested archives inside each
384
+ encrypted member.
385
+
386
+ Pass one of the three password options to let zipcheck decrypt members while it
387
+ scans:
388
+
389
+ ```bash
390
+ zipcheck --ask-password bomb.zip
391
+ zipcheck --password-file /run/secrets/pw bomb.zip
392
+ zipcheck --password '42' bomb.zip # convenient, but leaks via argv/history
393
+ ```
394
+
395
+ - Both **WinZip AES** (AE-1/AE-2) and legacy **ZipCrypto** are supported.
396
+ AES needs the optional `aes` extra (`pip install 'zipcheck[aes]'`); ZipCrypto
397
+ is handled by the standard library.
398
+ - Decryption is bounded exactly like ordinary scanning: 64 KiB read chunks, the
399
+ per-file timeout, the nested capture limits, and `--verify-limit`. Decrypted
400
+ bytes are counted or held in a bounded in-memory image; nothing is written to
401
+ disk.
402
+ - Zipcheck does not brute-force or guess passwords. A wrong password, a missing
403
+ AES backend, or an unsupported encryption method makes the affected members
404
+ uninspectable, so static mode reports `unknown` and `--production` rejects the
405
+ file. It never falls back to `clean`.
406
+ - Passwords are never logged, never included in JSON/notes, and never stored in
407
+ `Limits` representations. Prefer `--ask-password` or `--password-file`; using
408
+ `--password` exposes the value to shell history and the process list.
409
+ - Only ZIP encryption is handled. Encrypted 7z/RAR data and encrypted RAR
410
+ headers remain opaque and fail closed.
411
+
412
+ With a password, the real-world AES-encrypted `42.zip` is followed through its
413
+ nested layers and reported `dangerous`. Without one it stays `unknown`, which is
414
+ the honest answer.
354
415
 
355
416
  ---
356
417
 
@@ -462,8 +523,9 @@ verification as a top-level file, recursively.
462
523
  Inspection **fails closed**. If a depth, candidate, archive-count or byte
463
524
  budget stops the scan while archive-like members remain unexamined, if a
464
525
  candidate can only be read as a truncated prefix, if a child scan is itself
465
- unknown, or if a 7z/RAR/encrypted member looks like an archive and cannot be
466
- decoded, static mode reports the file `unknown` with `INSPECTION_INCOMPLETE`.
526
+ unknown, or if a 7z/RAR member or an encrypted member without a password looks
527
+ like an archive and cannot be decoded, static mode reports the file `unknown`
528
+ with `INSPECTION_INCOMPLETE`.
467
529
  It is never reported `clean` just because the scanner gave up. Verified mode
468
530
  keeps its own contract: `suspicious`, or `unknown` under `--require-verified`.
469
531
 
@@ -474,8 +536,10 @@ When a nested bomb is found, scanning stops early and the parent is marked
474
536
  download.zip!lib0.zip!lib1.zip!...!payload.bin
475
537
  ```
476
538
 
477
- This is enough to catch 42.zip-style chains that are not encrypted. Encrypted
478
- bombs remain opaque; see below.
539
+ This is enough to catch 42.zip-style chains that are not encrypted. If the
540
+ outer layers are encrypted, supply a ZIP password (see
541
+ [Encrypted ZIPs](#encrypted-zips)) and the same recursion runs on the decrypted
542
+ members; without a password those members remain opaque and fail closed.
479
543
 
480
544
  ---
481
545
 
@@ -499,6 +563,9 @@ allocate an attacker-controlled amount of memory.
499
563
  - **LZMA decoder caps.** The xz path sets a 128 MiB decoder memory limit, so a
500
564
  malicious dictionary size cannot force a huge allocation.
501
565
  - **Wall-clock limit.** 60 seconds per file by default (`--timeout`).
566
+ - **Passwords stay out of results.** A password is read from the terminal or a
567
+ file, used only for member decryption, and excluded from `Limits` repr/equality
568
+ and every finding, note and JSON field. It is never written anywhere.
502
569
  - **No recursion into data formats.** 7z and RAR member data is never decoded,
503
570
  so a solid archive cannot be used to burn CPU during a scan.
504
571
  - **No stat-then-open race.** The path is opened and `fstat`ed on the same file
@@ -540,7 +607,7 @@ used):
540
607
  | gzip-compressed sparse TAR | 142 B | 1.00 TiB (inner TAR) | dangerous |
541
608
  | non-recursive overlap ZIP | 4.2 KiB | 8 KiB | dangerous |
542
609
  | RAR 5, 256 MiB of zeros | 11 KiB | 256 MiB | suspicious |
543
- | real `42.zip` (AES-encrypted) | 41.8 KiB | opaque | unknown |
610
+ | real `42.zip` (AES-encrypted, password `42`) | 41.8 KiB | 4 GiB at depth 6 | dangerous (exit 2); `unknown` without the password |
544
611
  | 7-Zip encoded header, 50 files | 729 B | 790 B | clean (correct) |
545
612
  | real RAR 4 archive | 336 B | 48 B | clean (correct) |
546
613
 
@@ -572,21 +639,31 @@ gzip-compressed sparse TAR, and the real 42.zip — scanned in ~0.19 seconds wit
572
639
  ~146 MiB peak RSS (the xz decoder's 64 MiB dictionary dominates). Reproduce
573
640
  with `tools/generate_samples.py` and `tools/benchmark.py` inside a VM/container.
574
641
 
642
+ With the real `42.zip` password (`42`) supplied via `--password-file`, the same
643
+ file is reported `dangerous` (exit `2`) in ~6 milliseconds: the outer AES layer
644
+ is decrypted in memory and the nested chain is followed far enough to find a
645
+ layer declaring 4 GiB. `--production` also returns `dangerous`; the deeper
646
+ layers are not fully verifiable within the capture caps, but positive evidence
647
+ outranks incompleteness. Without the password the file remains `unknown`
648
+ (exit `3`).
649
+
575
650
  ---
576
651
 
577
652
  ## Limitations and honest caveats
578
653
 
579
- - **Encrypted archives are opaque.** zipcheck cannot decrypt AES or password-
580
- protected members. Encrypted members named like archives make the file
581
- `unknown` (via `INSPECTION_INCOMPLETE`), because a nested bomb cannot be ruled
582
- out. The real 42.zip is exactly this case.
654
+ - **Encrypted ZIPs need a password.** AES (WinZip) requires the `aes` extra;
655
+ ZipCrypto works in the base install. Without `--password`,
656
+ `--password-file` or `--ask-password`, encrypted members remain opaque and
657
+ archive-like ones make the file `unknown`. A wrong password, a missing AES
658
+ backend, or an unsupported method also fails closed — zipcheck does not
659
+ brute-force passwords.
583
660
  - **Headers can lie.** zipcheck flags entries hidden from the EOCD count,
584
661
  conflicting local/central headers, forged sizes that leave the real stream in
585
662
  an unexplained gap, and measured output larger than the declared size when
586
663
  the tail probe runs. A forgery arranged to look contiguous, or one outside
587
664
  the probe caps, cannot be proven from metadata; `--verify` measures it.
588
- Formats the verifier cannot decode (7z, RAR, encrypted members) remain opaque
589
- and fail closed.
665
+ Formats the verifier cannot decode (7z, RAR, encrypted members without a
666
+ password) remain opaque and fail closed.
590
667
  - **Static is not verification.** The ZIP tail probe only examines members
591
668
  whose declared data ends before the next structure, and is capped at 256
592
669
  members, 64 KiB compressed each and 4 MiB of total output. Its absence is
@@ -630,7 +707,7 @@ sample or a hex description — see [SECURITY.md](SECURITY.md).
630
707
  ```json
631
708
  {
632
709
  "tool": "zipcheck",
633
- "version": "0.2.0",
710
+ "version": "0.3.0",
634
711
  "elapsed_ms": 3.1,
635
712
  "summary": {"clean": 0, "suspicious": 0, "dangerous": 1, "unknown": 0},
636
713
  "results": [
@@ -690,6 +767,7 @@ Layout:
690
767
  src/zipcheck/
691
768
  engine.py # orchestration, policy, nested inspection
692
769
  verify.py # bounded content verification (counting sink)
770
+ decrypt.py # bounded ZIP member decryption (AES/ZipCrypto)
693
771
  model.py # facts/findings/results
694
772
  limits.py # caps and thresholds
695
773
  detect.py # magic/extension routing
@@ -43,6 +43,7 @@ $ zipcheck suspicious.zip
43
43
  - [Usage](#usage)
44
44
  - [Detection rules](#detection-rules)
45
45
  - [Content verification](#content-verification)
46
+ - [Encrypted ZIPs](#encrypted-zips)
46
47
  - [How each format is measured](#how-each-format-is-measured)
47
48
  - [Nested archives and deep bombs](#nested-archives-and-deep-bombs)
48
49
  - [Safety model](#safety-model)
@@ -99,10 +100,11 @@ examples.
99
100
  ## What it detects
100
101
 
101
102
  - **ZIP / ZIP64** — declared expansion, per-entry impossible ratios, overlapping
102
- or shared entries, encrypted archive members, member count, contradictory
103
- local/central headers, entries hidden from the EOCD count, and a bounded
104
- stream-tail probe that catches a compressed stream extending past a forged
105
- compressed size when it leaves an unexplained gap.
103
+ or shared entries, encrypted archive members (decrypted and followed when a
104
+ password is supplied), member count, contradictory local/central headers,
105
+ entries hidden from the EOCD count, and a bounded stream-tail probe that
106
+ catches a compressed stream extending past a forged compressed size when it
107
+ leaves an unexplained gap.
106
108
  - **TAR** — huge declared members, sparse members, truncated archives, PAX and
107
109
  GNU long names, nested archives.
108
110
  - **gzip** — every stream is measured with a bounded multi-member probe, so
@@ -118,11 +120,12 @@ examples.
118
120
  unknown.
119
121
  - **Nested archives** — ZIP, TAR, gzip, bzip2 and xz are followed up to a
120
122
  configurable depth. Archive-like members inside 7z/RAR, encrypted archive-like
121
- members, and chains stopped by a cap are reported `unknown` instead of being
122
- silently skipped.
123
+ members without a password, and chains stopped by a cap are reported `unknown`
124
+ instead of being silently skipped.
123
125
  - **Content verification** — optional bounded decompression to a counting sink
124
126
  catches headers that understate the real size (ZIP stored/deflate/bzip2/LZMA,
125
- gzip, bzip2, xz/LZMA; TAR plan validation).
127
+ gzip, bzip2, xz/LZMA; TAR plan validation), including encrypted ZIP members
128
+ when a password is provided.
126
129
 
127
130
  Every result is one of: `clean`, `suspicious`, `dangerous`, `unknown`.
128
131
  **`unknown` is not a safe verdict** — it means the scanner could not prove
@@ -138,17 +141,23 @@ anything either way.
138
141
 
139
142
  ## Install
140
143
 
141
- Requires Python 3.10+. No runtime dependencies.
144
+ Requires Python 3.10+. There are no required runtime dependencies.
145
+
146
+ WinZip AES-encrypted ZIPs need the optional `aes` extra, which installs
147
+ [`pyzipper`](https://pypi.org/project/pyzipper/). Legacy ZipCrypto archives work
148
+ without it.
142
149
 
143
150
  ```bash
144
151
  pip install zipcheck
152
+ pip install 'zipcheck[aes]' # + AES-encrypted ZIP support
145
153
 
146
154
  # isolated CLI install (recommended for command-line use)
147
155
  pipx install zipcheck
156
+ pipx install 'zipcheck[aes]'
148
157
  # or
149
- uv tool install zipcheck
158
+ uv tool install 'zipcheck[aes]'
150
159
 
151
- # Arch Linux
160
+ # Arch Linux (python-pyzipper is an optional dependency for AES)
152
161
  yay -S zipcheck
153
162
  ```
154
163
 
@@ -157,11 +166,11 @@ From a checkout:
157
166
  ```bash
158
167
  git clone https://github.com/spaxe-dev/zipcheck
159
168
  cd zipcheck
160
- python -m pip install .
169
+ python -m pip install '.[aes]'
161
170
  PYTHONPATH=src python -m zipcheck file.rar # run without installing
162
171
  ```
163
172
 
164
- Container:
173
+ Container (the bundled image includes the AES extra):
165
174
 
166
175
  ```bash
167
176
  docker build -t zipcheck .
@@ -199,6 +208,12 @@ zipcheck -v archive.7z
199
208
 
200
209
  # production pre-flight: measure real output and fail closed
201
210
  zipcheck --production --verify-limit 256MiB --timeout 30 --json upload.zip
211
+
212
+ # encrypted ZIP: prompt for the password (not visible in shell history)
213
+ zipcheck --ask-password --production secret.zip
214
+
215
+ # encrypted ZIP in a script: read the password from a file
216
+ zipcheck --password-file /run/secrets/zip-password --json secret.zip
202
217
  ```
203
218
 
204
219
  Options:
@@ -216,6 +231,9 @@ Options:
216
231
  | `--verify-limit SIZE` | `512MiB` | Maximum output produced by verification |
217
232
  | `--require-verified` | off | Make unverifiable files `unknown` (implies `--verify`) |
218
233
  | `--production` | off | Fail-closed preset: implies `--verify --require-verified` |
234
+ | `--password TEXT` | none | Password for encrypted ZIP members (visible in shell history/process list) |
235
+ | `--password-file PATH` | none | Read the ZIP password from the first line of a file |
236
+ | `--ask-password` | off | Prompt for the ZIP password on the terminal |
219
237
  | `--ratio FLOAT` | `100` | Expansion ratio considered suspicious |
220
238
  | `--dangerous-ratio FLOAT` | `1000` | Expansion ratio considered dangerous |
221
239
  | `--min-size SIZE` | `16MiB` | Ignore ratios below this declared output |
@@ -260,7 +278,7 @@ verdict; `HIGH`/`CRITICAL` → *dangerous*, `MEDIUM` → *suspicious*.
260
278
  | `ENTRY_COUNT_MISMATCH` | HIGH / MEDIUM | The central directory contains more (HIGH) or fewer (MEDIUM) entries than the EOCD counts |
261
279
  | `NESTED_BOMB` / `NESTED_SUSPICIOUS` | HIGH / MEDIUM | A nested archive has that verdict |
262
280
  | `INSPECTION_INCOMPLETE` | MEDIUM | Nested inspection was incomplete (caps, truncation, opaque or unknown nested members); static verdict is `unknown` |
263
- | `ENCRYPTED_NESTED_ARCHIVE` | MEDIUM | Encrypted members look like archives and cannot be recursed into |
281
+ | `ENCRYPTED_NESTED_ARCHIVE` | MEDIUM | Encrypted members look like archives and no password was supplied |
264
282
  | `ISIZE_UNDERSTATED` | LOW | The measured gzip output exceeds the final member's `ISIZE` trailer |
265
283
  | `METADATA_UNDERSTATED` | HIGH (ZIP) / MEDIUM (streams) | Real decompressed output exceeds the declared size |
266
284
  | `METADATA_OVERSTATED` | MEDIUM | Declared size exceeds the real output |
@@ -306,10 +324,50 @@ When verification was requested but did not cover the whole file, an `UNVERIFIED
306
324
  - `--production` → shorthand for `--verify --require-verified`.
307
325
 
308
326
  This measures content; it is **not** a sandbox. The capped decompression still
309
- runs inside your process and consumes CPU and memory. Encrypted members, 7z/RAR
310
- member data, and methods without a stdlib decoder remain unverifiable and fail
311
- closed. The enforceable production boundary is still isolated extraction with
312
- OS quotas — see [docs/production.md](docs/production.md).
327
+ runs inside your process and consumes CPU and memory. Encrypted members without
328
+ a password, 7z/RAR member data, and methods without a stdlib decoder remain
329
+ unverifiable and fail closed. The enforceable production boundary is still
330
+ isolated extraction with OS quotas — see [docs/production.md](docs/production.md).
331
+
332
+ ---
333
+
334
+ ## Encrypted ZIPs
335
+
336
+ ZIP encryption hides member **content**, not the central directory: names,
337
+ sizes, compression methods and the encryption flag stay readable. That is why a
338
+ password-protected bomb still shows up as a ZIP and can be measured statically;
339
+ what it hides is the *real* expansion and any nested archives inside each
340
+ encrypted member.
341
+
342
+ Pass one of the three password options to let zipcheck decrypt members while it
343
+ scans:
344
+
345
+ ```bash
346
+ zipcheck --ask-password bomb.zip
347
+ zipcheck --password-file /run/secrets/pw bomb.zip
348
+ zipcheck --password '42' bomb.zip # convenient, but leaks via argv/history
349
+ ```
350
+
351
+ - Both **WinZip AES** (AE-1/AE-2) and legacy **ZipCrypto** are supported.
352
+ AES needs the optional `aes` extra (`pip install 'zipcheck[aes]'`); ZipCrypto
353
+ is handled by the standard library.
354
+ - Decryption is bounded exactly like ordinary scanning: 64 KiB read chunks, the
355
+ per-file timeout, the nested capture limits, and `--verify-limit`. Decrypted
356
+ bytes are counted or held in a bounded in-memory image; nothing is written to
357
+ disk.
358
+ - Zipcheck does not brute-force or guess passwords. A wrong password, a missing
359
+ AES backend, or an unsupported encryption method makes the affected members
360
+ uninspectable, so static mode reports `unknown` and `--production` rejects the
361
+ file. It never falls back to `clean`.
362
+ - Passwords are never logged, never included in JSON/notes, and never stored in
363
+ `Limits` representations. Prefer `--ask-password` or `--password-file`; using
364
+ `--password` exposes the value to shell history and the process list.
365
+ - Only ZIP encryption is handled. Encrypted 7z/RAR data and encrypted RAR
366
+ headers remain opaque and fail closed.
367
+
368
+ With a password, the real-world AES-encrypted `42.zip` is followed through its
369
+ nested layers and reported `dangerous`. Without one it stays `unknown`, which is
370
+ the honest answer.
313
371
 
314
372
  ---
315
373
 
@@ -421,8 +479,9 @@ verification as a top-level file, recursively.
421
479
  Inspection **fails closed**. If a depth, candidate, archive-count or byte
422
480
  budget stops the scan while archive-like members remain unexamined, if a
423
481
  candidate can only be read as a truncated prefix, if a child scan is itself
424
- unknown, or if a 7z/RAR/encrypted member looks like an archive and cannot be
425
- decoded, static mode reports the file `unknown` with `INSPECTION_INCOMPLETE`.
482
+ unknown, or if a 7z/RAR member or an encrypted member without a password looks
483
+ like an archive and cannot be decoded, static mode reports the file `unknown`
484
+ with `INSPECTION_INCOMPLETE`.
426
485
  It is never reported `clean` just because the scanner gave up. Verified mode
427
486
  keeps its own contract: `suspicious`, or `unknown` under `--require-verified`.
428
487
 
@@ -433,8 +492,10 @@ When a nested bomb is found, scanning stops early and the parent is marked
433
492
  download.zip!lib0.zip!lib1.zip!...!payload.bin
434
493
  ```
435
494
 
436
- This is enough to catch 42.zip-style chains that are not encrypted. Encrypted
437
- bombs remain opaque; see below.
495
+ This is enough to catch 42.zip-style chains that are not encrypted. If the
496
+ outer layers are encrypted, supply a ZIP password (see
497
+ [Encrypted ZIPs](#encrypted-zips)) and the same recursion runs on the decrypted
498
+ members; without a password those members remain opaque and fail closed.
438
499
 
439
500
  ---
440
501
 
@@ -458,6 +519,9 @@ allocate an attacker-controlled amount of memory.
458
519
  - **LZMA decoder caps.** The xz path sets a 128 MiB decoder memory limit, so a
459
520
  malicious dictionary size cannot force a huge allocation.
460
521
  - **Wall-clock limit.** 60 seconds per file by default (`--timeout`).
522
+ - **Passwords stay out of results.** A password is read from the terminal or a
523
+ file, used only for member decryption, and excluded from `Limits` repr/equality
524
+ and every finding, note and JSON field. It is never written anywhere.
461
525
  - **No recursion into data formats.** 7z and RAR member data is never decoded,
462
526
  so a solid archive cannot be used to burn CPU during a scan.
463
527
  - **No stat-then-open race.** The path is opened and `fstat`ed on the same file
@@ -499,7 +563,7 @@ used):
499
563
  | gzip-compressed sparse TAR | 142 B | 1.00 TiB (inner TAR) | dangerous |
500
564
  | non-recursive overlap ZIP | 4.2 KiB | 8 KiB | dangerous |
501
565
  | RAR 5, 256 MiB of zeros | 11 KiB | 256 MiB | suspicious |
502
- | real `42.zip` (AES-encrypted) | 41.8 KiB | opaque | unknown |
566
+ | real `42.zip` (AES-encrypted, password `42`) | 41.8 KiB | 4 GiB at depth 6 | dangerous (exit 2); `unknown` without the password |
503
567
  | 7-Zip encoded header, 50 files | 729 B | 790 B | clean (correct) |
504
568
  | real RAR 4 archive | 336 B | 48 B | clean (correct) |
505
569
 
@@ -531,21 +595,31 @@ gzip-compressed sparse TAR, and the real 42.zip — scanned in ~0.19 seconds wit
531
595
  ~146 MiB peak RSS (the xz decoder's 64 MiB dictionary dominates). Reproduce
532
596
  with `tools/generate_samples.py` and `tools/benchmark.py` inside a VM/container.
533
597
 
598
+ With the real `42.zip` password (`42`) supplied via `--password-file`, the same
599
+ file is reported `dangerous` (exit `2`) in ~6 milliseconds: the outer AES layer
600
+ is decrypted in memory and the nested chain is followed far enough to find a
601
+ layer declaring 4 GiB. `--production` also returns `dangerous`; the deeper
602
+ layers are not fully verifiable within the capture caps, but positive evidence
603
+ outranks incompleteness. Without the password the file remains `unknown`
604
+ (exit `3`).
605
+
534
606
  ---
535
607
 
536
608
  ## Limitations and honest caveats
537
609
 
538
- - **Encrypted archives are opaque.** zipcheck cannot decrypt AES or password-
539
- protected members. Encrypted members named like archives make the file
540
- `unknown` (via `INSPECTION_INCOMPLETE`), because a nested bomb cannot be ruled
541
- out. The real 42.zip is exactly this case.
610
+ - **Encrypted ZIPs need a password.** AES (WinZip) requires the `aes` extra;
611
+ ZipCrypto works in the base install. Without `--password`,
612
+ `--password-file` or `--ask-password`, encrypted members remain opaque and
613
+ archive-like ones make the file `unknown`. A wrong password, a missing AES
614
+ backend, or an unsupported method also fails closed — zipcheck does not
615
+ brute-force passwords.
542
616
  - **Headers can lie.** zipcheck flags entries hidden from the EOCD count,
543
617
  conflicting local/central headers, forged sizes that leave the real stream in
544
618
  an unexplained gap, and measured output larger than the declared size when
545
619
  the tail probe runs. A forgery arranged to look contiguous, or one outside
546
620
  the probe caps, cannot be proven from metadata; `--verify` measures it.
547
- Formats the verifier cannot decode (7z, RAR, encrypted members) remain opaque
548
- and fail closed.
621
+ Formats the verifier cannot decode (7z, RAR, encrypted members without a
622
+ password) remain opaque and fail closed.
549
623
  - **Static is not verification.** The ZIP tail probe only examines members
550
624
  whose declared data ends before the next structure, and is capped at 256
551
625
  members, 64 KiB compressed each and 4 MiB of total output. Its absence is
@@ -589,7 +663,7 @@ sample or a hex description — see [SECURITY.md](SECURITY.md).
589
663
  ```json
590
664
  {
591
665
  "tool": "zipcheck",
592
- "version": "0.2.0",
666
+ "version": "0.3.0",
593
667
  "elapsed_ms": 3.1,
594
668
  "summary": {"clean": 0, "suspicious": 0, "dangerous": 1, "unknown": 0},
595
669
  "results": [
@@ -649,6 +723,7 @@ Layout:
649
723
  src/zipcheck/
650
724
  engine.py # orchestration, policy, nested inspection
651
725
  verify.py # bounded content verification (counting sink)
726
+ decrypt.py # bounded ZIP member decryption (AES/ZipCrypto)
652
727
  model.py # facts/findings/results
653
728
  limits.py # caps and thresholds
654
729
  detect.py # magic/extension routing
@@ -8,11 +8,14 @@ or a guarantee that an archive is safe.
8
8
 
9
9
  The scanner itself is designed to be safe against hostile inputs:
10
10
 
11
- - it never extracts archive members to disk or memory beyond a bounded prefix;
11
+ - it never extracts archive members to disk; decrypted ZIP data is counted or
12
+ held in a bounded in-memory image only;
12
13
  - it never invokes `unzip`, `7z`, `tar`, `rar`, or any external program;
13
- - it caps metadata reads, entry counts, nested depth, decompressed bytes and
14
+ - it caps metadata reads, entry counts, nested depth, decrypted bytes and
14
15
  wall-clock time;
15
- - it checks 7z/xz decoder dictionary sizes against a memory cap.
16
+ - it checks 7z/xz decoder dictionary sizes against a memory cap;
17
+ - it keeps a supplied ZIP password out of findings, notes, JSON and `Limits`
18
+ representations, and never writes it anywhere.
16
19
 
17
20
  Those controls protect against resource exhaustion during the scan. They say
18
21
  nothing about what happens if *you* later extract the archive with another tool.
@@ -21,7 +24,7 @@ memory and time quotas.
21
24
 
22
25
  ## Supported versions
23
26
 
24
- Security fixes are applied to the latest `0.1.x` release.
27
+ Security fixes are applied to the latest `0.3.x` release.
25
28
 
26
29
  ## Reporting a vulnerability
27
30
 
@@ -39,11 +42,16 @@ Useful reports include:
39
42
  - a bounded-decompression bypass that exceeds the documented caps;
40
43
  - a parser confusion that makes a clearly dangerous archive report `clean`;
41
44
  - path traversal or command execution (there should be none);
42
- - a way to make the scanner write to disk or spawn a process.
45
+ - a way to make the scanner write to disk or spawn a process;
46
+ - a way to make a supplied password appear in output, logs or process metadata
47
+ beyond the documented `--password` argv caveat.
43
48
 
44
49
  ## Known non-goals
45
50
 
46
- - Decrypting AES/password-protected archives.
51
+ - Brute-forcing, guessing or recovering passwords.
52
+ - Decrypting password-protected 7z/RAR data or encrypted RAR headers. ZIP
53
+ encryption is supported when a password is supplied and, for AES, the
54
+ optional `pyzipper` extra is installed.
47
55
  - Proving that an archive is benign. `clean` means "no rule fired", not "safe".
48
56
  - Detecting bombs in formats that are not parsed.
49
57
  - Protecting a machine from extraction performed by a different program.