zipcheck 0.1.0__py3-none-any.whl

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.
zipcheck/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """zipcheck: static archive-bomb detection without extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .engine import scan_bytes, scan_file
6
+ from .engine import scan_file as scan
7
+ from .limits import Limits
8
+ from .model import EntryInfo, Finding, ScanResult, Severity
9
+
10
+ __all__ = [
11
+ "EntryInfo",
12
+ "Finding",
13
+ "Limits",
14
+ "ScanResult",
15
+ "Severity",
16
+ "scan",
17
+ "scan_bytes",
18
+ "scan_file",
19
+ ]
20
+
21
+ __version__ = "0.1.0"
zipcheck/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
zipcheck/cli.py ADDED
@@ -0,0 +1,333 @@
1
+ """Command-line interface for zipcheck."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from typing import Any, Dict, List, Optional, Sequence
11
+
12
+ from . import __version__
13
+ from .detect import extension_suggests_archive
14
+ from .engine import scan_file
15
+ from .limits import Limits
16
+ from .model import ScanResult, Severity
17
+ from .reader import human_ratio, human_size
18
+
19
+ VERDICT_COLORS = {
20
+ "clean": "\x1b[32m",
21
+ "suspicious": "\x1b[33m",
22
+ "dangerous": "\x1b[31m",
23
+ "unknown": "\x1b[90m",
24
+ }
25
+ SEVERITY_COLORS = {
26
+ Severity.INFO: "\x1b[90m",
27
+ Severity.LOW: "\x1b[36m",
28
+ Severity.MEDIUM: "\x1b[33m",
29
+ Severity.HIGH: "\x1b[31m",
30
+ Severity.CRITICAL: "\x1b[1;31m",
31
+ }
32
+ RESET = "\x1b[0m"
33
+
34
+
35
+ def _parse_size(value: str) -> int:
36
+ text = value.strip().lower().replace("_", "")
37
+ if not text:
38
+ raise argparse.ArgumentTypeError("empty size")
39
+ suffixes = {
40
+ "k": 1024,
41
+ "kb": 1024,
42
+ "kib": 1024,
43
+ "m": 1024**2,
44
+ "mb": 1024**2,
45
+ "mib": 1024**2,
46
+ "g": 1024**3,
47
+ "gb": 1024**3,
48
+ "gib": 1024**3,
49
+ "t": 1024**4,
50
+ "tb": 1024**4,
51
+ "tib": 1024**4,
52
+ }
53
+ for suffix in sorted(suffixes, key=len, reverse=True):
54
+ if text.endswith(suffix):
55
+ number = text[: -len(suffix)].strip()
56
+ try:
57
+ return int(float(number) * suffixes[suffix])
58
+ except ValueError as exc:
59
+ raise argparse.ArgumentTypeError(f"invalid size: {value}") from exc
60
+ try:
61
+ return int(text)
62
+ except ValueError as exc:
63
+ raise argparse.ArgumentTypeError(f"invalid size: {value}") from exc
64
+
65
+
66
+ def build_parser() -> argparse.ArgumentParser:
67
+ parser = argparse.ArgumentParser(
68
+ prog="zipcheck",
69
+ description=(
70
+ "Static zip-bomb / archive-bomb detector. Reads only archive metadata; "
71
+ "it never extracts files and never writes member data."
72
+ ),
73
+ epilog=(
74
+ "Exit codes: 0 clean (or below --fail-on), 1 suspicious, 2 dangerous, "
75
+ "3 unknown/unscannable, 4 invocation or I/O error."
76
+ ),
77
+ )
78
+ parser.add_argument("paths", nargs="+", metavar="PATH", help="archive file(s) to scan")
79
+ parser.add_argument("-r", "--recursive", action="store_true", help="recurse into directories")
80
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
81
+ parser.add_argument(
82
+ "--fail-on",
83
+ choices=("none", "suspicious", "dangerous"),
84
+ default="suspicious",
85
+ help="lowest verdict that causes a non-zero exit (default: suspicious)",
86
+ )
87
+ parser.add_argument("--depth", type=int, default=6, help="maximum nested archive depth")
88
+ parser.add_argument(
89
+ "--timeout", type=float, default=60.0, help="per-file wall-clock limit in seconds"
90
+ )
91
+ parser.add_argument(
92
+ "--no-probe",
93
+ dest="probe",
94
+ action="store_false",
95
+ help="do not run bounded decompression on bzip2 / size-less formats",
96
+ )
97
+ parser.add_argument(
98
+ "--probe-limit",
99
+ type=_parse_size,
100
+ default=64 * 1024 * 1024,
101
+ help="maximum bytes produced by a bounded probe (default: 64MiB)",
102
+ )
103
+ parser.add_argument(
104
+ "--verify",
105
+ action="store_true",
106
+ help="decompress content into a counting sink to detect forged metadata",
107
+ )
108
+ parser.add_argument(
109
+ "--verify-limit",
110
+ type=_parse_size,
111
+ default=512 * 1024 * 1024,
112
+ help="maximum bytes produced by content verification (default: 512MiB)",
113
+ )
114
+ parser.add_argument(
115
+ "--require-verified",
116
+ action="store_true",
117
+ help="fail closed (unknown) when content cannot be verified; implies --verify",
118
+ )
119
+ parser.add_argument(
120
+ "--production",
121
+ action="store_true",
122
+ help=(
123
+ "fail-closed preset for user uploads: verify content and reject "
124
+ "archives that cannot be fully verified (implies --verify "
125
+ "--require-verified)"
126
+ ),
127
+ )
128
+ parser.add_argument(
129
+ "--ratio",
130
+ type=float,
131
+ default=100.0,
132
+ help="expansion ratio considered suspicious (default: 100)",
133
+ )
134
+ parser.add_argument(
135
+ "--dangerous-ratio",
136
+ type=float,
137
+ default=1000.0,
138
+ help="expansion ratio considered dangerous (default: 1000)",
139
+ )
140
+ parser.add_argument(
141
+ "--min-size",
142
+ type=_parse_size,
143
+ default=16 * 1024 * 1024,
144
+ help="ignore expansion ratios below this declared size (default: 16MiB)",
145
+ )
146
+ parser.add_argument(
147
+ "--entries",
148
+ type=int,
149
+ default=10,
150
+ help="number of archive members to show in human output (default: 10)",
151
+ )
152
+ parser.add_argument("--quiet", action="store_true", help="one line per file")
153
+ parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
154
+ parser.add_argument(
155
+ "-v", "--verbose", action="store_true", help="show notes and member samples"
156
+ )
157
+ parser.add_argument("--version", action="version", version=f"zipcheck {__version__}")
158
+ return parser
159
+
160
+
161
+ def _collect_paths(paths: Sequence[str], recursive: bool) -> List[str]:
162
+ collected: List[str] = []
163
+ for path in paths:
164
+ if os.path.isdir(path):
165
+ if not recursive:
166
+ raise IsADirectoryError(
167
+ f"{path!r} is a directory (use --recursive to scan its archives)"
168
+ )
169
+ for root, dirs, files in os.walk(path):
170
+ dirs.sort()
171
+ for name in sorted(files):
172
+ candidate = os.path.join(root, name)
173
+ if extension_suggests_archive(candidate):
174
+ collected.append(candidate)
175
+ else:
176
+ collected.append(path)
177
+ return collected
178
+
179
+
180
+ def _colorize(text: str, color: str, enabled: bool) -> str:
181
+ return f"{color}{text}{RESET}" if enabled else text
182
+
183
+
184
+ def _print_result(result: ScanResult, args: argparse.Namespace, color: bool) -> None:
185
+ indent = " " * (result.depth + 1)
186
+ verdict = result.verdict.upper()
187
+ colored_verdict = _colorize(verdict, VERDICT_COLORS.get(result.verdict, ""), color)
188
+ print(f"{indent}{colored_verdict} {result.path}")
189
+ print(f"{indent} format: {result.format} ({result.detail or 'n/a'})")
190
+ print(f"{indent} on disk: {human_size(result.size)}")
191
+ if result.declared_uncompressed is not None:
192
+ ratio = result.ratio
193
+ print(
194
+ f"{indent} declared: {human_size(result.declared_uncompressed)}"
195
+ f" ({human_ratio(ratio)})"
196
+ )
197
+ if result.entry_count is not None:
198
+ print(f"{indent} members: {result.entry_count:,}")
199
+ if result.verified is not None:
200
+ state = "verified" if result.verified else "unverified"
201
+ if result.verification_capped:
202
+ state += " (cap reached)"
203
+ print(
204
+ f"{indent} verify: {state}, "
205
+ f"{human_size(result.verified_uncompressed)} produced - {result.verification_detail}"
206
+ )
207
+ if result.error:
208
+ print(f"{indent} error: {result.error}")
209
+
210
+ for finding in result.findings:
211
+ color_code = SEVERITY_COLORS.get(finding.severity, "")
212
+ severity = _colorize(finding.severity.label.upper(), color_code, color)
213
+ print(f"{indent} [{severity}] {finding.code}: {finding.title}")
214
+ if args.verbose or finding.severity >= Severity.MEDIUM:
215
+ print(f"{indent} {finding.detail}")
216
+
217
+ if args.verbose and result.notes:
218
+ for note in result.notes:
219
+ print(f"{indent} note: {note}")
220
+
221
+ if args.verbose and result.entries:
222
+ print(f"{indent} members (sample):")
223
+ for entry in result.entries[: args.entries]:
224
+ kind = "dir" if entry.is_dir else entry.method or "?"
225
+ print(
226
+ f"{indent} {entry.name!r} {human_size(entry.usize)} "
227
+ f"({kind}, {human_ratio(entry.ratio)})"
228
+ )
229
+
230
+ for index, child in enumerate(result.nested):
231
+ if args.verbose or index == 0:
232
+ _print_result(child, args, color)
233
+ else:
234
+ child_verdict = _colorize(
235
+ child.verdict.upper(), VERDICT_COLORS.get(child.verdict, ""), color
236
+ )
237
+ print(f"{indent} nested: {child_verdict} {child.path}")
238
+
239
+
240
+ def _summary(results: Sequence[ScanResult]) -> Dict[str, int]:
241
+ summary = {"clean": 0, "suspicious": 0, "dangerous": 0, "unknown": 0}
242
+ for result in results:
243
+ summary[result.verdict] += 1
244
+ return summary
245
+
246
+
247
+ def _exit_code(results: Sequence[ScanResult], fail_on: str, io_error: bool) -> int:
248
+ if io_error:
249
+ return 4
250
+ if fail_on == "none":
251
+ return 0
252
+ summary = _summary(results)
253
+ if summary["dangerous"]:
254
+ return 2
255
+ if fail_on == "suspicious" and summary["suspicious"]:
256
+ return 1
257
+ if summary["unknown"]:
258
+ return 3
259
+ return 0
260
+
261
+
262
+ def main(argv: Optional[Sequence[str]] = None) -> int:
263
+ parser = build_parser()
264
+ args = parser.parse_args(argv)
265
+
266
+ try:
267
+ paths = _collect_paths(args.paths, args.recursive)
268
+ except (IsADirectoryError, OSError) as exc:
269
+ print(f"zipcheck: {exc}", file=sys.stderr)
270
+ return 4
271
+ if not paths:
272
+ print("zipcheck: no archive files found", file=sys.stderr)
273
+ return 4
274
+
275
+ limits = Limits(
276
+ max_depth=max(0, args.depth),
277
+ timeout=max(0.0, args.timeout),
278
+ probe=args.probe,
279
+ probe_limit=args.probe_limit,
280
+ verify=args.verify or args.require_verified or args.production,
281
+ verify_limit=args.verify_limit,
282
+ require_verified=args.require_verified or args.production,
283
+ min_expansion_bytes=args.min_size,
284
+ suspicious_ratio=args.ratio,
285
+ dangerous_ratio=max(args.dangerous_ratio, args.ratio),
286
+ critical_ratio=max(args.dangerous_ratio * 100.0, args.ratio * 100.0),
287
+ )
288
+
289
+ color = (
290
+ sys.stdout.isatty()
291
+ and not args.no_color
292
+ and os.environ.get("NO_COLOR") is None
293
+ )
294
+ started = time.monotonic()
295
+ results: List[ScanResult] = []
296
+ io_error = False
297
+ for path in paths:
298
+ try:
299
+ result = scan_file(path, limits)
300
+ except Exception as exc: # defensive: a parser bug must not abort the run
301
+ result = ScanResult(path=path, size=0, error=f"internal error: {exc}", unknown=True)
302
+ io_error = True
303
+ results.append(result)
304
+ if not args.json and not args.quiet:
305
+ _print_result(result, args, color)
306
+ print()
307
+
308
+ elapsed = time.monotonic() - started
309
+ summary = _summary(results)
310
+
311
+ if args.json:
312
+ payload: Dict[str, Any] = {
313
+ "tool": "zipcheck",
314
+ "version": __version__,
315
+ "elapsed_ms": round(elapsed * 1000, 3),
316
+ "summary": summary,
317
+ "results": [result.to_dict(sample_entries=max(args.entries, 25)) for result in results],
318
+ }
319
+ json.dump(payload, sys.stdout, indent=2)
320
+ sys.stdout.write("\n")
321
+ elif args.quiet:
322
+ for result in results:
323
+ print(f"{result.verdict.upper():10} {result.path}")
324
+ else:
325
+ print(
326
+ f"{len(results)} file(s): {summary['dangerous']} dangerous, "
327
+ f"{summary['suspicious']} suspicious, {summary['clean']} clean, "
328
+ f"{summary['unknown']} unknown ({elapsed:.2f}s)"
329
+ )
330
+ if not args.verbose:
331
+ print("use -v for notes and member samples; --json for machine output")
332
+
333
+ return _exit_code(results, args.fail_on, io_error)
zipcheck/detect.py ADDED
@@ -0,0 +1,138 @@
1
+ """Format identification.
2
+
3
+ Magic bytes identify the *container format*, not whether it is a bomb. There
4
+ is no universal "zip bomb signature": every bomb is a structurally valid
5
+ archive whose declared expansion is enormous. This module therefore only
6
+ routes a file to the right metadata parser. Extensions are used as a fallback
7
+ for formats with weak or optional magic (notably tar and bare LZMA).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import Optional, Tuple
14
+
15
+ ZIP_MAGICS = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
16
+ RAR4_MAGIC = b"Rar!\x1a\x07\x00"
17
+ RAR5_MAGIC = b"Rar!\x1a\x07\x01\x00"
18
+ SEVENZIP_MAGIC = b"7z\xbc\xaf\x27\x1c"
19
+ GZIP_MAGIC = b"\x1f\x8b"
20
+ BZIP2_MAGIC = b"BZh"
21
+ XZ_MAGIC = b"\xfd7zXZ\x00"
22
+ LZMA_MAGIC = b"\x5d\x00\x00"
23
+ ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
24
+ ZSTD_SKIPPABLE_MASK = 0xFFFFFFF0
25
+ ZSTD_SKIPPABLE_BASE = 0x184D2A50
26
+
27
+ ZIP_EXTENSIONS = {
28
+ ".zip",
29
+ ".zipx",
30
+ ".jar",
31
+ ".war",
32
+ ".apk",
33
+ ".epub",
34
+ ".whl",
35
+ ".xpi",
36
+ ".crx",
37
+ ".cbz",
38
+ ".docx",
39
+ ".xlsx",
40
+ ".pptx",
41
+ ".odt",
42
+ ".ods",
43
+ ".odp",
44
+ }
45
+ TAR_EXTENSIONS = {".tar", ".tarx", ".pax", ".cpio"}
46
+ SEVENZIP_EXTENSIONS = {".7z"}
47
+ RAR_EXTENSIONS = {".rar", ".rev"}
48
+ GZIP_EXTENSIONS = {".gz", ".gzip", ".tgz", ".z"}
49
+ BZIP2_EXTENSIONS = {".bz2", ".bzip2", ".tbz2", ".tbz"}
50
+ XZ_EXTENSIONS = {".xz", ".txz"}
51
+ LZMA_EXTENSIONS = {".lzma"}
52
+ ZSTD_EXTENSIONS = {".zst", ".zstd", ".tzst"}
53
+
54
+ ARCHIVE_EXTENSIONS = (
55
+ ZIP_EXTENSIONS
56
+ | TAR_EXTENSIONS
57
+ | SEVENZIP_EXTENSIONS
58
+ | RAR_EXTENSIONS
59
+ | GZIP_EXTENSIONS
60
+ | BZIP2_EXTENSIONS
61
+ | XZ_EXTENSIONS
62
+ | LZMA_EXTENSIONS
63
+ | ZSTD_EXTENSIONS
64
+ )
65
+
66
+
67
+ def _extension(path: str) -> str:
68
+ base = os.path.basename(path).lower()
69
+ # .tar.gz, .tar.bz2, ... -> outer format is what we can inspect first.
70
+ if base.endswith(".tar.gz"):
71
+ return ".gz"
72
+ if base.endswith(".tar.bz2"):
73
+ return ".bz2"
74
+ if base.endswith(".tar.xz"):
75
+ return ".xz"
76
+ if base.endswith(".tar.zst"):
77
+ return ".zst"
78
+ return os.path.splitext(base)[1]
79
+
80
+
81
+ def extension_suggests_archive(path: str) -> bool:
82
+ return _extension(path) in ARCHIVE_EXTENSIONS
83
+
84
+
85
+ def sniff(head: bytes, path: str = "") -> Tuple[Optional[str], str]:
86
+ """Return ``(format_key, why)`` or ``(None, "")``.
87
+
88
+ ``head`` needs only the first ~512 bytes plus (for ZIP) the extension.
89
+ """
90
+
91
+ ext = _extension(path)
92
+
93
+ if head.startswith(ZIP_MAGICS):
94
+ return "zip", "ZIP local header magic"
95
+ if head.startswith(RAR5_MAGIC):
96
+ return "rar5", "RAR 5 signature"
97
+ if head.startswith(RAR4_MAGIC):
98
+ return "rar4", "RAR 4 signature"
99
+ if head.startswith(SEVENZIP_MAGIC):
100
+ return "7z", "7-Zip signature"
101
+ if head.startswith(GZIP_MAGIC) and len(head) >= 3 and head[2] == 0x08:
102
+ return "gzip", "gzip magic"
103
+ if head.startswith(BZIP2_MAGIC) and len(head) >= 4 and 0x31 <= head[3] <= 0x39:
104
+ return "bzip2", "bzip2 magic"
105
+ if head.startswith(XZ_MAGIC):
106
+ return "xz", "xz magic"
107
+ if head.startswith(ZSTD_MAGIC):
108
+ return "zstd", "Zstandard frame magic"
109
+ if len(head) >= 512 and head[257:262] == b"ustar":
110
+ return "tar", "ustar header at offset 257"
111
+
112
+ # Fallbacks for weak/optional magic.
113
+ if ext in ZIP_EXTENSIONS:
114
+ return "zip", "ZIP extension"
115
+ if ext in TAR_EXTENSIONS:
116
+ return "tar", "tar extension"
117
+ if ext in SEVENZIP_EXTENSIONS:
118
+ return "7z", "7-Zip extension"
119
+ if ext in RAR_EXTENSIONS:
120
+ return "rar5" if head.startswith(RAR5_MAGIC) else "rar4", "RAR extension"
121
+ if ext in GZIP_EXTENSIONS:
122
+ return "gzip", "gzip extension"
123
+ if ext in BZIP2_EXTENSIONS:
124
+ return "bzip2", "bzip2 extension"
125
+ if ext in XZ_EXTENSIONS:
126
+ return "xz", "xz extension"
127
+ if ext in LZMA_EXTENSIONS:
128
+ return "lzma", "LZMA extension"
129
+ if ext in ZSTD_EXTENSIONS:
130
+ return "zstd", "Zstandard extension"
131
+ if len(head) >= 3 and head.startswith(LZMA_MAGIC):
132
+ return "lzma", "LZMA-alone magic"
133
+ return None, ""
134
+
135
+
136
+ def looks_like_archive(head: bytes, path: str = "") -> bool:
137
+ fmt, _ = sniff(head, path)
138
+ return fmt is not None