uploadshield 2.0.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.
Files changed (62) hide show
  1. uploadshield/__init__.py +49 -0
  2. uploadshield/__main__.py +17 -0
  3. uploadshield/checks/__init__.py +65 -0
  4. uploadshield/checks/archive.py +316 -0
  5. uploadshield/checks/basic.py +476 -0
  6. uploadshield/checks/image.py +107 -0
  7. uploadshield/checks/magika.py +129 -0
  8. uploadshield/checks/markup.py +181 -0
  9. uploadshield/checks/office.py +174 -0
  10. uploadshield/checks/pdf.py +277 -0
  11. uploadshield/checks/scanners.py +205 -0
  12. uploadshield/checks/xml.py +123 -0
  13. uploadshield/cli/__init__.py +276 -0
  14. uploadshield/cli/_commands.py +401 -0
  15. uploadshield/cli/_json.py +80 -0
  16. uploadshield/cli/_render.py +319 -0
  17. uploadshield/cli/_scan.py +106 -0
  18. uploadshield/cli/_style.py +95 -0
  19. uploadshield/cli/_verdicts.py +363 -0
  20. uploadshield/cli/_walk.py +159 -0
  21. uploadshield/core/__init__.py +4 -0
  22. uploadshield/core/_deconstruct.py +39 -0
  23. uploadshield/core/check.py +79 -0
  24. uploadshield/core/containers.py +131 -0
  25. uploadshield/core/result.py +63 -0
  26. uploadshield/core/sniffing.py +432 -0
  27. uploadshield/core/types.py +319 -0
  28. uploadshield/core/upload.py +192 -0
  29. uploadshield/core/violations.py +79 -0
  30. uploadshield/django/__init__.py +36 -0
  31. uploadshield/django/apps.py +13 -0
  32. uploadshield/django/checks.py +148 -0
  33. uploadshield/django/cli.py +36 -0
  34. uploadshield/django/conf.py +189 -0
  35. uploadshield/django/decorators.py +204 -0
  36. uploadshield/django/exceptions.py +39 -0
  37. uploadshield/django/forms.py +40 -0
  38. uploadshield/django/handlers.py +322 -0
  39. uploadshield/django/responses.py +92 -0
  40. uploadshield/django/service.py +473 -0
  41. uploadshield/django/signals.py +16 -0
  42. uploadshield/django/storage.py +204 -0
  43. uploadshield/django/templates/uploadshield/rejected.html +15 -0
  44. uploadshield/django/validators.py +48 -0
  45. uploadshield/django/views.py +40 -0
  46. uploadshield/engine.py +230 -0
  47. uploadshield/fastapi/__init__.py +23 -0
  48. uploadshield/fastapi/shield.py +299 -0
  49. uploadshield/flask/__init__.py +42 -0
  50. uploadshield/flask/core.py +359 -0
  51. uploadshield/flask/decorators.py +69 -0
  52. uploadshield/flask/wtforms.py +58 -0
  53. uploadshield/policy.py +137 -0
  54. uploadshield/presets.py +65 -0
  55. uploadshield/py.typed +0 -0
  56. uploadshield/sanitizers.py +132 -0
  57. uploadshield/web.py +218 -0
  58. uploadshield-2.0.0.dist-info/METADATA +239 -0
  59. uploadshield-2.0.0.dist-info/RECORD +62 -0
  60. uploadshield-2.0.0.dist-info/WHEEL +4 -0
  61. uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
  62. uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,276 @@
