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.
- uploadshield/__init__.py +49 -0
- uploadshield/__main__.py +17 -0
- uploadshield/checks/__init__.py +65 -0
- uploadshield/checks/archive.py +316 -0
- uploadshield/checks/basic.py +476 -0
- uploadshield/checks/image.py +107 -0
- uploadshield/checks/magika.py +129 -0
- uploadshield/checks/markup.py +181 -0
- uploadshield/checks/office.py +174 -0
- uploadshield/checks/pdf.py +277 -0
- uploadshield/checks/scanners.py +205 -0
- uploadshield/checks/xml.py +123 -0
- uploadshield/cli/__init__.py +276 -0
- uploadshield/cli/_commands.py +401 -0
- uploadshield/cli/_json.py +80 -0
- uploadshield/cli/_render.py +319 -0
- uploadshield/cli/_scan.py +106 -0
- uploadshield/cli/_style.py +95 -0
- uploadshield/cli/_verdicts.py +363 -0
- uploadshield/cli/_walk.py +159 -0
- uploadshield/core/__init__.py +4 -0
- uploadshield/core/_deconstruct.py +39 -0
- uploadshield/core/check.py +79 -0
- uploadshield/core/containers.py +131 -0
- uploadshield/core/result.py +63 -0
- uploadshield/core/sniffing.py +432 -0
- uploadshield/core/types.py +319 -0
- uploadshield/core/upload.py +192 -0
- uploadshield/core/violations.py +79 -0
- uploadshield/django/__init__.py +36 -0
- uploadshield/django/apps.py +13 -0
- uploadshield/django/checks.py +148 -0
- uploadshield/django/cli.py +36 -0
- uploadshield/django/conf.py +189 -0
- uploadshield/django/decorators.py +204 -0
- uploadshield/django/exceptions.py +39 -0
- uploadshield/django/forms.py +40 -0
- uploadshield/django/handlers.py +322 -0
- uploadshield/django/responses.py +92 -0
- uploadshield/django/service.py +473 -0
- uploadshield/django/signals.py +16 -0
- uploadshield/django/storage.py +204 -0
- uploadshield/django/templates/uploadshield/rejected.html +15 -0
- uploadshield/django/validators.py +48 -0
- uploadshield/django/views.py +40 -0
- uploadshield/engine.py +230 -0
- uploadshield/fastapi/__init__.py +23 -0
- uploadshield/fastapi/shield.py +299 -0
- uploadshield/flask/__init__.py +42 -0
- uploadshield/flask/core.py +359 -0
- uploadshield/flask/decorators.py +69 -0
- uploadshield/flask/wtforms.py +58 -0
- uploadshield/policy.py +137 -0
- uploadshield/presets.py +65 -0
- uploadshield/py.typed +0 -0
- uploadshield/sanitizers.py +132 -0
- uploadshield/web.py +218 -0
- uploadshield-2.0.0.dist-info/METADATA +239 -0
- uploadshield-2.0.0.dist-info/RECORD +62 -0
- uploadshield-2.0.0.dist-info/WHEEL +4 -0
- uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
- 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
|
+
}
|