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,277 @@
|
|
|
1
|
+
"""Detection of active content in PDF documents (no third-party dependency)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import re
|
|
8
|
+
import zlib
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from uploadshield.core.check import Check
|
|
13
|
+
from uploadshield.core.types import MiB
|
|
14
|
+
from uploadshield.core.upload import ContentTooLargeError
|
|
15
|
+
from uploadshield.core.violations import Code, Violation
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
from uploadshield.core.upload import InspectedFile
|
|
21
|
+
|
|
22
|
+
__all__ = ["DEFAULT_PDF_KEYWORDS", "PdfActiveContent", "pdf_names"]
|
|
23
|
+
|
|
24
|
+
#: Name objects that make a PDF execute code, launch programs, carry payloads or
|
|
25
|
+
#: exfiltrate data. ``/OpenAction`` and ``/AA`` are deliberately absent: LaTeX and
|
|
26
|
+
#: most office suites emit them for harmless view settings; the scripts they would
|
|
27
|
+
#: trigger are caught by ``/JavaScript`` and ``/JS``.
|
|
28
|
+
DEFAULT_PDF_KEYWORDS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"JavaScript",
|
|
31
|
+
"JS",
|
|
32
|
+
"Launch",
|
|
33
|
+
"EmbeddedFile",
|
|
34
|
+
"EmbeddedFiles",
|
|
35
|
+
"RichMedia",
|
|
36
|
+
"XFA",
|
|
37
|
+
"ImportData",
|
|
38
|
+
"SubmitForm",
|
|
39
|
+
"GoToE",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_NAME = re.compile(rb"/([^\s/()<>\[\]{}%]{1,127})")
|
|
44
|
+
_HEX_ESCAPE = re.compile(rb"#([0-9A-Fa-f]{2})")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pdf_names(data: bytes) -> set[str]:
|
|
48
|
+
"""All PDF name objects in ``data`` with ``#xx`` escapes decoded (``/J#61vaScript``)."""
|
|
49
|
+
names: set[str] = set()
|
|
50
|
+
for raw in set(_NAME.findall(data)):
|
|
51
|
+
decoded = (
|
|
52
|
+
_HEX_ESCAPE.sub(lambda m: bytes([int(m.group(1), 16)]), raw) if b"#" in raw else raw
|
|
53
|
+
)
|
|
54
|
+
names.add(decoded.decode("latin-1"))
|
|
55
|
+
return names
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Uncheckable(Exception): # noqa: N818 - a verdict, not an error
|
|
59
|
+
"""An object stream that cannot be decoded, so the objects in it cannot be checked."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_FILTER = re.compile(rb"/Filter\s*(\[[^\]]*\]|/[^\s/\[\]<>()]+)")
|
|
63
|
+
_PREDICTOR = re.compile(rb"/Predictor\s+(\d+)")
|
|
64
|
+
_FILTER_NAME = re.compile(rb"/([^\s/\[\]<>()]+)")
|
|
65
|
+
_LENGTH = re.compile(rb"/Length\s+(\d+)(?!\d)(?!\s+\d+\s+R)")
|
|
66
|
+
_WHITESPACE = b"\x00\t\n\f\r "
|
|
67
|
+
#: The ``stream`` keyword: after whitespace or a delimiter, before an end of line.
|
|
68
|
+
_KEYWORD = rb"(?:(?<=[\x00\t\n\f\r ()<>\[\]{}])|\A)stream(?:\r\n|\n|\r)"
|
|
69
|
+
_STREAM_KEYWORD = re.compile(_KEYWORD)
|
|
70
|
+
#: What the lexer stops at outside streams. ``skip`` consumes whole runs of what
|
|
71
|
+
#: does not change its state - ordinary bytes, comments, strings without nested
|
|
72
|
+
#: parentheses, hex strings - so floods of them cost no Python-level iterations.
|
|
73
|
+
#: Each alternative starts with a different byte, so the run never backtracks.
|
|
74
|
+
_TOKEN = re.compile(
|
|
75
|
+
rb"(?P<skip>(?:[^%(<>s]|s(?!tream[\r\n])|%[^\r\n]*|\((?:[^()\\]|\\.)*\)"
|
|
76
|
+
rb"|<[0-9A-Fa-f\x00\t\n\f\r ]*>)+)"
|
|
77
|
+
rb"|(?P<string>\()|(?P<open><<)|(?P<close>>>)|(?P<hex><)|(?P<stream>" + _KEYWORD + rb")",
|
|
78
|
+
re.DOTALL,
|
|
79
|
+
)
|
|
80
|
+
_STRING_TOKEN = re.compile(rb"[()\\]")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _skip_string(data: bytes, start: int) -> int:
|
|
84
|
+
"""The offset after the literal string opening at ``start`` (nested, escaped)."""
|
|
85
|
+
depth, pos = 0, start
|
|
86
|
+
while match := _STRING_TOKEN.search(data, pos):
|
|
87
|
+
token, pos = match.group(), match.end()
|
|
88
|
+
if token == b"\\":
|
|
89
|
+
pos += 1 # the escaped character, whatever it is
|
|
90
|
+
elif token == b"(":
|
|
91
|
+
depth += 1
|
|
92
|
+
else:
|
|
93
|
+
depth -= 1
|
|
94
|
+
if depth == 0:
|
|
95
|
+
return pos
|
|
96
|
+
raise _Uncheckable("unterminated string")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _skip_opaque(data: bytes, kind: str | None, match: re.Match[bytes]) -> int:
|
|
100
|
+
"""The offset after the comment, string or hex string at ``match``."""
|
|
101
|
+
if kind == "skip":
|
|
102
|
+
return match.end()
|
|
103
|
+
if kind == "string": # nested parentheses: counted here
|
|
104
|
+
return _skip_string(data, match.start())
|
|
105
|
+
close = data.find(b">", match.end()) # a malformed hex string
|
|
106
|
+
if close == -1:
|
|
107
|
+
raise _Uncheckable("unterminated hex string")
|
|
108
|
+
return close + 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _streams(data: bytes) -> Iterator[tuple[bytes, bytes]]:
|
|
112
|
+
"""Every stream as ``(dictionary, raw data)``, found the way a PDF lexer reads them.
|
|
113
|
+
|
|
114
|
+
Strings, comments and nested dictionaries are skipped structurally, so text inside
|
|
115
|
+
a dictionary (``/Note (obj)``) or its size cannot hide its keys. Stream data is
|
|
116
|
+
skipped by its direct ``/Length`` when ``endstream`` follows there, as readers do.
|
|
117
|
+
|
|
118
|
+
A ``stream`` keyword the walk does not reach - inside a string, a comment or the
|
|
119
|
+
data of another stream - could still be what a reader finds through the cross
|
|
120
|
+
reference table, so it makes the file uncheckable rather than being ignored.
|
|
121
|
+
"""
|
|
122
|
+
seen: set[int] = set()
|
|
123
|
+
depth = pos = 0
|
|
124
|
+
opened = -1
|
|
125
|
+
last: tuple[int, int] | None = None # the last top-level dictionary
|
|
126
|
+
while match := _TOKEN.search(data, pos):
|
|
127
|
+
kind, at = match.lastgroup, match.start()
|
|
128
|
+
if kind in ("skip", "string", "hex"):
|
|
129
|
+
pos = _skip_opaque(data, kind, match)
|
|
130
|
+
continue
|
|
131
|
+
pos = match.end()
|
|
132
|
+
if kind == "open":
|
|
133
|
+
opened = at if depth == 0 else opened
|
|
134
|
+
depth += 1
|
|
135
|
+
elif kind == "close":
|
|
136
|
+
if depth == 1:
|
|
137
|
+
last = (opened, pos)
|
|
138
|
+
depth = max(depth - 1, 0)
|
|
139
|
+
else: # the stream keyword
|
|
140
|
+
if depth or last is None or data[last[1] : at].strip(_WHITESPACE):
|
|
141
|
+
raise _Uncheckable("stream without a dictionary")
|
|
142
|
+
seen.add(at)
|
|
143
|
+
dictionary = _HEX_ESCAPE.sub(
|
|
144
|
+
lambda m: bytes([int(m.group(1), 16)]), data[last[0] : last[1]]
|
|
145
|
+
)
|
|
146
|
+
finish = _stream_end(data, dictionary, pos)
|
|
147
|
+
yield dictionary, data[pos:finish]
|
|
148
|
+
pos = min(finish + len(b"endstream"), len(data))
|
|
149
|
+
last = None
|
|
150
|
+
if depth:
|
|
151
|
+
raise _Uncheckable("unterminated dictionary")
|
|
152
|
+
if any(m.start() not in seen for m in _STREAM_KEYWORD.finditer(data)):
|
|
153
|
+
raise _Uncheckable("stream keyword outside the object structure")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _stream_end(data: bytes, dictionary: bytes, begin: int) -> int:
|
|
157
|
+
"""Where the data of a stream starting at ``begin`` ends (``endstream`` or the end)."""
|
|
158
|
+
if length := _LENGTH.search(dictionary):
|
|
159
|
+
end = begin + int(length.group(1))
|
|
160
|
+
after = data[end : end + 64].lstrip(_WHITESPACE)
|
|
161
|
+
if end <= len(data) and after.startswith(b"endstream"):
|
|
162
|
+
return data.find(b"endstream", end)
|
|
163
|
+
found = data.find(b"endstream", begin)
|
|
164
|
+
return found if found != -1 else len(data)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _inflate(data: bytes, budget: int) -> bytes:
|
|
168
|
+
for wbits in (zlib.MAX_WBITS | 32, -zlib.MAX_WBITS): # zlib/gzip with any window; raw
|
|
169
|
+
try:
|
|
170
|
+
inflated = zlib.decompressobj(wbits).decompress(data, budget + 1)
|
|
171
|
+
except zlib.error:
|
|
172
|
+
continue
|
|
173
|
+
if len(inflated) > budget:
|
|
174
|
+
raise ContentTooLargeError(f"object streams inflate to more than {budget} bytes")
|
|
175
|
+
return inflated
|
|
176
|
+
raise _Uncheckable("FlateDecode data that cannot be inflated")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _decode(raw: bytes, filters: list[str], budget: int) -> bytes:
|
|
180
|
+
data = raw
|
|
181
|
+
for name in filters:
|
|
182
|
+
if name in ("FlateDecode", "Fl"):
|
|
183
|
+
data = _inflate(data, budget)
|
|
184
|
+
elif name in ("ASCIIHexDecode", "AHx"):
|
|
185
|
+
digits = bytes(c for c in data.split(b">", 1)[0] if c not in b" \t\r\n\f\x00")
|
|
186
|
+
data = bytes.fromhex((digits + b"0" * (len(digits) % 2)).decode("latin-1"))
|
|
187
|
+
elif name in ("ASCII85Decode", "A85"):
|
|
188
|
+
data = base64.a85decode(data.strip().removeprefix(b"<~").split(b"~>", 1)[0])
|
|
189
|
+
else:
|
|
190
|
+
raise _Uncheckable(f"filter {name}")
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _object_streams(data: bytes, budget: int) -> Iterator[bytes]:
|
|
195
|
+
"""Decode every object stream, where PDFs can hide action dictionaries.
|
|
196
|
+
|
|
197
|
+
Objects live either in the file body, which is scanned as it is, or in object
|
|
198
|
+
streams (``/Type /ObjStm``, or any stream with ``/N`` and ``/First``, as readers do
|
|
199
|
+
not insist on the type). Each one is decoded completely, under one budget for the
|
|
200
|
+
whole file; a budget overrun or an encoding that cannot be decoded is reported, so
|
|
201
|
+
nothing is skipped silently.
|
|
202
|
+
"""
|
|
203
|
+
for dictionary, raw in _streams(data):
|
|
204
|
+
names = pdf_names(dictionary)
|
|
205
|
+
if "ObjStm" not in names and not {"N", "First"} <= names:
|
|
206
|
+
continue
|
|
207
|
+
if (predictor := _PREDICTOR.search(dictionary)) and int(predictor.group(1)) > 1:
|
|
208
|
+
raise _Uncheckable("predictor")
|
|
209
|
+
filters_match = _FILTER.search(dictionary)
|
|
210
|
+
filters = (
|
|
211
|
+
[n.decode("latin-1") for n in _FILTER_NAME.findall(filters_match.group(1))]
|
|
212
|
+
if filters_match
|
|
213
|
+
else []
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
decoded = _decode(raw, filters, budget)
|
|
217
|
+
except (ValueError, binascii.Error) as exc:
|
|
218
|
+
raise _Uncheckable(str(exc)) from exc
|
|
219
|
+
budget -= len(decoded)
|
|
220
|
+
if budget < 0:
|
|
221
|
+
raise ContentTooLargeError("object streams inflate beyond the budget")
|
|
222
|
+
yield decoded
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True)
|
|
226
|
+
class PdfActiveContent(Check):
|
|
227
|
+
"""Refuse PDFs containing scripts, launch actions, embedded files or forms that
|
|
228
|
+
submit data.
|
|
229
|
+
|
|
230
|
+
Name objects are matched exactly after decoding ``#xx`` obfuscation, in the raw
|
|
231
|
+
file *and* inside compressed (object) streams, where attackers usually hide them.
|
|
232
|
+
Encrypted PDFs cannot be inspected and are refused unless ``allow_encrypted``.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
_deconstruct_module = "uploadshield.checks"
|
|
236
|
+
name = "pdf"
|
|
237
|
+
|
|
238
|
+
forbidden: frozenset[str] = DEFAULT_PDF_KEYWORDS
|
|
239
|
+
inspect_streams: bool = True
|
|
240
|
+
allow_encrypted: bool = False
|
|
241
|
+
max_bytes: int = 200 * MiB
|
|
242
|
+
max_inflated_bytes: int = 64 * MiB
|
|
243
|
+
|
|
244
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
245
|
+
return file.file_type is not None and file.file_type.mime == "application/pdf"
|
|
246
|
+
|
|
247
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
248
|
+
data = file.read(self.max_bytes)
|
|
249
|
+
names = pdf_names(data)
|
|
250
|
+
if "Encrypt" in names and not self.allow_encrypted:
|
|
251
|
+
yield self.violation(
|
|
252
|
+
Code.PDF_ACTIVE_CONTENT,
|
|
253
|
+
"Encrypted PDF files cannot be checked and are not allowed.",
|
|
254
|
+
reason="encrypted",
|
|
255
|
+
keywords=["Encrypt"],
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
# Without these names no stream can be an object stream: nothing to decode.
|
|
259
|
+
if self.inspect_streams and ("ObjStm" in names or {"N", "First"} <= names):
|
|
260
|
+
try:
|
|
261
|
+
for decoded in _object_streams(data, self.max_inflated_bytes):
|
|
262
|
+
names |= pdf_names(decoded)
|
|
263
|
+
except _Uncheckable as exc:
|
|
264
|
+
yield self.violation(
|
|
265
|
+
Code.PDF_ACTIVE_CONTENT,
|
|
266
|
+
"The PDF stores objects in a form that cannot be checked.",
|
|
267
|
+
reason="uncheckable",
|
|
268
|
+
detail=str(exc),
|
|
269
|
+
)
|
|
270
|
+
return
|
|
271
|
+
if hits := sorted(names & self.forbidden):
|
|
272
|
+
yield self.violation(
|
|
273
|
+
Code.PDF_ACTIVE_CONTENT,
|
|
274
|
+
"The PDF contains active content such as scripts or embedded files.",
|
|
275
|
+
reason="active_content",
|
|
276
|
+
keywords=hits,
|
|
277
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""External malware scanners: ClamAV (clamd) and YARA signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
import socket
|
|
8
|
+
import struct
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from uploadshield.core.check import Check
|
|
15
|
+
from uploadshield.core.types import MiB
|
|
16
|
+
from uploadshield.core.violations import Code, Violation
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
|
|
21
|
+
from uploadshield.core.upload import InspectedFile
|
|
22
|
+
|
|
23
|
+
__all__ = ["ClamAV", "ClamdError", "YaraRules", "clamd_ping", "clamd_scan"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("uploadshield")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
# ClamAV
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClamdError(Exception):
|
|
34
|
+
"""clamd could not be reached or answered with an error."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _connect(address: str, timeout: float) -> socket.socket:
|
|
38
|
+
parts = urlsplit(address)
|
|
39
|
+
if parts.scheme == "tcp":
|
|
40
|
+
if not parts.hostname or not parts.port:
|
|
41
|
+
raise ClamdError(f"Invalid clamd address {address!r}; use tcp://host:port")
|
|
42
|
+
return socket.create_connection((parts.hostname, parts.port), timeout=timeout)
|
|
43
|
+
if parts.scheme == "unix":
|
|
44
|
+
path = parts.path
|
|
45
|
+
elif not parts.scheme:
|
|
46
|
+
path = address
|
|
47
|
+
else:
|
|
48
|
+
raise ClamdError(f"Unsupported clamd address scheme {parts.scheme!r}")
|
|
49
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
50
|
+
sock.settimeout(timeout)
|
|
51
|
+
try:
|
|
52
|
+
sock.connect(path)
|
|
53
|
+
except OSError:
|
|
54
|
+
sock.close()
|
|
55
|
+
raise
|
|
56
|
+
return sock
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clamd_ping(address: str, *, timeout: float = 5.0) -> bool:
|
|
60
|
+
"""Whether a clamd answers ``PING`` at ``address`` (to fail early, before scanning)."""
|
|
61
|
+
try:
|
|
62
|
+
with _connect(address, timeout) as sock:
|
|
63
|
+
sock.sendall(b"zPING\x00")
|
|
64
|
+
reply = b""
|
|
65
|
+
while not reply.endswith(b"\x00") and len(reply) < 64:
|
|
66
|
+
data = sock.recv(64)
|
|
67
|
+
if not data:
|
|
68
|
+
break
|
|
69
|
+
reply += data
|
|
70
|
+
except (OSError, ClamdError):
|
|
71
|
+
return False
|
|
72
|
+
return reply.rstrip(b"\x00").strip() == b"PONG"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clamd_scan(file: InspectedFile, address: str, *, timeout: float, chunk_size: int) -> str | None:
|
|
76
|
+
"""Stream ``file`` to clamd (``INSTREAM``); return the signature name or ``None``."""
|
|
77
|
+
with _connect(address, timeout) as sock:
|
|
78
|
+
sock.sendall(b"zINSTREAM\x00")
|
|
79
|
+
for chunk in file.iter_chunks(chunk_size):
|
|
80
|
+
sock.sendall(struct.pack(">I", len(chunk)) + chunk)
|
|
81
|
+
sock.sendall(struct.pack(">I", 0))
|
|
82
|
+
reply = b""
|
|
83
|
+
while not reply.endswith(b"\x00"):
|
|
84
|
+
data = sock.recv(4096)
|
|
85
|
+
if not data:
|
|
86
|
+
break
|
|
87
|
+
reply += data
|
|
88
|
+
answer = reply.rstrip(b"\x00").decode("utf-8", "replace").strip()
|
|
89
|
+
# "stream: OK" | "stream: Eicar-Signature FOUND" | "... ERROR"
|
|
90
|
+
status = answer.split(":", 1)[-1].strip()
|
|
91
|
+
if status == "OK":
|
|
92
|
+
return None
|
|
93
|
+
if status.endswith(" FOUND"):
|
|
94
|
+
return status.removesuffix(" FOUND").strip()
|
|
95
|
+
raise ClamdError(answer or "empty reply from clamd")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class ClamAV(Check):
|
|
100
|
+
"""Scan the file with a running ``clamd`` daemon.
|
|
101
|
+
|
|
102
|
+
``address`` is ``unix:///path/to/clamd.sock`` or ``tcp://host:3310``. If the
|
|
103
|
+
daemon is unreachable the file is refused (``fail_open=False``), because silently
|
|
104
|
+
skipping the scan would turn an outage into a bypass.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
_deconstruct_module = "uploadshield.checks"
|
|
108
|
+
name = "clamav"
|
|
109
|
+
|
|
110
|
+
address: str = "unix:///var/run/clamav/clamd.ctl"
|
|
111
|
+
timeout: float = 30.0
|
|
112
|
+
fail_open: bool = False
|
|
113
|
+
chunk_size: int = 256 * 1024
|
|
114
|
+
|
|
115
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
116
|
+
try:
|
|
117
|
+
signature = clamd_scan(
|
|
118
|
+
file, self.address, timeout=self.timeout, chunk_size=self.chunk_size
|
|
119
|
+
)
|
|
120
|
+
except (OSError, ClamdError) as exc:
|
|
121
|
+
# An outage is expected operational noise: log without a traceback.
|
|
122
|
+
logger.error("ClamAV scan failed for %r: %s", file.name, exc) # noqa: TRY400
|
|
123
|
+
if not self.fail_open:
|
|
124
|
+
yield self.violation(
|
|
125
|
+
Code.SCANNER_UNAVAILABLE,
|
|
126
|
+
"The file could not be scanned for malware.",
|
|
127
|
+
error=str(exc),
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
if signature:
|
|
131
|
+
yield self.violation(
|
|
132
|
+
Code.MALWARE_DETECTED, "The file was identified as malware.", signature=signature
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
# YARA
|
|
138
|
+
# --------------------------------------------------------------------------- #
|
|
139
|
+
|
|
140
|
+
_RULE_SUFFIXES = (".yar", ".yara")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _rule_files(paths: tuple[str, ...]) -> dict[str, str]:
|
|
144
|
+
files: dict[str, str] = {}
|
|
145
|
+
for entry in paths:
|
|
146
|
+
path = Path(entry)
|
|
147
|
+
candidates = (
|
|
148
|
+
sorted(p for p in path.iterdir() if p.suffix.lower() in _RULE_SUFFIXES)
|
|
149
|
+
if path.is_dir()
|
|
150
|
+
else [path]
|
|
151
|
+
)
|
|
152
|
+
for candidate in candidates:
|
|
153
|
+
namespace = candidate.stem
|
|
154
|
+
while namespace in files:
|
|
155
|
+
namespace += "_"
|
|
156
|
+
files[namespace] = str(candidate)
|
|
157
|
+
return files
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@functools.cache
|
|
161
|
+
def _compiled(paths: tuple[str, ...], source: str | None) -> Any:
|
|
162
|
+
import yara
|
|
163
|
+
|
|
164
|
+
if source is not None:
|
|
165
|
+
return yara.compile(source=source)
|
|
166
|
+
files = _rule_files(paths)
|
|
167
|
+
if not files:
|
|
168
|
+
raise ValueError(f"No YARA rule files found in {paths!r}")
|
|
169
|
+
return yara.compile(filepaths=files)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class YaraRules(Check):
|
|
174
|
+
"""Match the file against YARA rules (``uv add "uploadshield[yara]"``).
|
|
175
|
+
|
|
176
|
+
``rules`` lists rule files and/or directories (``*.yar``/``*.yara``); ``source``
|
|
177
|
+
accepts inline rule text. Rules are compiled once per process and cached.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
_deconstruct_module = "uploadshield.checks"
|
|
181
|
+
name = "yara"
|
|
182
|
+
requires = ("yara",)
|
|
183
|
+
|
|
184
|
+
rules: tuple[str, ...] = ()
|
|
185
|
+
source: str | None = None
|
|
186
|
+
timeout: int = 30
|
|
187
|
+
max_bytes: int = 256 * MiB
|
|
188
|
+
|
|
189
|
+
def __post_init__(self) -> None:
|
|
190
|
+
object.__setattr__(self, "rules", tuple(str(p) for p in self.rules))
|
|
191
|
+
if not self.rules and self.source is None:
|
|
192
|
+
raise ValueError("YaraRules needs rule paths or inline source")
|
|
193
|
+
|
|
194
|
+
def compile(self) -> Any:
|
|
195
|
+
"""Compile (or fetch the cached) rules; raises on syntax errors."""
|
|
196
|
+
return _compiled(self.rules, self.source)
|
|
197
|
+
|
|
198
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
199
|
+
matches = self.compile().match(data=file.read(self.max_bytes), timeout=self.timeout)
|
|
200
|
+
if matches:
|
|
201
|
+
yield self.violation(
|
|
202
|
+
Code.SIGNATURE_MATCH,
|
|
203
|
+
"The file matches a known malicious signature.",
|
|
204
|
+
rules=sorted({m.rule for m in matches}),
|
|
205
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""XML document type definitions: the vehicle of XXE and entity-expansion bombs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from uploadshield.core.check import Check
|
|
10
|
+
from uploadshield.core.containers import zip_entries, zip_head
|
|
11
|
+
from uploadshield.core.types import KiB
|
|
12
|
+
from uploadshield.core.violations import Code, Violation
|
|
13
|
+
|
|
14
|
+
from .office import OFFICE_ZIP_TYPES
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
|
|
19
|
+
from uploadshield.core.upload import InspectedFile
|
|
20
|
+
|
|
21
|
+
__all__ = ["XmlSafety"]
|
|
22
|
+
|
|
23
|
+
#: ZIP-based document formats whose parts are XML that servers parse (OOXML, ODF, EPUB).
|
|
24
|
+
XML_DOCUMENT_TYPES = OFFICE_ZIP_TYPES | {"application/epub+zip"}
|
|
25
|
+
_XML_TYPES = frozenset({"application/xml"})
|
|
26
|
+
_XML_PARTS = (
|
|
27
|
+
".xml", ".rels", ".vml", ".xhtml", ".htm", ".html", ".opf", ".ncx", ".svg", ".rdf", ".xsl",
|
|
28
|
+
) # fmt: skip
|
|
29
|
+
_PROLOG_NOISE = re.compile(r"\s+|<\?.*?\?>|<!--.*?-->", re.DOTALL)
|
|
30
|
+
_ROOT_ELEMENT = re.compile(r"<[A-Za-z_:À-\U000effff]")
|
|
31
|
+
_BOMS = (
|
|
32
|
+
(b"\xef\xbb\xbf", "utf-8"),
|
|
33
|
+
(b"\x00\x00\xfe\xff", "utf-32-be"),
|
|
34
|
+
(b"\xff\xfe\x00\x00", "utf-32-le"),
|
|
35
|
+
(b"\xfe\xff", "utf-16-be"),
|
|
36
|
+
(b"\xff\xfe", "utf-16-le"),
|
|
37
|
+
)
|
|
38
|
+
#: Encodings recognisable from "<?" without a byte order mark (XML 1.0, appendix F).
|
|
39
|
+
_UNMARKED = (
|
|
40
|
+
(b"\x00\x00\x00<", "utf-32-be"),
|
|
41
|
+
(b"<\x00\x00\x00", "utf-32-le"),
|
|
42
|
+
(b"\x00<\x00?", "utf-16-be"),
|
|
43
|
+
(b"<\x00?\x00", "utf-16-le"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _decode(head: bytes) -> str:
|
|
48
|
+
for marker, encoding in _BOMS:
|
|
49
|
+
if head.startswith(marker):
|
|
50
|
+
return head[len(marker) :].decode(encoding, "ignore")
|
|
51
|
+
for marker, encoding in _UNMARKED:
|
|
52
|
+
if head.startswith(marker):
|
|
53
|
+
return head.decode(encoding, "ignore")
|
|
54
|
+
return head.decode("utf-8", "ignore")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def declares_dtd(head: bytes, *, complete: bool) -> bool | None:
|
|
58
|
+
"""Whether the XML prolog in ``head`` declares a DTD; ``None`` if it cannot tell.
|
|
59
|
+
|
|
60
|
+
A DTD (and with it every entity) can only appear in the prolog, before the root
|
|
61
|
+
element, so the first bytes decide. ``complete`` says whether ``head`` is the whole
|
|
62
|
+
document: a prolog that runs past an incomplete head cannot be judged.
|
|
63
|
+
"""
|
|
64
|
+
text = _decode(head)
|
|
65
|
+
position = 0
|
|
66
|
+
while match := _PROLOG_NOISE.match(text, position):
|
|
67
|
+
position = match.end()
|
|
68
|
+
rest = text[position : position + 9].lower()
|
|
69
|
+
if rest.startswith(("<!doctype", "<!entity")):
|
|
70
|
+
return True
|
|
71
|
+
if _ROOT_ELEMENT.match(text, position):
|
|
72
|
+
return False
|
|
73
|
+
return False if complete else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class XmlSafety(Check):
|
|
78
|
+
"""Refuse XML that declares a document type (DTD) - in Office documents, ODF and
|
|
79
|
+
EPUB, and in XML uploads a policy explicitly allows.
|
|
80
|
+
|
|
81
|
+
DTDs are how external entities (XXE: server-side file disclosure and request
|
|
82
|
+
forgery when the document is parsed) and entity-expansion bombs ("billion
|
|
83
|
+
laughs") get in. Office, LibreOffice and e-book readers never write them, so their
|
|
84
|
+
presence is a reliable sign of an attack on whatever parses the upload. SVG is
|
|
85
|
+
covered by :class:`~uploadshield.checks.SvgActiveContent`.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
_deconstruct_module = "uploadshield.checks"
|
|
89
|
+
name = "xml"
|
|
90
|
+
|
|
91
|
+
head_bytes: int = 64 * KiB
|
|
92
|
+
|
|
93
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
94
|
+
file_type = file.file_type
|
|
95
|
+
return file_type is not None and file_type.mime in (XML_DOCUMENT_TYPES | _XML_TYPES)
|
|
96
|
+
|
|
97
|
+
def run(self, file: InspectedFile) -> Iterator[Violation]:
|
|
98
|
+
file_type = file.file_type
|
|
99
|
+
if file_type is not None and file_type.mime in _XML_TYPES:
|
|
100
|
+
head = file.head(self.head_bytes)
|
|
101
|
+
verdicts = {file.name: declares_dtd(head, complete=file.size <= len(head))}
|
|
102
|
+
else:
|
|
103
|
+
verdicts = {}
|
|
104
|
+
for entry in zip_entries(file) or []:
|
|
105
|
+
if entry.is_dir() or not entry.filename.lower().endswith(_XML_PARTS):
|
|
106
|
+
continue
|
|
107
|
+
head = zip_head(file, entry.filename, self.head_bytes)
|
|
108
|
+
if head is not None: # unreadable members are ArchiveSafety's business
|
|
109
|
+
verdicts[entry.filename] = declares_dtd(
|
|
110
|
+
head, complete=entry.file_size <= len(head)
|
|
111
|
+
)
|
|
112
|
+
if dtds := [part for part, verdict in verdicts.items() if verdict is True]:
|
|
113
|
+
yield self.violation(
|
|
114
|
+
Code.XML_DTD,
|
|
115
|
+
"The document contains an XML document type definition, which is not allowed.",
|
|
116
|
+
parts=dtds[:10],
|
|
117
|
+
)
|
|
118
|
+
elif unclear := [part for part, verdict in verdicts.items() if verdict is None]:
|
|
119
|
+
yield self.violation(
|
|
120
|
+
Code.XML_DTD,
|
|
121
|
+
"The document's XML could not be checked for a document type definition.",
|
|
122
|
+
parts=unclear[:10],
|
|
123
|
+
)
|