1
+ """The ``uploadshield`` command: inspect files the way an upload endpoint would.
2
+
3
+ ``uploadshield scan PATH...`` is anything here dangerous to accept as an upload?
4
+ ``uploadshield validate PATH...`` would my policy accept this file, and why (not)?
5
+ ``uploadshield policies [NAME]`` which policies exist, and what does one do?
6
+ ``uploadshield explain [CODE]`` what does a violation code mean?
7
+
8
+ Exit status: 0 everything clean or accepted, 1 something was not, 2 the command could
9
+ not run as asked, 130 interrupted. The command uses the standard library only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import contextlib
16
+ import logging
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, NoReturn, TextIO
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Generator, Sequence
24
+
25
+ __all__ = ["main"]
26
+
27
+ _DESCRIPTION = """\
28
+ Inspect files the way an upload endpoint would: by their content, not their name.
29
+
30
+ scan Is anything here dangerous to accept as an upload?
31
+ validate Would a policy accept these files - and why not?
32
+ policies The policies you can validate against.
33
+ explain What a violation code means and how to allow it.
34
+ """
35
+ _EPILOG = """\
36
+ examples:
37
+ uploadshield scan ~/Downloads
38
+ uploadshield scan uploads/ --exclude '*.log' --quiet
39
+ uploadshield validate avatar.jpg --policy sanitized_images --save out/
40
+ curl -s https://example.com/f | uploadshield validate - --name f.pdf --policy pdf
41
+ uploadshield validate cv.docx --django --policy documents
42
+ uploadshield explain office_macros
43
+
44
+ exit status: 0 clean or accepted, 1 not, 2 usage or setup error, 130 interrupted
45
+ """
46
+
47
+
48
+ class _UsageError(Exception):
49
+ def __init__(self, parser: argparse.ArgumentParser, message: str) -> None:
50
+ super().__init__(message)
51
+ self.parser = parser
52
+
53
+
54
+ class _Exit(Exception): # noqa: N818 - control flow for --help / --version
55
+ def __init__(self, status: int) -> None:
56
+ super().__init__(status)
57
+ self.status = status
58
+
59
+
60
+ class _Parser(argparse.ArgumentParser):
61
+ """Raises instead of exiting, so :func:`main` always returns its status."""
62
+
63
+ def error(self, message: str) -> NoReturn:
64
+ raise _UsageError(self, message)
65
+
66
+ def exit(self, status: int = 0, message: str | None = None) -> NoReturn:
67
+ if message:
68
+ sys.stderr.write(message)
69
+ raise _Exit(status)
70
+
71
+
72
+ def build_parser() -> argparse.ArgumentParser:
73
+ from uploadshield import __version__
74
+
75
+ output = _Parser(add_help=False)
76
+ group = output.add_argument_group("output")
77
+ group.add_argument("--json", action="store_true", help="print one JSON document")
78
+ group.add_argument(
79
+ "--color",
80
+ choices=("auto", "always", "never"),
81
+ default="auto",
82
+ help="colour the output (auto: on a terminal, unless NO_COLOR is set)",
83
+ )
84
+ group.add_argument(
85
+ "-q", "--quiet", action="store_true", help="only show files that are not clean"
86
+ )
87
+ group.add_argument(
88
+ "-v", "--verbose", action="store_true", help="show the engine's log messages"
89
+ )
90
+
91
+ files = _Parser(add_help=False)
92
+ group = files.add_argument_group("files")
93
+ group.add_argument("paths", nargs="+", metavar="PATH", help="files or directories; - for stdin")
94
+ group.add_argument(
95
+ "--name",
96
+ metavar="NAME",
97
+ help="judge the file under this name (required for stdin; simulates a client)",
98
+ )
99
+ group.add_argument(
100
+ "--exclude",
101
+ metavar="GLOB",
102
+ action="append",
103
+ default=[],
104
+ help="skip names or paths matching GLOB (repeatable)",
105
+ )
106
+ group.add_argument(
107
+ "--follow-symlinks",
108
+ action="store_true",
109
+ help="follow symbolic links found in directories (those you name are always followed)",
110
+ )
111
+
112
+ scanners = _Parser(add_help=False)
113
+ group = scanners.add_argument_group("extra scanners")
114
+ group.add_argument(
115
+ "--clamav",
116
+ metavar="ADDRESS",
117
+ help="also scan with clamd: unix:///var/run/clamav/clamd.ctl or tcp://host:3310",
118
+ )
119
+ group.add_argument(
120
+ "--yara",
121
+ metavar="RULES",
122
+ action="append",
123
+ help="also match YARA rules: a file or directory (repeatable; needs uploadshield[yara])",
124
+ )
125
+ group.add_argument(
126
+ "--magika",
127
+ action="store_true",
128
+ help="also ask Google's Magika model (needs uploadshield[magika])",
129
+ )
130
+
131
+ project = _Parser(add_help=False)
132
+ group = project.add_argument_group("where policies come from")
133
+ group.add_argument(
134
+ "--django",
135
+ action="store_true",
136
+ help="use the Django project's UPLOADSHIELD settings (DJANGO_SETTINGS_MODULE)",
137
+ )
138
+ group.add_argument(
139
+ "--settings", metavar="MODULE", help="Django settings module (implies --django)"
140
+ )
141
+ group.add_argument(
142
+ "--pythonpath",
143
+ metavar="DIR",
144
+ action="append",
145
+ default=[],
146
+ help="import policies from DIR too (for dotted paths; repeatable)",
147
+ )
148
+
149
+ parser = _Parser(
150
+ prog="uploadshield",
151
+ description=_DESCRIPTION,
152
+ epilog=_EPILOG,
153
+ formatter_class=argparse.RawDescriptionHelpFormatter,
154
+ )
155
+ parser.add_argument("--version", action="version", version=f"uploadshield {__version__}")
156
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
157
+
158
+ commands.add_parser(
159
+ "scan",
160
+ parents=[files, scanners, output],
161
+ help="is anything here dangerous to accept as an upload?",
162
+ description=(
163
+ "Judge files by what they contain: executables and scripts in disguise, "
164
+ "active PDF/Office/SVG content, archive bombs, hidden code, polyglots. Every "
165
+ "format uploadshield can inspect is accepted; names only count when they are "
166
+ "dangerous (shell.php.png, .htaccess)."
167
+ ),
168
+ epilog="verdicts: clean, dangerous, refused (harmless but unusual), not verified",
169
+ )
170
+
171
+ check = commands.add_parser(
172
+ "validate",
173
+ parents=[files, project, scanners, output],
174
+ help="would a policy accept these files - and why not?",
175
+ description=(
176
+ "Run files through a policy exactly as an upload endpoint would, and show "
177
+ "every check, every violation with its details, and what sanitizers changed."
178
+ ),
179
+ )
180
+ group = check.add_argument_group("policy")
181
+ group.add_argument(
182
+ "-p",
183
+ "--policy",
184
+ metavar="POLICY",
185
+ help="a policy name (uploadshield policies) or a dotted path (myapp.uploads.AVATARS); "
186
+ "default: default, or the project's DEFAULT_POLICY with --django",
187
+ )
188
+ group.add_argument(
189
+ "--declared-type", metavar="MIME", help="the Content-Type a client would send"
190
+ )
191
+ group.add_argument(
192
+ "--all-checks",
193
+ action="store_true",
194
+ help="run every check instead of stopping at the first failure",
195
+ )
196
+ group.add_argument(
197
+ "--save",
198
+ metavar="DIR",
199
+ type=Path,
200
+ help="write each accepted file as it would be stored (sanitized); never overwrites",
201
+ )
202
+
203
+ listing = commands.add_parser(
204
+ "policies",
205
+ parents=[project, output],
206
+ help="the policies you can validate against",
207
+ description="List policies, or show what one allows and which checks it runs.",
208
+ )
209
+ listing.add_argument("name", nargs="?", metavar="NAME", help="show this policy in detail")
210
+
211
+ explain = commands.add_parser(
212
+ "explain",
213
+ parents=[output],
214
+ help="what a violation code means and how to allow it",
215
+ description="Explain violation codes (all of them, without arguments).",
216
+ )
217
+ explain.add_argument("codes", nargs="*", metavar="CODE")
218
+ return parser
219
+
220
+
221
+ def main(argv: Sequence[str] | None = None) -> int:
222
+ """Run the command; returns the exit status (also for ``--help`` and usage errors)."""
223
+ parser = build_parser()
224
+ try:
225
+ args = parser.parse_args(argv)
226
+ except _Exit as exc:
227
+ return exc.status
228
+ except _UsageError as exc:
229
+ exc.parser.print_usage(sys.stderr)
230
+ print(f"{exc.parser.prog}: error: {exc}", file=sys.stderr)
231
+ return 2
232
+
233
+ from ._commands import COMMANDS, Console
234
+ from ._scan import SetupError
235
+ from ._style import Style, color_enabled
236
+
237
+ out, err = sys.stdout, sys.stderr
238
+ for stream in (out, err):
239
+ with contextlib.suppress(AttributeError, ValueError):
240
+ stream.reconfigure(errors="backslashreplace") # ty: ignore[unresolved-attribute]
241
+ console = Console(out, err, Style(out, color=color_enabled(args.color, out)))
242
+ with _engine_logs(verbose=args.verbose, stream=err):
243
+ try:
244
+ return COMMANDS[args.command](args, console)
245
+ except SetupError as exc:
246
+ print(f"uploadshield: {exc}", file=err)
247
+ return 2
248
+ except KeyboardInterrupt:
249
+ return 130
250
+ except BrokenPipeError:
251
+ # The reader went away (| head): stop quietly, as other tools do.
252
+ devnull = os.open(os.devnull, os.O_WRONLY)
253
+ os.dup2(devnull, out.fileno())
254
+ return 1
255
+
256
+
257
+ @contextlib.contextmanager
258
+ def _engine_logs(*, verbose: bool, stream: TextIO) -> Generator[None, None, None]:
259
+ """Keep engine logs (tracebacks of crashed checks) out of the report unless asked."""
260
+ logger = logging.getLogger("uploadshield")
261
+ handler: logging.Handler
262
+ if verbose:
263
+ handler = logging.StreamHandler(stream)
264
+ handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
265
+ else:
266
+ handler = logging.NullHandler()
267
+ saved = logger.propagate, logger.level
268
+ logger.addHandler(handler)
269
+ logger.propagate = False
270
+ if verbose:
271
+ logger.setLevel(logging.DEBUG)
272
+ try:
273
+ yield
274
+ finally:
275
+ logger.removeHandler(handler)
276
+ logger.propagate, logger.level = saved
@@ -0,0 +1,401 @@
1
+ """The four commands: ``scan``, ``validate``, ``policies`` and ``explain``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import IO, TYPE_CHECKING, Any, TextIO
12
+
13
+ from uploadshield import presets, web
14
+ from uploadshield.core.types import format_size, get_type
15
+ from uploadshield.core.upload import Upload
16
+ from uploadshield.engine import validate
17
+
18
+ from ._json import dumps
19
+ from ._render import HumanReporter, JsonReporter, Reporter, missing_dependency
20
+ from ._scan import SetupError, scan_policy, scanner_checks
21
+ from ._style import Style, safe
22
+ from ._verdicts import CODE_INFO, CONDITIONS, Outcome, Summary, Verdict, judge
23
+ from ._walk import STDIN, Target, iter_targets, missing_paths, open_target
24
+
25
+ if TYPE_CHECKING:
26
+ import argparse
27
+
28
+ from uploadshield.core.violations import Code
29
+ from uploadshield.policy import Policy
30
+
31
+ __all__ = ["COMMANDS", "Console"]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Console:
36
+ out: TextIO
37
+ err: TextIO
38
+ style: Style
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # scan and validate
43
+ # --------------------------------------------------------------------------- #
44
+
45
+
46
+ def scan(args: argparse.Namespace, console: Console) -> int:
47
+ _check_paths(args)
48
+ extra = scanner_checks(clamav=args.clamav, yara=args.yara, magika=args.magika)
49
+ policy = scan_policy(extra)
50
+ return _inspect_all(args, console, policy, scan=True)
51
+
52
+
53
+ def validate_command(args: argparse.Namespace, console: Console) -> int:
54
+ _check_paths(args)
55
+ policy, _ = _policies(args)
56
+ extra = scanner_checks(clamav=args.clamav, yara=args.yara, magika=args.magika)
57
+ if extra:
58
+ policy = policy.extend(checks=extra)
59
+ if args.all_checks:
60
+ policy = policy.replace(fail_fast=False)
61
+ if args.save is not None:
62
+ try:
63
+ args.save.mkdir(parents=True, exist_ok=True)
64
+ except OSError as exc:
65
+ raise SetupError(f"cannot create {args.save}: {exc.strerror or exc}") from exc
66
+ return _inspect_all(args, console, policy, scan=False)
67
+
68
+
69
+ def _check_paths(args: argparse.Namespace) -> None:
70
+ if missing := missing_paths(args.paths):
71
+ raise SetupError(f"no such file or directory: {', '.join(safe(p) for p in missing)}")
72
+ if args.paths.count(STDIN) > 1:
73
+ raise SetupError("standard input (-) can only be read once")
74
+ if STDIN in args.paths and args.name is None:
75
+ raise SetupError("reading standard input needs --name: the name a client would send")
76
+ if args.name is not None and (len(args.paths) > 1 or Path(args.paths[0]).is_dir()):
77
+ raise SetupError("--name applies to a single file or to standard input")
78
+
79
+
80
+ def _inspect_all(args: argparse.Namespace, console: Console, policy: Policy, *, scan: bool) -> int:
81
+ reporter: Reporter
82
+ if args.json:
83
+ reporter = JsonReporter(console.out, command=args.command, policy=policy.name or None)
84
+ else:
85
+ reporter = HumanReporter(
86
+ console.out,
87
+ console.err,
88
+ console.style,
89
+ policy=policy,
90
+ detailed=not scan,
91
+ quiet=args.quiet,
92
+ )
93
+ for component in (*policy.pipeline, *policy.sanitizers):
94
+ if (extra := missing_dependency(component)) is not None:
95
+ reporter.warn(
96
+ f"{component.name} does nothing without the {extra} extra: "
97
+ f'uvx --from "uploadshield[{extra}]" uploadshield ..., '
98
+ f"or uv add 'uploadshield[{extra}]' in your project"
99
+ )
100
+ declared = getattr(args, "declared_type", None)
101
+ if declared and not policy.match_declared_type:
102
+ reporter.warn(
103
+ f'--declared-type has no effect: policy "{policy.name}" ignores the type a '
104
+ "client declares (match_declared_type=False)"
105
+ )
106
+ summary = Summary()
107
+ started = time.perf_counter()
108
+ interrupted = False
109
+ try:
110
+ targets = iter_targets(
111
+ args.paths, exclude=args.exclude, follow_symlinks=args.follow_symlinks
112
+ )
113
+ for target in targets:
114
+ outcome = _inspect(
115
+ target,
116
+ policy,
117
+ scan=scan,
118
+ name=args.name,
119
+ declared_type=declared,
120
+ save=getattr(args, "save", None),
121
+ )
122
+ summary.add(outcome)
123
+ reporter.file(outcome)
124
+ except KeyboardInterrupt:
125
+ interrupted = True
126
+ summary.seconds = time.perf_counter() - started
127
+ exit_code = 130 if interrupted else summary.exit_code
128
+ reporter.finish(summary, interrupted=interrupted, exit_code=exit_code)
129
+ return exit_code
130
+
131
+
132
+ def _inspect(
133
+ target: Target,
134
+ policy: Policy,
135
+ *,
136
+ scan: bool,
137
+ name: str | None,
138
+ declared_type: str | None,
139
+ save: Path | None,
140
+ ) -> Outcome:
141
+ if target.skipped is not None:
142
+ return _outcome(target, skipped=target.skipped)
143
+ if target.error is not None:
144
+ return _outcome(target, verdict=Verdict.NOT_VERIFIED, error=target.error)
145
+ try:
146
+ opened = open_target(target)
147
+ except OSError as exc:
148
+ reason = exc.strerror or type(exc).__name__
149
+ return _outcome(target, verdict=Verdict.NOT_VERIFIED, error=reason)
150
+ if isinstance(opened, str):
151
+ return _outcome(target, skipped=opened)
152
+ stream, size = opened
153
+ with stream:
154
+ upload = Upload(
155
+ stream=stream,
156
+ name=target.name if name is None else name,
157
+ size=size,
158
+ declared_type=declared_type,
159
+ )
160
+ result = validate(upload, policy, sanitize=not scan)
161
+ verdict, each = judge(result, scan=scan)
162
+ outcome = _outcome(
163
+ target, result=result, verdict=verdict, verdicts=each, sha256=web.sha256_of(stream)
164
+ )
165
+ if save is not None and result.ok:
166
+ _save(outcome, stream, save)
167
+ return outcome
168
+
169
+
170
+ def _outcome(target: Target, **fields: Any) -> Outcome:
171
+ return Outcome(target.display, root=target.root, label=target.label or target.display, **fields)
172
+
173
+
174
+ def _save(outcome: Outcome, stream: IO[bytes], directory: Path) -> None:
175
+ """Write what the application would store: the sanitized file, or the original."""
176
+ result = outcome.result
177
+ if result is None: # pragma: no cover - only called for inspected files
178
+ return
179
+ sanitized = result.sanitized
180
+ name = Path(sanitized.name if sanitized is not None else result.name).name
181
+ if name in ("", ".", ".."):
182
+ outcome.save_error = "the file has no usable name"
183
+ return
184
+ destination = directory / name
185
+ try:
186
+ with destination.open("xb") as fh:
187
+ if sanitized is not None and sanitized.data is not None:
188
+ fh.write(sanitized.data)
189
+ else:
190
+ stream.seek(0)
191
+ shutil.copyfileobj(stream, fh)
192
+ except FileExistsError:
193
+ outcome.save_error = f"{destination} exists (files are never overwritten)"
194
+ return
195
+ except OSError as exc:
196
+ outcome.save_error = f"{destination}: {exc.strerror or exc}"
197
+ return
198
+ outcome.saved = str(destination)
199
+
200
+
201
+ # --------------------------------------------------------------------------- #
202
+ # Policies
203
+ # --------------------------------------------------------------------------- #
204
+
205
+
206
+ def _policies(args: argparse.Namespace) -> tuple[Policy, dict[str, Policy]]:
207
+ """The policy ``--policy`` names, and every policy known by name."""
208
+ ref = getattr(args, "policy", None)
209
+ if args.pythonpath:
210
+ sys.path[:0] = [str(Path(p).resolve()) for p in args.pythonpath]
211
+ if args.django or args.settings or "." in (ref or ""):
212
+ # Project code is asked for: find it from the project directory, as manage.py
213
+ # does - but last, so nothing in the current directory shadows an installed
214
+ # package (a planted PIL/ in a folder of uploads, say).
215
+ sys.path.append(str(Path.cwd()))
216
+ if args.django or args.settings:
217
+ try:
218
+ from uploadshield.django.cli import configure
219
+
220
+ config = configure(args.settings)
221
+ known = dict(config.policies)
222
+ if ref is None:
223
+ return config.default_policy or presets.DEFAULT, known
224
+ return config.resolve(ref), known
225
+ except Exception as exc:
226
+ raise SetupError(f"Django: {exc}") from exc
227
+ known = dict(presets.BUILTIN_POLICIES)
228
+ try:
229
+ return web.resolve_policy(ref or "default", known), known
230
+ except web.PolicyLookupError as exc:
231
+ hint = "" if "." in (ref or "") else " (use --django for policies in Django settings)"
232
+ raise SetupError(f"{exc}{hint}") from exc
233
+
234
+
235
+ def policies(args: argparse.Namespace, console: Console) -> int:
236
+ args.policy = args.name
237
+ chosen, known = _policies(args)
238
+ s = console.style
239
+ if args.json:
240
+ selected = [chosen] if args.name else list(known.values())
241
+ print(dumps({"policies": [_policy_data(p) for p in selected]}), file=console.out)
242
+ return 0
243
+ if args.name is None:
244
+ source = (
245
+ "your project's and the built-in ones"
246
+ if args.django or args.settings
247
+ else ("built in; --django adds your project's")
248
+ )
249
+ print(s.bold(f"Policies ({source}):"), file=console.out)
250
+ width = max(len(n) for n in known)
251
+ for name, policy in sorted(known.items()):
252
+ own = s.dim(" (project)") if presets.BUILTIN_POLICIES.get(name) != policy else ""
253
+ line = f" {s.bold(name.ljust(width))} {_policy_summary(policy, s)}{own}"
254
+ print(line, file=console.out)
255
+ print(s.dim("\nDetails: uploadshield policies <name>"), file=console.out)
256
+ return 0
257
+ _show_policy(chosen, console)
258
+ return 0
259
+
260
+
261
+ def _policy_summary(policy: Policy, s: Style) -> str:
262
+ size = f"up to {format_size(policy.max_size)}" if policy.max_size else "no size limit"
263
+ parts = [_types_summary(policy), size]
264
+ if policy.sanitizers:
265
+ parts.append("sanitizes: " + ", ".join(x.name for x in policy.sanitizers))
266
+ return f" {s.dot} ".join(parts)
267
+
268
+
269
+ def _types_summary(policy: Policy) -> str:
270
+ types = sorted(policy.types)
271
+ if len(types) <= 3:
272
+ return ", ".join(_type_label(t) for t in types)
273
+ families = sorted({t.split("/")[0] for t in types})
274
+ return f"{len(types)} formats ({', '.join(families)})"
275
+
276
+
277
+ def _type_label(mime: str) -> str:
278
+ found = get_type(mime)
279
+ return f"{found.label} ({mime})" if found is not None else mime
280
+
281
+
282
+ def _show_policy(policy: Policy, console: Console) -> None:
283
+ s = console.style
284
+
285
+ def row(label: str, value: str) -> None:
286
+ print(f" {s.dim(f'{label:<12}')} {value}", file=console.out)
287
+
288
+ print(s.bold(policy.name or "custom policy"), file=console.out)
289
+ types = sorted(policy.types)
290
+ row("allows", _type_label(types[0]) if types else "nothing")
291
+ for mime in types[1:]:
292
+ row("", _type_label(mime))
293
+ limit = format_size(policy.max_size) if policy.max_size else "no limit"
294
+ row("max size", f"{limit}; empty files {'allowed' if policy.allow_empty else 'refused'}")
295
+ row("pipeline", f" {s.arrow} ".join(c.name for c in policy.pipeline))
296
+ for component in (*policy.pipeline, *policy.sanitizers):
297
+ _, _, options = component.deconstruct()
298
+ if options:
299
+ shown = ", ".join(f"{k}={_short(v)}" for k, v in options.items())
300
+ row("", s.dim(f"{type(component).__name__}({shown})"))
301
+ row("sanitizers", ", ".join(x.name for x in policy.sanitizers) or "none")
302
+ row("names", "must match the content" if policy.match_extension else "not compared")
303
+ row(
304
+ "declared",
305
+ "the client's Content-Type must match" if policy.match_declared_type else "ignored",
306
+ )
307
+ stops = "yes: stops at the first failing check" if policy.fail_fast else "no: runs all"
308
+ row("fail fast", stops)
309
+ for component in (*policy.pipeline, *policy.sanitizers):
310
+ if (extra := missing_dependency(component)) is not None:
311
+ row(
312
+ "inactive",
313
+ s.paint(
314
+ f"{component.name} does nothing without uploadshield[{extra}] "
315
+ f"(uv add 'uploadshield[{extra}]')",
316
+ "yellow",
317
+ ),
318
+ )
319
+
320
+
321
+ def _short(value: Any) -> str:
322
+ text = repr(value)
323
+ return text if len(text) <= 60 else text[:59] + "\u2026"
324
+
325
+
326
+ def _policy_data(policy: Policy) -> dict[str, Any]:
327
+ return {
328
+ "name": policy.name,
329
+ "allowed_types": sorted(policy.types),
330
+ "max_size": policy.max_size,
331
+ "allow_empty": policy.allow_empty,
332
+ "match_extension": policy.match_extension,
333
+ "match_declared_type": policy.match_declared_type,
334
+ "pipeline": [c.name for c in policy.pipeline],
335
+ "sanitizers": [x.name for x in policy.sanitizers],
336
+ "fail_fast": policy.fail_fast,
337
+ "inactive": [
338
+ c.name
339
+ for c in (*policy.pipeline, *policy.sanitizers)
340
+ if missing_dependency(c) is not None
341
+ ],
342
+ }
343
+
344
+
345
+ # --------------------------------------------------------------------------- #
346
+ # explain
347
+ # --------------------------------------------------------------------------- #
348
+
349
+
350
+ def explain(args: argparse.Namespace, console: Console) -> int:
351
+ s = console.style
352
+ known = {str(code): code for code in CODE_INFO}
353
+ unknown = [c for c in args.codes if c not in known]
354
+ if unknown:
355
+ for code in unknown:
356
+ close = difflib.get_close_matches(code, known, n=1)
357
+ hint = f"; did you mean {close[0]}?" if close else ""
358
+ print(f"uploadshield: unknown code {safe(code)!r}{hint}", file=console.err)
359
+ print("Every code: uploadshield explain", file=console.err)
360
+ return 2
361
+ codes = [known[c] for c in args.codes]
362
+ if args.json:
363
+ selected = codes or list(CODE_INFO)
364
+ data = [
365
+ {"code": str(c), "verdict": CODE_INFO[c].verdict.value, "condition": CONDITIONS.get(c)}
366
+ | {k: getattr(CODE_INFO[c], k) for k in ("title", "meaning", "cause", "relax")}
367
+ for c in selected
368
+ ]
369
+ print(dumps({"codes": data}), file=console.out)
370
+ return 0
371
+ if not codes:
372
+ for verdict in (Verdict.DANGEROUS, Verdict.NOT_VERIFIED, Verdict.REFUSED):
373
+ print(s.bold(verdict.label.capitalize()), file=console.out)
374
+ for code, info in CODE_INFO.items():
375
+ if info.verdict is verdict:
376
+ print(f" {code!s:<30} {info.title}", file=console.out)
377
+ print(s.dim("\nDetails: uploadshield explain <code>"), file=console.out)
378
+ return 0
379
+ for code in codes:
380
+ _explain_one(code, console)
381
+ return 0
382
+
383
+
384
+ def _explain_one(code: Code, console: Console) -> None:
385
+ s = console.style
386
+ info = CODE_INFO[code]
387
+ verdict = info.verdict.label
388
+ if condition := CONDITIONS.get(code):
389
+ verdict += f"; {condition}"
390
+ print(f"{s.bold(str(code))} {info.title} {s.dim(f'({verdict})')}", file=console.out)
391
+ for label, text in (("what", info.meaning), ("why", info.cause), ("to allow", info.relax)):
392
+ print(f" {s.dim(f'{label:<9}')} {text}", file=console.out)
393
+ print("", file=console.out)
394
+
395
+
396
+ COMMANDS = {
397
+ "scan": scan,
398
+ "validate": validate_command,
399
+ "policies": policies,
400
+ "explain": explain,
401
+ }