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,80 @@
|
|
|
1
|
+
"""The ``--json`` document: one object with every file, a summary and the exit status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from uploadshield.web import json_safe
|
|
9
|
+
|
|
10
|
+
from ._verdicts import Verdict
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._verdicts import Outcome, Summary
|
|
14
|
+
|
|
15
|
+
__all__ = ["SCHEMA", "document", "dumps", "json_safe"]
|
|
16
|
+
|
|
17
|
+
#: Bumped when a field changes meaning or disappears; new fields do not bump it.
|
|
18
|
+
SCHEMA = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def file_entry(outcome: Outcome) -> dict[str, Any]:
|
|
22
|
+
entry: dict[str, Any] = {"path": outcome.display, "verdict": outcome.verdict.value}
|
|
23
|
+
if outcome.skipped is not None:
|
|
24
|
+
return {"path": outcome.display, "verdict": None, "skipped": outcome.skipped}
|
|
25
|
+
if outcome.error is not None:
|
|
26
|
+
entry["error"] = outcome.error
|
|
27
|
+
if outcome.result is not None:
|
|
28
|
+
entry["sha256"] = outcome.sha256
|
|
29
|
+
result = outcome.result
|
|
30
|
+
entry |= result.as_dict(include_details=True)
|
|
31
|
+
entry.pop("field", None) # always None outside a web request
|
|
32
|
+
for error, verdict in zip(entry["errors"], outcome.verdicts, strict=True):
|
|
33
|
+
error["verdict"] = verdict.value
|
|
34
|
+
if (sanitized := result.sanitized) is not None:
|
|
35
|
+
entry["sanitized_as"] = {
|
|
36
|
+
"name": sanitized.name,
|
|
37
|
+
"content_type": sanitized.content_type,
|
|
38
|
+
"size": len(sanitized.data) if sanitized.data is not None else result.size,
|
|
39
|
+
"content_changed": sanitized.data is not None,
|
|
40
|
+
}
|
|
41
|
+
if outcome.saved is not None:
|
|
42
|
+
entry["saved"] = outcome.saved
|
|
43
|
+
if outcome.save_error is not None:
|
|
44
|
+
entry["save_error"] = outcome.save_error
|
|
45
|
+
return entry
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def document(
|
|
49
|
+
*,
|
|
50
|
+
command: str,
|
|
51
|
+
policy: str | None,
|
|
52
|
+
outcomes: list[Outcome],
|
|
53
|
+
summary: Summary,
|
|
54
|
+
warnings: list[str],
|
|
55
|
+
interrupted: bool,
|
|
56
|
+
exit_code: int,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
from uploadshield import __version__
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"schema": SCHEMA,
|
|
62
|
+
"version": __version__,
|
|
63
|
+
"command": command,
|
|
64
|
+
"policy": policy,
|
|
65
|
+
"files": [file_entry(o) for o in outcomes],
|
|
66
|
+
"summary": {
|
|
67
|
+
"files": summary.files,
|
|
68
|
+
**{verdict.value: summary.counts[verdict] for verdict in Verdict},
|
|
69
|
+
"skipped": sum(summary.skipped.values()),
|
|
70
|
+
"seconds": round(summary.seconds, 3),
|
|
71
|
+
},
|
|
72
|
+
"warnings": warnings,
|
|
73
|
+
"interrupted": interrupted,
|
|
74
|
+
"exit_code": exit_code,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dumps(data: dict[str, Any]) -> str:
|
|
79
|
+
# ASCII escapes keep undecodable names (lone surrogates) valid and printable.
|
|
80
|
+
return json.dumps(json_safe(data), indent=2, ensure_ascii=True)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Human-readable output for ``scan`` and ``validate``, and the ``--json`` reporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol, TextIO
|
|
8
|
+
|
|
9
|
+
from uploadshield.core.types import format_size
|
|
10
|
+
from uploadshield.core.violations import Code
|
|
11
|
+
|
|
12
|
+
from ._json import document, dumps
|
|
13
|
+
from ._style import Style, safe
|
|
14
|
+
from ._verdicts import Verdict
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from uploadshield.core.check import Check, Sanitizer
|
|
18
|
+
from uploadshield.core.result import ValidationResult
|
|
19
|
+
from uploadshield.policy import Policy
|
|
20
|
+
|
|
21
|
+
from ._verdicts import Outcome, Summary
|
|
22
|
+
|
|
23
|
+
__all__ = ["HumanReporter", "JsonReporter", "Reporter", "missing_dependency"]
|
|
24
|
+
|
|
25
|
+
_COLORS = {
|
|
26
|
+
Verdict.CLEAN: "green",
|
|
27
|
+
Verdict.REFUSED: "yellow",
|
|
28
|
+
Verdict.NOT_VERIFIED: "magenta",
|
|
29
|
+
Verdict.DANGEROUS: "red",
|
|
30
|
+
}
|
|
31
|
+
#: Details worth a glance in a scan (``validate`` shows all of them).
|
|
32
|
+
_HIGHLIGHTS = (
|
|
33
|
+
"keywords",
|
|
34
|
+
"members",
|
|
35
|
+
"found",
|
|
36
|
+
"marker",
|
|
37
|
+
"signature",
|
|
38
|
+
"rules",
|
|
39
|
+
"magika_label",
|
|
40
|
+
"read_as",
|
|
41
|
+
)
|
|
42
|
+
#: Optional dependencies without which a component silently does nothing.
|
|
43
|
+
_NEEDS: dict[str, tuple[str, str]] = {
|
|
44
|
+
"ImageIntegrity": ("PIL", "images"),
|
|
45
|
+
"ReencodeImage": ("PIL", "images"),
|
|
46
|
+
"MagikaCheck": ("magika", "magika"),
|
|
47
|
+
"YaraRules": ("yara", "yara"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def missing_dependency(component: Check | Sanitizer) -> str | None:
|
|
52
|
+
"""The extra a check or sanitizer needs but cannot find (it is then inactive)."""
|
|
53
|
+
needed = _NEEDS.get(type(component).__name__)
|
|
54
|
+
if needed is None or importlib.util.find_spec(needed[0]) is not None:
|
|
55
|
+
return None
|
|
56
|
+
return needed[1]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Reporter(Protocol):
|
|
60
|
+
def file(self, outcome: Outcome) -> None: ...
|
|
61
|
+
def warn(self, text: str) -> None: ...
|
|
62
|
+
def finish(self, summary: Summary, *, interrupted: bool, exit_code: int) -> None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class JsonReporter:
|
|
66
|
+
"""Collects everything and prints one document at the end."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, out: TextIO, *, command: str, policy: str | None) -> None:
|
|
69
|
+
self.out = out
|
|
70
|
+
self.command = command
|
|
71
|
+
self.policy = policy
|
|
72
|
+
self.outcomes: list[Outcome] = []
|
|
73
|
+
self.warnings: list[str] = []
|
|
74
|
+
|
|
75
|
+
def file(self, outcome: Outcome) -> None:
|
|
76
|
+
self.outcomes.append(outcome)
|
|
77
|
+
|
|
78
|
+
def warn(self, text: str) -> None:
|
|
79
|
+
self.warnings.append(text)
|
|
80
|
+
|
|
81
|
+
def finish(self, summary: Summary, *, interrupted: bool, exit_code: int) -> None:
|
|
82
|
+
data = document(
|
|
83
|
+
command=self.command,
|
|
84
|
+
policy=self.policy,
|
|
85
|
+
outcomes=self.outcomes,
|
|
86
|
+
summary=summary,
|
|
87
|
+
warnings=self.warnings,
|
|
88
|
+
interrupted=interrupted,
|
|
89
|
+
exit_code=exit_code,
|
|
90
|
+
)
|
|
91
|
+
print(dumps(data), file=self.out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HumanReporter:
|
|
95
|
+
"""Prints each file as it is inspected: a line per file (scan) or a trace (validate)."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
out: TextIO,
|
|
100
|
+
err: TextIO,
|
|
101
|
+
style: Style,
|
|
102
|
+
*,
|
|
103
|
+
policy: Policy,
|
|
104
|
+
detailed: bool,
|
|
105
|
+
quiet: bool,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.out, self.err, self.s = out, err, style
|
|
108
|
+
self.policy = policy
|
|
109
|
+
self.detailed = detailed
|
|
110
|
+
self.quiet = quiet
|
|
111
|
+
self._root: str | None = None
|
|
112
|
+
|
|
113
|
+
# -- per file ---------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def file(self, outcome: Outcome) -> None:
|
|
116
|
+
if outcome.skipped is not None:
|
|
117
|
+
if not self.quiet:
|
|
118
|
+
self._heading(outcome)
|
|
119
|
+
line = f" - {safe(outcome.label)} skipped: {outcome.skipped}"
|
|
120
|
+
self._print(self.s.dim(line))
|
|
121
|
+
return
|
|
122
|
+
if self.quiet and outcome.verdict is Verdict.CLEAN and outcome.save_error is None:
|
|
123
|
+
return
|
|
124
|
+
self._heading(outcome)
|
|
125
|
+
if self.detailed:
|
|
126
|
+
self._trace(outcome)
|
|
127
|
+
else:
|
|
128
|
+
self._line(outcome)
|
|
129
|
+
|
|
130
|
+
def _heading(self, outcome: Outcome) -> None:
|
|
131
|
+
"""Files found in a directory are listed under it, relative to it."""
|
|
132
|
+
if outcome.root != self._root:
|
|
133
|
+
self._root = outcome.root
|
|
134
|
+
if outcome.root is not None:
|
|
135
|
+
self._print(self.s.bold(safe(outcome.root.rstrip("/\\") + "/")))
|
|
136
|
+
|
|
137
|
+
def _glyph(self, verdict: Verdict) -> str:
|
|
138
|
+
glyph = {
|
|
139
|
+
Verdict.CLEAN: self.s.ok,
|
|
140
|
+
Verdict.REFUSED: "!",
|
|
141
|
+
Verdict.NOT_VERIFIED: "?",
|
|
142
|
+
Verdict.DANGEROUS: self.s.bad,
|
|
143
|
+
}[verdict]
|
|
144
|
+
return self.s.paint(glyph, _COLORS[verdict], "bold")
|
|
145
|
+
|
|
146
|
+
def _describe(self, result: ValidationResult) -> str:
|
|
147
|
+
kind = result.file_type.label if result.file_type is not None else "unknown format"
|
|
148
|
+
return f"{kind} {self.s.dot} {format_size(result.size)}"
|
|
149
|
+
|
|
150
|
+
def _line(self, outcome: Outcome) -> None:
|
|
151
|
+
name = self.s.bold(safe(outcome.label))
|
|
152
|
+
if outcome.result is None:
|
|
153
|
+
reason = outcome.error or "could not be read"
|
|
154
|
+
self._print(f" {self._glyph(outcome.verdict)} {name} not verified: {reason}")
|
|
155
|
+
return
|
|
156
|
+
result = outcome.result
|
|
157
|
+
self._print(
|
|
158
|
+
f" {self._glyph(outcome.verdict)} {name} {self.s.dim(self._describe(result))}"
|
|
159
|
+
)
|
|
160
|
+
for violation, verdict in zip(result.violations, outcome.verdicts, strict=True):
|
|
161
|
+
# A line per file shows what makes it this verdict; --json lists everything.
|
|
162
|
+
if verdict is not outcome.verdict:
|
|
163
|
+
continue
|
|
164
|
+
message = violation.message
|
|
165
|
+
if violation.code == Code.TYPE_NOT_ALLOWED and verdict is Verdict.NOT_VERIFIED:
|
|
166
|
+
message = (
|
|
167
|
+
"Its contents cannot be inspected: unpack it and scan the files, "
|
|
168
|
+
"or add --clamav."
|
|
169
|
+
)
|
|
170
|
+
extra = _highlights(violation.details, self.s.ellipsis)
|
|
171
|
+
code = self.s.paint(str(violation.code), _COLORS[verdict])
|
|
172
|
+
self._print(f" {code} {safe(message)}{self.s.dim(extra)}")
|
|
173
|
+
|
|
174
|
+
def _trace(self, outcome: Outcome) -> None:
|
|
175
|
+
s = self.s
|
|
176
|
+
name = s.bold(safe(outcome.label))
|
|
177
|
+
policy = f'"{self.policy.name or "custom"}"'
|
|
178
|
+
if outcome.result is None:
|
|
179
|
+
self._print(f"{self._glyph(outcome.verdict)} {name} not verified: {outcome.error}")
|
|
180
|
+
self._print("")
|
|
181
|
+
return
|
|
182
|
+
result = outcome.result
|
|
183
|
+
self._print(f"{self._glyph(outcome.verdict)} {name} {self._head(outcome, policy)}")
|
|
184
|
+
if result.name != Path(outcome.label).name:
|
|
185
|
+
self._row("as", safe(result.name))
|
|
186
|
+
detected = (
|
|
187
|
+
f"{result.file_type.label} ({result.file_type.mime}), {format_size(result.size)}"
|
|
188
|
+
if result.file_type is not None
|
|
189
|
+
else f"unknown format, {format_size(result.size)}"
|
|
190
|
+
)
|
|
191
|
+
self._row("detected", detected)
|
|
192
|
+
if result.declared_type:
|
|
193
|
+
declared = safe(result.declared_type)
|
|
194
|
+
if not self.policy.match_declared_type:
|
|
195
|
+
declared += s.dim(" (not compared: the policy has match_declared_type=False)")
|
|
196
|
+
self._row("declared", declared)
|
|
197
|
+
self._checks(result)
|
|
198
|
+
self._violations(outcome)
|
|
199
|
+
if result.sanitized is not None:
|
|
200
|
+
sanitized = result.sanitized
|
|
201
|
+
size = len(sanitized.data) if sanitized.data is not None else result.size
|
|
202
|
+
changed = "renamed only" if sanitized.data is None else "rewritten"
|
|
203
|
+
self._row(
|
|
204
|
+
"sanitized",
|
|
205
|
+
f"{', '.join(sanitized.applied)} {s.arrow} {safe(sanitized.name)} "
|
|
206
|
+
f"({sanitized.content_type}, {format_size(size)}, {changed})",
|
|
207
|
+
)
|
|
208
|
+
if outcome.saved is not None:
|
|
209
|
+
self._row("saved", safe(outcome.saved))
|
|
210
|
+
if outcome.save_error is not None:
|
|
211
|
+
self._row("not saved", s.paint(outcome.save_error, "red"))
|
|
212
|
+
if not result.ok:
|
|
213
|
+
codes = " ".join(dict.fromkeys(str(v.code) for v in result.violations))
|
|
214
|
+
self._row("explain", s.dim(f"uploadshield explain {codes}"))
|
|
215
|
+
self._print("")
|
|
216
|
+
|
|
217
|
+
def _head(self, outcome: Outcome, policy: str) -> str:
|
|
218
|
+
s = self.s
|
|
219
|
+
if outcome.verdict is Verdict.CLEAN:
|
|
220
|
+
return s.paint(f"accepted by {policy}", "green", "bold")
|
|
221
|
+
head = s.paint(f"refused by {policy}", _COLORS[outcome.verdict], "bold")
|
|
222
|
+
if outcome.verdict in (Verdict.DANGEROUS, Verdict.NOT_VERIFIED):
|
|
223
|
+
head += s.paint(f" ({outcome.verdict.label})", _COLORS[outcome.verdict])
|
|
224
|
+
return head
|
|
225
|
+
|
|
226
|
+
def _violations(self, outcome: Outcome) -> None:
|
|
227
|
+
s = self.s
|
|
228
|
+
violations = outcome.result.violations if outcome.result is not None else ()
|
|
229
|
+
for violation, verdict in zip(violations, outcome.verdicts, strict=True):
|
|
230
|
+
code = s.paint(f"{self._glyph(verdict)} {violation.code}", _COLORS[verdict])
|
|
231
|
+
self._print(f" {code} {safe(violation.message)}")
|
|
232
|
+
for key, value in violation.details.items():
|
|
233
|
+
if value not in (None, "", [], ()):
|
|
234
|
+
self._print(s.dim(f" {key}: {_format(value, s.ellipsis, limit=300)}"))
|
|
235
|
+
|
|
236
|
+
def _checks(self, result: ValidationResult) -> None:
|
|
237
|
+
s = self.s
|
|
238
|
+
failed = {v.check for v in result.violations}
|
|
239
|
+
ran: list[str] = []
|
|
240
|
+
idle: list[str] = []
|
|
241
|
+
not_run: list[str] = []
|
|
242
|
+
inactive: list[str] = []
|
|
243
|
+
stopped = False
|
|
244
|
+
for check in self.policy.pipeline:
|
|
245
|
+
if check.name in result.checks_run:
|
|
246
|
+
if check.name in failed:
|
|
247
|
+
ran.append(s.paint(f"{s.bad} {check.name}", "red"))
|
|
248
|
+
stopped = self.policy.fail_fast
|
|
249
|
+
else:
|
|
250
|
+
ran.append(s.paint(f"{s.ok} {check.name}", "green"))
|
|
251
|
+
elif stopped:
|
|
252
|
+
not_run.append(check.name)
|
|
253
|
+
elif (extra := missing_dependency(check)) is not None:
|
|
254
|
+
inactive.append(f"{check.name} (needs uploadshield[{extra}])")
|
|
255
|
+
else:
|
|
256
|
+
idle.append(check.name)
|
|
257
|
+
self._row("checks", " ".join(ran) or "none")
|
|
258
|
+
if idle:
|
|
259
|
+
self._row("", s.dim(f"not applicable: {', '.join(idle)}"))
|
|
260
|
+
if not_run:
|
|
261
|
+
self._row("", s.dim(f"not run after the first failure: {', '.join(not_run)}"))
|
|
262
|
+
self._row("", s.dim(" (--all-checks runs every check)"))
|
|
263
|
+
if inactive:
|
|
264
|
+
self._row("", s.paint(f"inactive: {', '.join(inactive)}", "yellow"))
|
|
265
|
+
|
|
266
|
+
# -- summary ----------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def warn(self, text: str) -> None:
|
|
269
|
+
print(self.s.paint(f"warning: {text}", "yellow"), file=self.err)
|
|
270
|
+
|
|
271
|
+
def finish(self, summary: Summary, *, interrupted: bool, exit_code: int) -> None:
|
|
272
|
+
s = self.s
|
|
273
|
+
parts = [
|
|
274
|
+
s.paint(f"{summary.counts[v]} {v.label}", _COLORS[v])
|
|
275
|
+
for v in (Verdict.CLEAN, Verdict.DANGEROUS, Verdict.REFUSED, Verdict.NOT_VERIFIED)
|
|
276
|
+
if summary.counts[v] or v is Verdict.CLEAN
|
|
277
|
+
]
|
|
278
|
+
files = f"{summary.files} file{'' if summary.files == 1 else 's'}"
|
|
279
|
+
line = f"{files} in {summary.seconds:.1f} s: {', '.join(parts)}"
|
|
280
|
+
if skipped := sum(summary.skipped.values()):
|
|
281
|
+
reasons = ", ".join(f"{n} {why}" for why, n in sorted(summary.skipped.items()))
|
|
282
|
+
line += s.dim(f"; {skipped} skipped ({reasons})")
|
|
283
|
+
if not self.detailed or summary.files > 1 or interrupted:
|
|
284
|
+
self._print(("" if self.detailed else "\n") + s.bold(line + "."))
|
|
285
|
+
if interrupted:
|
|
286
|
+
self._print(
|
|
287
|
+
s.paint("Interrupted: the files above are all that was inspected.", "yellow")
|
|
288
|
+
)
|
|
289
|
+
elif exit_code == 1 and not self.detailed:
|
|
290
|
+
self._print(s.dim("What a code means: uploadshield explain <code>"))
|
|
291
|
+
|
|
292
|
+
# -- helpers ----------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def _row(self, label: str, value: str) -> None:
|
|
295
|
+
self._print(f" {self.s.dim(f'{label:<10}')} {value}")
|
|
296
|
+
|
|
297
|
+
def _print(self, text: str) -> None:
|
|
298
|
+
print(text, file=self.out)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _highlights(details: Any, ellipsis: str) -> str:
|
|
302
|
+
shown = [
|
|
303
|
+
_format(details[key], ellipsis, limit=80)
|
|
304
|
+
for key in _HIGHLIGHTS
|
|
305
|
+
if details.get(key) not in (None, "", [], ())
|
|
306
|
+
]
|
|
307
|
+
if details.get("in_sanitized_output"):
|
|
308
|
+
shown.append("in the sanitized output")
|
|
309
|
+
return f" {'; '.join(shown)}" if shown else ""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _format(value: Any, ellipsis: str, *, limit: int) -> str:
|
|
313
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
314
|
+
items = sorted(value, key=str) if isinstance(value, (set, frozenset)) else value
|
|
315
|
+
text = ", ".join(str(v) for v in items)
|
|
316
|
+
else:
|
|
317
|
+
text = str(value)
|
|
318
|
+
text = safe(text)
|
|
319
|
+
return text if len(text) <= limit else text[: limit - 1] + ellipsis
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The policy behind ``uploadshield scan``, and the optional scanners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import importlib.util
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from uploadshield.checks import (
|
|
10
|
+
DEFAULT_CONTENT_CHECKS,
|
|
11
|
+
ClamAV,
|
|
12
|
+
EmbeddedContent,
|
|
13
|
+
FilenameCheck,
|
|
14
|
+
MagikaCheck,
|
|
15
|
+
YaraRules,
|
|
16
|
+
)
|
|
17
|
+
from uploadshield.checks.scanners import clamd_ping
|
|
18
|
+
from uploadshield.core.types import all_types
|
|
19
|
+
from uploadshield.policy import Policy
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from uploadshield.core.check import Check
|
|
23
|
+
|
|
24
|
+
__all__ = ["SetupError", "scan_policy", "scanner_checks"]
|
|
25
|
+
|
|
26
|
+
#: Formats a scan refuses outright: dangerous to accept whatever they contain.
|
|
27
|
+
ACTIVE_TYPES = frozenset({"text/html"})
|
|
28
|
+
#: Archives whose members the checks cannot see (only ZIP containers are opened).
|
|
29
|
+
OPAQUE_ARCHIVES = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"application/gzip",
|
|
32
|
+
"application/x-tar",
|
|
33
|
+
"application/x-bzip2",
|
|
34
|
+
"application/x-xz",
|
|
35
|
+
"application/x-7z-compressed",
|
|
36
|
+
"application/vnd.rar",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SetupError(Exception):
|
|
42
|
+
"""The command cannot run as asked (exit status 2)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scan_policy(extra_checks: tuple[Check, ...] = ()) -> Policy:
|
|
46
|
+
"""Accept every format uploadshield can inspect, and judge each one by its content.
|
|
47
|
+
|
|
48
|
+
Built when the command runs, so formats an application registered count too.
|
|
49
|
+
Executables and HTML are refused (they are dangerous as uploads whatever they
|
|
50
|
+
contain); archives it cannot open are reported as not verified. Names are judged
|
|
51
|
+
for what makes them dangerous - not for being hidden or lacking an extension - and
|
|
52
|
+
there is no size limit: files too large for a check are reported, not skipped.
|
|
53
|
+
"""
|
|
54
|
+
allowed = {
|
|
55
|
+
t.mime
|
|
56
|
+
for t in all_types()
|
|
57
|
+
if not t.executable and t.mime not in ACTIVE_TYPES | OPAQUE_ARCHIVES
|
|
58
|
+
}
|
|
59
|
+
content_checks = tuple(
|
|
60
|
+
dataclasses.replace(check, max_scan_bytes=1 << 62)
|
|
61
|
+
if isinstance(check, EmbeddedContent)
|
|
62
|
+
else check
|
|
63
|
+
for check in DEFAULT_CONTENT_CHECKS
|
|
64
|
+
)
|
|
65
|
+
return Policy(
|
|
66
|
+
name="scan",
|
|
67
|
+
allowed_types=allowed,
|
|
68
|
+
max_size=None,
|
|
69
|
+
allow_empty=True,
|
|
70
|
+
filename=FilenameCheck(allow_hidden=True, require_extension=False),
|
|
71
|
+
content_checks=content_checks,
|
|
72
|
+
checks=extra_checks,
|
|
73
|
+
fail_fast=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scanner_checks(
|
|
78
|
+
*, clamav: str | None, yara: list[str] | None, magika: bool
|
|
79
|
+
) -> tuple[Check, ...]:
|
|
80
|
+
"""The optional scanners, verified before the first file is read."""
|
|
81
|
+
checks: list[Check] = []
|
|
82
|
+
if clamav:
|
|
83
|
+
if not clamd_ping(clamav):
|
|
84
|
+
raise SetupError(f"clamd does not answer at {clamav} (is it running?)")
|
|
85
|
+
checks.append(ClamAV(address=clamav))
|
|
86
|
+
if yara:
|
|
87
|
+
_require("yara", "yara")
|
|
88
|
+
rules = YaraRules(rules=tuple(yara))
|
|
89
|
+
try:
|
|
90
|
+
rules.compile()
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise SetupError(f"YARA rules do not compile: {exc}") from exc
|
|
93
|
+
checks.append(rules)
|
|
94
|
+
if magika:
|
|
95
|
+
_require("magika", "magika")
|
|
96
|
+
checks.append(MagikaCheck())
|
|
97
|
+
return tuple(checks)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _require(module: str, extra: str) -> None:
|
|
101
|
+
if importlib.util.find_spec(module) is None:
|
|
102
|
+
raise SetupError(
|
|
103
|
+
f"--{extra} needs the {extra} extra: "
|
|
104
|
+
f'uvx --from "uploadshield[{extra}]" uploadshield ..., '
|
|
105
|
+
f"or uv add 'uploadshield[{extra}]' in your project"
|
|
106
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Terminal styling: colour decisions, glyphs, and escaping of untrusted names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import unicodedata
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
__all__ = ["Style", "color_enabled", "safe"]
|
|
11
|
+
|
|
12
|
+
_BIDI_CONTROLS = frozenset(
|
|
13
|
+
"\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069"
|
|
14
|
+
)
|
|
15
|
+
_CODES = {
|
|
16
|
+
"bold": "1",
|
|
17
|
+
"dim": "2",
|
|
18
|
+
"red": "31",
|
|
19
|
+
"green": "32",
|
|
20
|
+
"yellow": "33",
|
|
21
|
+
"blue": "34",
|
|
22
|
+
"magenta": "35",
|
|
23
|
+
"cyan": "36",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def safe(text: str) -> str:
|
|
28
|
+
"""``text`` with everything that could act on a terminal made visible.
|
|
29
|
+
|
|
30
|
+
File names are attacker-controlled: an ESC byte would let a name rewrite the
|
|
31
|
+
screen, CR/LF would forge lines, bidi overrides would reverse what is shown, and
|
|
32
|
+
undecodable bytes (lone surrogates) would crash the output stream.
|
|
33
|
+
"""
|
|
34
|
+
if text.isprintable() and not _BIDI_CONTROLS.intersection(text):
|
|
35
|
+
return text
|
|
36
|
+
out: list[str] = []
|
|
37
|
+
for char in text:
|
|
38
|
+
if char in _BIDI_CONTROLS or unicodedata.category(char) in ("Cc", "Cs"):
|
|
39
|
+
point = ord(char)
|
|
40
|
+
out.append(f"\\x{point:02x}" if point < 0x100 else f"\\u{point:04x}")
|
|
41
|
+
else:
|
|
42
|
+
out.append(char)
|
|
43
|
+
return "".join(out)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def color_enabled(choice: str, stream: TextIO) -> bool:
|
|
47
|
+
"""``--color`` first, then ``NO_COLOR``, ``FORCE_COLOR``, and finally a TTY check."""
|
|
48
|
+
if choice != "auto":
|
|
49
|
+
return choice == "always"
|
|
50
|
+
if os.environ.get("NO_COLOR"):
|
|
51
|
+
return False
|
|
52
|
+
if os.environ.get("FORCE_COLOR", "0") not in ("", "0"):
|
|
53
|
+
return True
|
|
54
|
+
if os.environ.get("TERM") == "dumb" or not _isatty(stream):
|
|
55
|
+
return False
|
|
56
|
+
return sys.platform != "win32" or "WT_SESSION" in os.environ
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _isatty(stream: TextIO) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
return stream.isatty()
|
|
62
|
+
except (AttributeError, ValueError):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _can_encode(stream: TextIO, text: str) -> bool:
|
|
67
|
+
try:
|
|
68
|
+
text.encode(getattr(stream, "encoding", None) or "ascii")
|
|
69
|
+
except (UnicodeEncodeError, LookupError):
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Style:
|
|
75
|
+
"""Colours and glyphs for one output stream (plain text when colour is off)."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, stream: TextIO, *, color: bool) -> None:
|
|
78
|
+
self.color = color
|
|
79
|
+
fancy = _can_encode(stream, "\u2713\u2717\u2192\u00b7\u2026\u2500")
|
|
80
|
+
self.ok, self.bad = ("\u2713", "\u2717") if fancy else ("OK", "X")
|
|
81
|
+
self.arrow = "\u2192" if fancy else "->"
|
|
82
|
+
self.dot = "\u00b7" if fancy else "-"
|
|
83
|
+
self.ellipsis = "\u2026" if fancy else "..."
|
|
84
|
+
|
|
85
|
+
def paint(self, text: str, *styles: str) -> str:
|
|
86
|
+
if not self.color or not styles or not text:
|
|
87
|
+
return text
|
|
88
|
+
codes = ";".join(_CODES[s] for s in styles)
|
|
89
|
+
return f"\x1b[{codes}m{text}\x1b[0m"
|
|
90
|
+
|
|
91
|
+
def bold(self, text: str) -> str:
|
|
92
|
+
return self.paint(text, "bold")
|
|
93
|
+
|
|
94
|
+
def dim(self, text: str) -> str:
|
|
95
|
+
return self.paint(text, "dim")
|