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,363 @@
|
|
|
1
|
+
"""What each violation means for a person: verdicts, explanations, per-file outcomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from uploadshield.core.violations import Code
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from uploadshield.core.result import ValidationResult
|
|
14
|
+
from uploadshield.core.violations import Violation
|
|
15
|
+
|
|
16
|
+
__all__ = ["CODE_INFO", "CONDITIONS", "CodeInfo", "Outcome", "Summary", "Verdict", "judge"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Verdict(enum.StrEnum):
|
|
20
|
+
"""Ordered from best to worst: a file takes the worst verdict of its violations."""
|
|
21
|
+
|
|
22
|
+
CLEAN = "clean"
|
|
23
|
+
REFUSED = "refused"
|
|
24
|
+
NOT_VERIFIED = "not_verified"
|
|
25
|
+
DANGEROUS = "dangerous"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def rank(self) -> int:
|
|
29
|
+
return list(Verdict).index(self)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def label(self) -> str:
|
|
33
|
+
return self.value.replace("_", " ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
D, U, R = Verdict.DANGEROUS, Verdict.NOT_VERIFIED, Verdict.REFUSED
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class CodeInfo:
|
|
41
|
+
verdict: Verdict
|
|
42
|
+
title: str
|
|
43
|
+
meaning: str
|
|
44
|
+
cause: str
|
|
45
|
+
relax: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
CODE_INFO: dict[Code, CodeInfo] = {
|
|
49
|
+
Code.FILE_EMPTY: CodeInfo(
|
|
50
|
+
R,
|
|
51
|
+
"empty file",
|
|
52
|
+
"The file has no content.",
|
|
53
|
+
"An empty upload, or a client that failed to read the file.",
|
|
54
|
+
"Policy(allow_empty=True)",
|
|
55
|
+
),
|
|
56
|
+
Code.FILE_TOO_LARGE: CodeInfo(
|
|
57
|
+
R,
|
|
58
|
+
"file too large",
|
|
59
|
+
"The file is larger than the policy's max_size.",
|
|
60
|
+
"A large file, or one meant to fill the disk.",
|
|
61
|
+
"Policy(max_size=...), or max_size=None for no limit",
|
|
62
|
+
),
|
|
63
|
+
Code.FILENAME_INVALID: CodeInfo(
|
|
64
|
+
D,
|
|
65
|
+
"invalid file name",
|
|
66
|
+
"The name contains path separators, '..', control or bidi characters, or "
|
|
67
|
+
"characters Windows forbids.",
|
|
68
|
+
"Path traversal (../../etc/cron.d/x), NUL bytes, or a U+202E override that shows "
|
|
69
|
+
"'gpj.exe' as 'exe.jpg'.",
|
|
70
|
+
"FilenameCheck(allow_leading_hyphen=True, allow_consecutive_dots=True) for those "
|
|
71
|
+
"two; SafeFilename() or RandomizeFilename() replace names instead",
|
|
72
|
+
),
|
|
73
|
+
Code.FILENAME_TOO_LONG: CodeInfo(
|
|
74
|
+
R,
|
|
75
|
+
"file name too long",
|
|
76
|
+
"The name is longer than FilenameCheck.max_length bytes (UTF-8).",
|
|
77
|
+
"Very long names break filesystems and databases.",
|
|
78
|
+
"FilenameCheck(max_length=...)",
|
|
79
|
+
),
|
|
80
|
+
Code.FILENAME_HIDDEN: CodeInfo(
|
|
81
|
+
R,
|
|
82
|
+
"hidden file name",
|
|
83
|
+
"The name starts with a dot.",
|
|
84
|
+
"Dotfiles are hidden and often read as configuration.",
|
|
85
|
+
"FilenameCheck(allow_hidden=True)",
|
|
86
|
+
),
|
|
87
|
+
Code.FILENAME_RESERVED: CodeInfo(
|
|
88
|
+
D,
|
|
89
|
+
"reserved file name",
|
|
90
|
+
"The name is a Windows device name (CON, NUL, COM1, ...), a server configuration "
|
|
91
|
+
"file (.htaccess, web.config) or an 8.3 short name (PROGRA~1).",
|
|
92
|
+
"Configuration files change how a web server treats the upload directory.",
|
|
93
|
+
"FilenameCheck(forbidden_names=..., allow_short_names=True)",
|
|
94
|
+
),
|
|
95
|
+
Code.FILENAME_DANGEROUS_EXTENSION: CodeInfo(
|
|
96
|
+
D,
|
|
97
|
+
"dangerous extension",
|
|
98
|
+
"An extension in the name makes servers or clients run the file (.php, .jsp, .exe, "
|
|
99
|
+
".bat, ...), wherever it appears (shell.php.png).",
|
|
100
|
+
"A web shell or executable named to be run where it is stored.",
|
|
101
|
+
"FilenameCheck(forbidden_extensions=...)",
|
|
102
|
+
),
|
|
103
|
+
Code.FILENAME_MISSING_EXTENSION: CodeInfo(
|
|
104
|
+
R,
|
|
105
|
+
"no extension",
|
|
106
|
+
"The name has no extension.",
|
|
107
|
+
"Files without an extension are served with a guessed type.",
|
|
108
|
+
"FilenameCheck(require_extension=False)",
|
|
109
|
+
),
|
|
110
|
+
Code.TYPE_UNKNOWN: CodeInfo(
|
|
111
|
+
U,
|
|
112
|
+
"unknown format",
|
|
113
|
+
"The content matches no file format uploadshield knows.",
|
|
114
|
+
"A format it does not know, or random or encrypted data.",
|
|
115
|
+
"register_type(FileType(...)) for your format, or Policy(sniffer=MagicSniffer())",
|
|
116
|
+
),
|
|
117
|
+
Code.TYPE_NOT_ALLOWED: CodeInfo(
|
|
118
|
+
R,
|
|
119
|
+
"format not allowed",
|
|
120
|
+
"The format detected from the content is not in the policy's allowed_types.",
|
|
121
|
+
"The content is not what the name says (a PHP script named .png), or the format "
|
|
122
|
+
"is simply not accepted here.",
|
|
123
|
+
"policy.extend(allowed_types={'<mime type>'})",
|
|
124
|
+
),
|
|
125
|
+
Code.EXTENSION_MISMATCH: CodeInfo(
|
|
126
|
+
R,
|
|
127
|
+
"extension does not match the content",
|
|
128
|
+
"The name's extension does not belong to the format detected from the content.",
|
|
129
|
+
"A renamed file, e.g. a JPEG named .png - or a disguise.",
|
|
130
|
+
"Policy(match_extension=False) with SafeFilename() or RandomizeFilename(), which "
|
|
131
|
+
"give the file the right extension",
|
|
132
|
+
),
|
|
133
|
+
Code.CONTENT_MISMATCH: CodeInfo(
|
|
134
|
+
D,
|
|
135
|
+
"content is a different format",
|
|
136
|
+
"Magika's model identifies the content as a forbidden format.",
|
|
137
|
+
"Code or an executable inside a file of an allowed format.",
|
|
138
|
+
"MagikaCheck(forbidden=..., min_score=...)",
|
|
139
|
+
),
|
|
140
|
+
Code.DECLARED_TYPE_MISMATCH: CodeInfo(
|
|
141
|
+
R,
|
|
142
|
+
"declared type does not match",
|
|
143
|
+
"The client's Content-Type header contradicts the content.",
|
|
144
|
+
"A client that guessed wrong, or one that lies.",
|
|
145
|
+
"Policy(match_declared_type=False), the default",
|
|
146
|
+
),
|
|
147
|
+
Code.IMAGE_CORRUPT: CodeInfo(
|
|
148
|
+
R,
|
|
149
|
+
"corrupt image",
|
|
150
|
+
"Pillow cannot decode the image completely.",
|
|
151
|
+
"A truncated or damaged file, or one crafted against image libraries.",
|
|
152
|
+
"none: re-save the image",
|
|
153
|
+
),
|
|
154
|
+
Code.IMAGE_TOO_LARGE: CodeInfo(
|
|
155
|
+
D,
|
|
156
|
+
"decompression bomb",
|
|
157
|
+
"The image's dimensions, frame count or total pixels exceed the limits.",
|
|
158
|
+
"A small file that needs gigabytes of memory to decode.",
|
|
159
|
+
"ImageIntegrity(max_pixels=..., max_frames=..., max_total_pixels=...)",
|
|
160
|
+
),
|
|
161
|
+
Code.PDF_ACTIVE_CONTENT: CodeInfo(
|
|
162
|
+
D,
|
|
163
|
+
"active PDF content",
|
|
164
|
+
"The PDF contains JavaScript, launch actions, embedded files or forms that submit "
|
|
165
|
+
"data - or it is encrypted, or stores objects in a form that cannot be checked.",
|
|
166
|
+
"Malicious PDFs run scripts or drop files when opened.",
|
|
167
|
+
"PdfActiveContent(forbidden=..., allow_encrypted=True)",
|
|
168
|
+
),
|
|
169
|
+
Code.ARCHIVE_CORRUPT: CodeInfo(
|
|
170
|
+
R,
|
|
171
|
+
"corrupt archive",
|
|
172
|
+
"The ZIP container cannot be read consistently.",
|
|
173
|
+
"A damaged file, or one built so that tools disagree about its contents.",
|
|
174
|
+
"none",
|
|
175
|
+
),
|
|
176
|
+
Code.ARCHIVE_UNSAFE_PATH: CodeInfo(
|
|
177
|
+
D,
|
|
178
|
+
"unsafe archive path (zip slip)",
|
|
179
|
+
"A member would be extracted outside the target directory (../, absolute paths, "
|
|
180
|
+
"drive letters) or is a symbolic link.",
|
|
181
|
+
"Extracting it overwrites files elsewhere.",
|
|
182
|
+
"none",
|
|
183
|
+
),
|
|
184
|
+
Code.ARCHIVE_BOMB: CodeInfo(
|
|
185
|
+
D,
|
|
186
|
+
"archive bomb",
|
|
187
|
+
"The archive expands far beyond its size, has too many members, overlapping "
|
|
188
|
+
"entries or too deep nesting.",
|
|
189
|
+
"A small file that fills the disk or memory when extracted.",
|
|
190
|
+
"ArchiveSafety(max_ratio=..., max_uncompressed_bytes=..., max_entries=..., max_depth=...)",
|
|
191
|
+
),
|
|
192
|
+
Code.ARCHIVE_ENCRYPTED: CodeInfo(
|
|
193
|
+
U,
|
|
194
|
+
"encrypted archive",
|
|
195
|
+
"Encrypted members cannot be inspected.",
|
|
196
|
+
"Encryption hides malware from scanners.",
|
|
197
|
+
"ArchiveSafety(allow_encrypted=True)",
|
|
198
|
+
),
|
|
199
|
+
Code.ARCHIVE_DANGEROUS_MEMBER: CodeInfo(
|
|
200
|
+
D,
|
|
201
|
+
"dangerous archive member",
|
|
202
|
+
"A member has a dangerous extension or a server configuration name (shell.php, "
|
|
203
|
+
".htaccess, ...).",
|
|
204
|
+
"Archives that are extracted on the server deliver what the upload check refused.",
|
|
205
|
+
"ArchiveSafety(check_member_names=False)",
|
|
206
|
+
),
|
|
207
|
+
Code.OFFICE_MACROS: CodeInfo(
|
|
208
|
+
D,
|
|
209
|
+
"macros",
|
|
210
|
+
"The document contains macros, ActiveX controls or DDE fields that run commands.",
|
|
211
|
+
"The classic malicious-attachment payload.",
|
|
212
|
+
"remove OfficeMacros() from content_checks (not recommended)",
|
|
213
|
+
),
|
|
214
|
+
Code.OFFICE_EXTERNAL_REFERENCE: CodeInfo(
|
|
215
|
+
D,
|
|
216
|
+
"remote template",
|
|
217
|
+
"The document loads a remote template or object when it is opened.",
|
|
218
|
+
"Used to fetch macros later and to leak Windows credentials.",
|
|
219
|
+
"OfficeMacros(block_external_references=False)",
|
|
220
|
+
),
|
|
221
|
+
Code.SVG_ACTIVE_CONTENT: CodeInfo(
|
|
222
|
+
D,
|
|
223
|
+
"scripted SVG",
|
|
224
|
+
"The SVG contains scripts, event handlers, javascript: links, foreignObject, "
|
|
225
|
+
"entities or external references.",
|
|
226
|
+
"Served from your domain, it runs script in your users' browsers (stored XSS).",
|
|
227
|
+
"none: rasterize SVG uploads instead",
|
|
228
|
+
),
|
|
229
|
+
Code.XML_DTD: CodeInfo(
|
|
230
|
+
D,
|
|
231
|
+
"XML DTD",
|
|
232
|
+
"The XML declares a DTD, which enables entity expansion and external entities.",
|
|
233
|
+
"Billion-laughs denial of service and XXE file disclosure.",
|
|
234
|
+
"none",
|
|
235
|
+
),
|
|
236
|
+
Code.EMBEDDED_CODE: CodeInfo(
|
|
237
|
+
D,
|
|
238
|
+
"embedded code",
|
|
239
|
+
"Code (PHP, script tags, JSP, SSI) is hidden inside the file.",
|
|
240
|
+
"A web shell in image metadata, or PHP tags in a text file that include() runs.",
|
|
241
|
+
"EmbeddedContent(categories=..., scan_text=False)",
|
|
242
|
+
),
|
|
243
|
+
Code.POLYGLOT: CodeInfo(
|
|
244
|
+
D,
|
|
245
|
+
"polyglot",
|
|
246
|
+
"The file is also a valid ZIP archive or PDF document.",
|
|
247
|
+
"One file that different programs read as different formats (GIFAR).",
|
|
248
|
+
"EmbeddedContent(categories=...)",
|
|
249
|
+
),
|
|
250
|
+
Code.MALWARE_DETECTED: CodeInfo(
|
|
251
|
+
D,
|
|
252
|
+
"malware",
|
|
253
|
+
"ClamAV found a malware signature.",
|
|
254
|
+
"Known malware.",
|
|
255
|
+
"none",
|
|
256
|
+
),
|
|
257
|
+
Code.SIGNATURE_MATCH: CodeInfo(
|
|
258
|
+
D,
|
|
259
|
+
"YARA match",
|
|
260
|
+
"One of your YARA rules matched.",
|
|
261
|
+
"Whatever the rule describes.",
|
|
262
|
+
"adjust the rules",
|
|
263
|
+
),
|
|
264
|
+
Code.SCANNER_UNAVAILABLE: CodeInfo(
|
|
265
|
+
U,
|
|
266
|
+
"scanner unavailable",
|
|
267
|
+
"An external scanner could not be reached or refused the file.",
|
|
268
|
+
"clamd is down, or the file exceeds clamd's StreamMaxLength.",
|
|
269
|
+
"ClamAV(fail_open=True) (not recommended)",
|
|
270
|
+
),
|
|
271
|
+
Code.SANITIZATION_FAILED: CodeInfo(
|
|
272
|
+
U,
|
|
273
|
+
"sanitizing failed",
|
|
274
|
+
"A sanitizer could not process the file, or what it produced failed the checks.",
|
|
275
|
+
"Pillow is missing or cannot re-encode the image.",
|
|
276
|
+
"uv add 'uploadshield[images]'",
|
|
277
|
+
),
|
|
278
|
+
Code.CHECK_FAILED: CodeInfo(
|
|
279
|
+
U,
|
|
280
|
+
"check failed",
|
|
281
|
+
"A check could not finish: the file needs more memory than it may use, or the "
|
|
282
|
+
"check crashed.",
|
|
283
|
+
"A very large file - or a bug, which you are welcome to report.",
|
|
284
|
+
"raise the check's limit, e.g. PdfActiveContent(max_bytes=...)",
|
|
285
|
+
),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#: Codes whose verdict depends on the file (see :func:`verdict_for`).
|
|
289
|
+
CONDITIONS: dict[Code, str] = {
|
|
290
|
+
Code.TYPE_NOT_ALLOWED: "dangerous when the content is an executable or HTML; in a scan, "
|
|
291
|
+
"not verified for archives it cannot open",
|
|
292
|
+
Code.PDF_ACTIVE_CONTENT: "not verified when the PDF is encrypted or cannot be decoded",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#: Formats that are dangerous to accept as uploads whatever their content.
|
|
296
|
+
_ACTIVE_TYPES = frozenset({"text/html"})
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def verdict_for(violation: Violation, result: ValidationResult, *, scan: bool) -> Verdict:
|
|
300
|
+
code = violation.code
|
|
301
|
+
if scan and code == Code.TYPE_UNKNOWN and result.size == 0:
|
|
302
|
+
return Verdict.CLEAN # nothing to inspect (its name is still judged)
|
|
303
|
+
if code == Code.TYPE_NOT_ALLOWED:
|
|
304
|
+
detected = result.file_type
|
|
305
|
+
if detected is not None and (detected.executable or detected.mime in _ACTIVE_TYPES):
|
|
306
|
+
return D
|
|
307
|
+
return U if scan else R # in a scan: a format it cannot look into
|
|
308
|
+
if code == Code.PDF_ACTIVE_CONTENT and violation.details.get("reason") in (
|
|
309
|
+
"encrypted",
|
|
310
|
+
"uncheckable",
|
|
311
|
+
):
|
|
312
|
+
return U
|
|
313
|
+
info = CODE_INFO.get(code) if isinstance(code, Code) else None
|
|
314
|
+
return info.verdict if info is not None else R
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def judge(result: ValidationResult, *, scan: bool) -> tuple[Verdict, tuple[Verdict, ...]]:
|
|
318
|
+
"""The verdict of each violation and of the file (the worst one)."""
|
|
319
|
+
each = tuple(verdict_for(v, result, scan=scan) for v in result.violations)
|
|
320
|
+
return max(each, key=lambda v: v.rank, default=Verdict.CLEAN), each
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(slots=True)
|
|
324
|
+
class Outcome:
|
|
325
|
+
"""What happened to one path."""
|
|
326
|
+
|
|
327
|
+
display: str
|
|
328
|
+
result: ValidationResult | None = None
|
|
329
|
+
verdict: Verdict = Verdict.CLEAN
|
|
330
|
+
verdicts: tuple[Verdict, ...] = ()
|
|
331
|
+
sha256: str | None = None
|
|
332
|
+
error: str | None = None # could not be read: counts as not verified
|
|
333
|
+
skipped: str | None = None # deliberately not inspected (symbolic link, device)
|
|
334
|
+
saved: str | None = None
|
|
335
|
+
save_error: str | None = None
|
|
336
|
+
#: Where a file found in a directory is shown: under ``root``, as ``label``.
|
|
337
|
+
root: str | None = None
|
|
338
|
+
label: str = ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(slots=True)
|
|
342
|
+
class Summary:
|
|
343
|
+
counts: Counter[Verdict] = field(default_factory=Counter)
|
|
344
|
+
skipped: Counter[str] = field(default_factory=Counter)
|
|
345
|
+
save_errors: int = 0
|
|
346
|
+
seconds: float = 0.0
|
|
347
|
+
|
|
348
|
+
def add(self, outcome: Outcome) -> None:
|
|
349
|
+
if outcome.skipped is not None:
|
|
350
|
+
self.skipped[outcome.skipped] += 1
|
|
351
|
+
return
|
|
352
|
+
self.counts[outcome.verdict] += 1
|
|
353
|
+
self.save_errors += outcome.save_error is not None
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def files(self) -> int:
|
|
357
|
+
return sum(self.counts.values())
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def exit_code(self) -> int:
|
|
361
|
+
if self.save_errors:
|
|
362
|
+
return 2
|
|
363
|
+
return 0 if self.files == self.counts[Verdict.CLEAN] else 1
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Turn command-line paths into files to inspect, without surprises.
|
|
2
|
+
|
|
3
|
+
* Directories are walked in sorted order, so output is stable.
|
|
4
|
+
* Symbolic links named on the command line are followed; links found while walking
|
|
5
|
+
are skipped unless asked for, and directory cycles are cut.
|
|
6
|
+
* Only regular files are read. A FIFO would block forever and ``/dev/zero`` never
|
|
7
|
+
ends, so files are opened non-blocking and checked with ``fstat`` first.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import dataclasses
|
|
14
|
+
import fnmatch
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import IO, TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable, Iterator
|
|
25
|
+
|
|
26
|
+
__all__ = ["STDIN", "Target", "iter_targets", "missing_paths", "open_target"]
|
|
27
|
+
|
|
28
|
+
STDIN = "-"
|
|
29
|
+
_CHUNK = 1 << 20
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Target:
|
|
34
|
+
"""One thing to inspect. ``path`` is ``None`` for standard input.
|
|
35
|
+
|
|
36
|
+
``display`` is the full path as given; files found in a directory also have a
|
|
37
|
+
``root`` (that directory, as given) and a ``label`` relative to it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
display: str
|
|
41
|
+
name: str
|
|
42
|
+
path: Path | None = None
|
|
43
|
+
follow: bool = True # may the final component be a symbolic link?
|
|
44
|
+
error: str | None = None
|
|
45
|
+
skipped: str | None = None
|
|
46
|
+
root: str | None = None
|
|
47
|
+
label: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def missing_paths(paths: Iterable[str]) -> list[str]:
|
|
51
|
+
return [p for p in paths if p != STDIN and not os.path.lexists(p)]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def iter_targets(
|
|
55
|
+
paths: Iterable[str], *, exclude: Iterable[str] = (), follow_symlinks: bool = False
|
|
56
|
+
) -> Iterator[Target]:
|
|
57
|
+
patterns = tuple(exclude)
|
|
58
|
+
for arg in paths:
|
|
59
|
+
if arg == STDIN:
|
|
60
|
+
yield Target(display="<stdin>", name="", label="<stdin>")
|
|
61
|
+
continue
|
|
62
|
+
path = Path(arg)
|
|
63
|
+
if path.is_dir(): # follows a link named on the command line, like find -H
|
|
64
|
+
for target in _walk(path, patterns, follow_symlinks=follow_symlinks):
|
|
65
|
+
label = Path(target.display).relative_to(path).as_posix()
|
|
66
|
+
yield dataclasses.replace(target, root=arg, label=label)
|
|
67
|
+
else:
|
|
68
|
+
yield Target(display=arg, name=path.name, path=path, label=arg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _excluded(name: str, relative: str, patterns: tuple[str, ...]) -> bool:
|
|
72
|
+
return any(fnmatch.fnmatch(name, p) or fnmatch.fnmatch(relative, p) for p in patterns)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _walk(root: Path, patterns: tuple[str, ...], *, follow_symlinks: bool) -> Iterator[Target]:
|
|
76
|
+
seen: set[tuple[int, int]] = set()
|
|
77
|
+
try:
|
|
78
|
+
info = root.stat()
|
|
79
|
+
seen.add((info.st_dev, info.st_ino))
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
stack = [root]
|
|
83
|
+
while stack:
|
|
84
|
+
directory = stack.pop()
|
|
85
|
+
try:
|
|
86
|
+
with os.scandir(directory) as scan:
|
|
87
|
+
entries = sorted(scan, key=lambda e: e.name)
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
yield Target(str(directory), directory.name, directory, error=_reason(exc))
|
|
90
|
+
continue
|
|
91
|
+
subdirectories: list[Path] = []
|
|
92
|
+
for entry in entries:
|
|
93
|
+
path = Path(entry.path)
|
|
94
|
+
relative = path.relative_to(root).as_posix()
|
|
95
|
+
if _excluded(entry.name, relative, patterns):
|
|
96
|
+
continue
|
|
97
|
+
link = entry.is_symlink()
|
|
98
|
+
if link and not follow_symlinks:
|
|
99
|
+
yield Target(str(path), entry.name, path, skipped="symbolic link")
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
is_dir = entry.is_dir(follow_symlinks=follow_symlinks)
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
yield Target(str(path), entry.name, path, error=_reason(exc))
|
|
105
|
+
continue
|
|
106
|
+
if is_dir:
|
|
107
|
+
try:
|
|
108
|
+
info = entry.stat(follow_symlinks=True)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
yield Target(str(path), entry.name, path, error=_reason(exc))
|
|
111
|
+
continue
|
|
112
|
+
if (key := (info.st_dev, info.st_ino)) in seen:
|
|
113
|
+
yield Target(str(path), entry.name, path, skipped="directory cycle")
|
|
114
|
+
continue
|
|
115
|
+
seen.add(key)
|
|
116
|
+
subdirectories.append(path)
|
|
117
|
+
else:
|
|
118
|
+
yield Target(str(path), entry.name, path, follow=follow_symlinks)
|
|
119
|
+
stack.extend(reversed(subdirectories)) # depth first, in sorted order
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def open_target(target: Target) -> tuple[IO[bytes], int] | str:
|
|
123
|
+
"""An open binary stream and its size, or why the target is not a regular file.
|
|
124
|
+
|
|
125
|
+
Raises :class:`OSError` when the file cannot be read (reported as not verified).
|
|
126
|
+
"""
|
|
127
|
+
if target.path is None:
|
|
128
|
+
return _spool_stdin()
|
|
129
|
+
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
|
|
130
|
+
if not target.follow:
|
|
131
|
+
flags |= getattr(os, "O_NOFOLLOW", 0) # it was not a link a moment ago
|
|
132
|
+
fd = os.open(target.path, flags)
|
|
133
|
+
try:
|
|
134
|
+
info = os.fstat(fd)
|
|
135
|
+
if not stat.S_ISREG(info.st_mode):
|
|
136
|
+
os.close(fd)
|
|
137
|
+
return "not a regular file"
|
|
138
|
+
return os.fdopen(fd, "rb"), info.st_size
|
|
139
|
+
except BaseException:
|
|
140
|
+
with contextlib.suppress(OSError):
|
|
141
|
+
os.close(fd)
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _spool_stdin() -> tuple[IO[bytes], int]:
|
|
146
|
+
# A copy of our own: the engine closes the streams it copies, and --save needs
|
|
147
|
+
# the bytes afterwards.
|
|
148
|
+
spool = tempfile.SpooledTemporaryFile(max_size=16 * _CHUNK) # noqa: SIM115
|
|
149
|
+
size = 0
|
|
150
|
+
source = sys.stdin.buffer
|
|
151
|
+
while chunk := source.read(_CHUNK):
|
|
152
|
+
spool.write(chunk)
|
|
153
|
+
size += len(chunk)
|
|
154
|
+
spool.seek(0)
|
|
155
|
+
return spool, size
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _reason(exc: OSError) -> str:
|
|
159
|
+
return exc.strerror or type(exc).__name__
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Support for Django's migration serializer without importing Django.
|
|
2
|
+
|
|
3
|
+
Django serialises model-field validators into migration files by calling
|
|
4
|
+
``deconstruct()`` on them. Policies and checks are frozen dataclasses, so their
|
|
5
|
+
constructor arguments are exactly their fields; emitting the non-default ones keeps
|
|
6
|
+
migrations short and stable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["Deconstructible"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Deconstructible:
|
|
18
|
+
"""Mixin for frozen dataclasses: ``deconstruct() -> (path, args, kwargs)``."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ()
|
|
21
|
+
|
|
22
|
+
#: Public import path prefix used in migrations instead of the private module.
|
|
23
|
+
_deconstruct_module: str | None = None
|
|
24
|
+
|
|
25
|
+
def deconstruct(self) -> tuple[str, tuple[Any, ...], dict[str, Any]]:
|
|
26
|
+
if not dataclasses.is_dataclass(self): # pragma: no cover - programming error
|
|
27
|
+
raise TypeError(f"{type(self).__name__} must be a dataclass")
|
|
28
|
+
kwargs: dict[str, Any] = {}
|
|
29
|
+
for f in dataclasses.fields(self):
|
|
30
|
+
if not f.init or not f.repr:
|
|
31
|
+
continue
|
|
32
|
+
value = getattr(self, f.name)
|
|
33
|
+
if f.default is not dataclasses.MISSING and value == f.default:
|
|
34
|
+
continue
|
|
35
|
+
if f.default_factory is not dataclasses.MISSING and value == f.default_factory():
|
|
36
|
+
continue
|
|
37
|
+
kwargs[f.name] = value
|
|
38
|
+
module = self._deconstruct_module or type(self).__module__
|
|
39
|
+
return f"{module}.{type(self).__qualname__}", (), kwargs
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Extension points: validation checks and sanitizers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
8
|
+
|
|
9
|
+
from ._deconstruct import Deconstructible
|
|
10
|
+
from .violations import Violation
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
from .upload import InspectedFile
|
|
16
|
+
|
|
17
|
+
__all__ = ["Check", "Replacement", "Sanitizer", "Stage"]
|
|
18
|
+
|
|
19
|
+
#: What a check needs to see: the name only, the byte count, the first 8 KiB, or
|
|
20
|
+
#: the complete content. Streaming integrations run the early stages while the
|
|
21
|
+
#: upload is still arriving.
|
|
22
|
+
Stage = Literal["name", "size", "type", "content"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Component(Deconstructible):
|
|
26
|
+
__slots__ = ()
|
|
27
|
+
|
|
28
|
+
#: Short identifier used in violations, logs and ``checks_run``.
|
|
29
|
+
name: ClassVar[str] = "component"
|
|
30
|
+
#: Importable modules this component needs (e.g. ``("PIL",)``).
|
|
31
|
+
requires: ClassVar[tuple[str, ...]] = ()
|
|
32
|
+
#: The earliest point at which the component can run (see :data:`Stage`).
|
|
33
|
+
stage: ClassVar[Stage] = "content"
|
|
34
|
+
|
|
35
|
+
def applies(self, file: InspectedFile) -> bool:
|
|
36
|
+
"""Whether this component is relevant for ``file`` (e.g. only for PDFs)."""
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def missing_dependencies(cls) -> tuple[str, ...]:
|
|
41
|
+
return tuple(m for m in cls.requires if importlib.util.find_spec(m) is None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Check(_Component):
|
|
45
|
+
"""A single, focused validation rule.
|
|
46
|
+
|
|
47
|
+
Subclasses are frozen dataclasses whose fields are the rule's parameters. ``run``
|
|
48
|
+
yields zero or more :class:`Violation` objects and must not modify the file.
|
|
49
|
+
Exceptions raised by a check are treated as a failed verification (fail closed).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
__slots__ = ()
|
|
53
|
+
|
|
54
|
+
def run(self, file: InspectedFile) -> Iterable[Violation]:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def violation(self, code: str, message: str, **details: Any) -> Violation:
|
|
58
|
+
return Violation(code=code, message=message, check=self.name, details=details)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Replacement:
|
|
63
|
+
"""What a sanitizer changed. ``None`` fields are left as they were."""
|
|
64
|
+
|
|
65
|
+
data: bytes | None = None
|
|
66
|
+
name: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Sanitizer(_Component):
|
|
70
|
+
"""Transforms an already *validated* file into a safer equivalent.
|
|
71
|
+
|
|
72
|
+
Examples: re-encoding images to drop metadata and trailing payloads, or replacing
|
|
73
|
+
the client-chosen filename. Sanitizers run only after every check has passed.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
__slots__ = ()
|
|
77
|
+
|
|
78
|
+
def sanitize(self, file: InspectedFile) -> Replacement:
|
|
79
|
+
raise NotImplementedError
|