uploadshield 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. uploadshield/__init__.py +49 -0
  2. uploadshield/__main__.py +17 -0
  3. uploadshield/checks/__init__.py +65 -0
  4. uploadshield/checks/archive.py +316 -0
  5. uploadshield/checks/basic.py +476 -0
  6. uploadshield/checks/image.py +107 -0
  7. uploadshield/checks/magika.py +129 -0
  8. uploadshield/checks/markup.py +181 -0
  9. uploadshield/checks/office.py +174 -0
  10. uploadshield/checks/pdf.py +277 -0
  11. uploadshield/checks/scanners.py +205 -0
  12. uploadshield/checks/xml.py +123 -0
  13. uploadshield/cli/__init__.py +276 -0
  14. uploadshield/cli/_commands.py +401 -0
  15. uploadshield/cli/_json.py +80 -0
  16. uploadshield/cli/_render.py +319 -0
  17. uploadshield/cli/_scan.py +106 -0
  18. uploadshield/cli/_style.py +95 -0
  19. uploadshield/cli/_verdicts.py +363 -0
  20. uploadshield/cli/_walk.py +159 -0
  21. uploadshield/core/__init__.py +4 -0
  22. uploadshield/core/_deconstruct.py +39 -0
  23. uploadshield/core/check.py +79 -0
  24. uploadshield/core/containers.py +131 -0
  25. uploadshield/core/result.py +63 -0
  26. uploadshield/core/sniffing.py +432 -0
  27. uploadshield/core/types.py +319 -0
  28. uploadshield/core/upload.py +192 -0
  29. uploadshield/core/violations.py +79 -0
  30. uploadshield/django/__init__.py +36 -0
  31. uploadshield/django/apps.py +13 -0
  32. uploadshield/django/checks.py +148 -0
  33. uploadshield/django/cli.py +36 -0
  34. uploadshield/django/conf.py +189 -0
  35. uploadshield/django/decorators.py +204 -0
  36. uploadshield/django/exceptions.py +39 -0
  37. uploadshield/django/forms.py +40 -0
  38. uploadshield/django/handlers.py +322 -0
  39. uploadshield/django/responses.py +92 -0
  40. uploadshield/django/service.py +473 -0
  41. uploadshield/django/signals.py +16 -0
  42. uploadshield/django/storage.py +204 -0
  43. uploadshield/django/templates/uploadshield/rejected.html +15 -0
  44. uploadshield/django/validators.py +48 -0
  45. uploadshield/django/views.py +40 -0
  46. uploadshield/engine.py +230 -0
  47. uploadshield/fastapi/__init__.py +23 -0
  48. uploadshield/fastapi/shield.py +299 -0
  49. uploadshield/flask/__init__.py +42 -0
  50. uploadshield/flask/core.py +359 -0
  51. uploadshield/flask/decorators.py +69 -0
  52. uploadshield/flask/wtforms.py +58 -0
  53. uploadshield/policy.py +137 -0
  54. uploadshield/presets.py +65 -0
  55. uploadshield/py.typed +0 -0
  56. uploadshield/sanitizers.py +132 -0
  57. uploadshield/web.py +218 -0
  58. uploadshield-2.0.0.dist-info/METADATA +239 -0
  59. uploadshield-2.0.0.dist-info/RECORD +62 -0
  60. uploadshield-2.0.0.dist-info/WHEEL +4 -0
  61. uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
  62. uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,129 @@
1
+ """A machine-learning second opinion from Google Magika (``uv add "uploadshield[magika]"``).
2
+
3
+ Magika classifies 200+ formats, including the text and source-code formats that have
4
+ no magic number (it tells CSV from Python, PowerShell or batch). It is used here as a
5
+ **veto only**: it can refuse a file the deterministic checks accepted, never accept one
6
+ they refused. The reasons:
7
+
8
+ * it is a probabilistic classifier with no claims about adversarial robustness;
9
+ * it only looks at the first and last KiB of a file, so whoever writes those bytes
10
+ steers its verdict;
11
+ * a model upgrade changes its answers.
12
+
13
+ A bad verdict can therefore cost availability (a false positive) but never safety.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from uploadshield.core.check import Check
23
+ from uploadshield.core.violations import Code, Violation
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Iterator
27
+
28
+ from uploadshield.core.upload import InspectedFile
29
+
30
+ __all__ = ["DEFAULT_MAGIKA_FORBIDDEN", "MAGIKA_EQUIVALENTS", "MagikaCheck"]
31
+
32
+ #: Magika labels for content that executes or renders when opened or served.
33
+ DEFAULT_MAGIKA_FORBIDDEN = frozenset({
34
+ # executables, installers, bytecode, shortcuts
35
+ "pebin", "elf", "macho", "coff", "msi", "lnk", "dex", "apk", "jar", "javabytecode",
36
+ "pythonbytecode", "wasm", "chm", "swf", "crx", "xpi", "internetshortcut", "winregistry",
37
+ # scripts
38
+ "shell", "batch", "powershell", "vba", "autohotkey", "autoit", "javascript",
39
+ "typescript", "coffeescript", "python", "perl", "ruby", "php", "asp", "lua", "tcl",
40
+ # active markup and server configuration
41
+ "html", "svg", "mht", "htaccess",
42
+ }) # fmt: skip
43
+
44
+ #: Labels that correspond to a registered type: if a policy explicitly allows that
45
+ #: type (e.g. ``text/html``), Magika agreeing with it is not a reason to refuse.
46
+ MAGIKA_EQUIVALENTS: dict[str, str] = {
47
+ "html": "text/html",
48
+ "svg": "image/svg+xml",
49
+ "php": "application/x-httpd-php",
50
+ "shell": "application/x-sh",
51
+ "pebin": "application/vnd.microsoft.portable-executable",
52
+ "elf": "application/x-elf",
53
+ "macho": "application/x-mach-binary",
54
+ "jar": "application/java-archive",
55
+ "apk": "application/vnd.android.package-archive",
56
+ "wasm": "application/wasm",
57
+ "javabytecode": "application/java-vm",
58
+ }
59
+
60
+ _models: dict[str, Any] = {}
61
+ _models_lock = threading.Lock()
62
+
63
+
64
+ def _model(prediction_mode: str) -> Any:
65
+ """One ``Magika`` instance per process and mode, created on first use."""
66
+ if (model := _models.get(prediction_mode)) is None:
67
+ with _models_lock:
68
+ if (model := _models.get(prediction_mode)) is None:
69
+ from magika import Magika, PredictionMode
70
+
71
+ model = Magika(prediction_mode=PredictionMode(prediction_mode))
72
+ _models[prediction_mode] = model
73
+ return model
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class MagikaCheck(Check):
78
+ """Refuse files that Magika confidently recognises as code, executables or markup.
79
+
80
+ Typical catch: a ``.csv`` or ``.txt`` that is really a PowerShell, batch or Python
81
+ script; deterministic sniffing can only prove it is text. Only labels in
82
+ ``forbidden`` with a score of at least ``min_score`` refuse a file, and never when
83
+ the label matches the type the policy already accepted (see
84
+ :data:`MAGIKA_EQUIVALENTS`). ``prediction_mode`` is ``"high_confidence"``,
85
+ ``"medium_confidence"`` or ``"best_guess"``.
86
+ """
87
+
88
+ _deconstruct_module = "uploadshield.checks"
89
+ name = "magika"
90
+ requires = ("magika",)
91
+
92
+ forbidden: frozenset[str] = DEFAULT_MAGIKA_FORBIDDEN
93
+ min_score: float = 0.9
94
+ prediction_mode: str = "high_confidence"
95
+
96
+ def __post_init__(self) -> None:
97
+ if self.prediction_mode not in ("high_confidence", "medium_confidence", "best_guess"):
98
+ raise ValueError(f"Unknown Magika prediction mode {self.prediction_mode!r}")
99
+ if not 0.0 <= self.min_score <= 1.0:
100
+ raise ValueError("min_score must be between 0 and 1")
101
+ object.__setattr__(self, "forbidden", frozenset(self.forbidden))
102
+
103
+ def applies(self, file: InspectedFile) -> bool:
104
+ return file.file_type is not None
105
+
106
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
107
+ model = _model(self.prediction_mode)
108
+ with file.open() as fh:
109
+ result = model.identify_stream(fh)
110
+ if not result.ok:
111
+ raise RuntimeError(f"Magika could not analyse the file: {result.status}")
112
+ output = result.output
113
+ label = str(output.label)
114
+ accepted = file.file_type.mime if file.file_type else None
115
+ if (
116
+ label in self.forbidden
117
+ and result.score >= self.min_score
118
+ and MAGIKA_EQUIVALENTS.get(label) != accepted
119
+ ):
120
+ yield self.violation(
121
+ Code.CONTENT_MISMATCH,
122
+ f"The file's content looks like {output.description}, which is not allowed here.",
123
+ magika_label=label,
124
+ score=round(float(result.score), 3),
125
+ group=str(output.group),
126
+ mime_type=str(output.mime_type),
127
+ model=str(model.get_model_name()),
128
+ detected_type=accepted,
129
+ )
@@ -0,0 +1,181 @@
1
+ """Script-capable markup (SVG) and code or polyglot payloads hidden in media files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ import struct
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ from uploadshield.core.check import Check
12
+ from uploadshield.core.types import GiB, MiB
13
+ from uploadshield.core.upload import ContentTooLargeError
14
+ from uploadshield.core.violations import Code, Violation
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterator
18
+
19
+ from uploadshield.core.upload import InspectedFile
20
+
21
+ __all__ = ["EmbeddedContent", "SvgActiveContent"]
22
+
23
+ # "<" cannot occur inside an XML attribute value, so "<[^<]*" spans a whole start tag -
24
+ # also when an attribute value contains ">" - and the scan stays linear.
25
+ _SVG_RULES: dict[str, re.Pattern[str]] = {
26
+ "script element": re.compile(r"<\s*(?:[a-z0-9_-]+:)?script\b"),
27
+ "script URI": re.compile(r"(?:java|vb)script:|data:text/html"),
28
+ "foreignObject": re.compile(r"<\s*(?:[a-z0-9_-]+:)?foreignobject\b"),
29
+ "embedded document": re.compile(r"<\s*(?:iframe|embed|object|frame)\b"),
30
+ "entity declaration": re.compile(r"<!entity|<!doctype[^<]*\["),
31
+ "XSLT stylesheet": re.compile(r"<\?xml-stylesheet"),
32
+ "external resource": re.compile(r"href\s*=\s*[\"']?\s*(?:https?:)?//"),
33
+ "href animation": re.compile(
34
+ r"<\s*(?:set|animate)\b[^<]*attributename\s*=\s*[\"']?(?:xlink:)?href"
35
+ ),
36
+ }
37
+ #: An ``on...=`` attribute inside a start tag. Attribute values are skipped as quoted
38
+ #: strings, so a ">" inside a value does not end the tag, while text after the tag is
39
+ #: never mistaken for an attribute. Matched on the text *before* character references
40
+ #: are decoded: "&quot;>" inside a value must not look like the end of the tag. Every
41
+ #: part stops at "<", ">" or a quote, so the scan stays linear.
42
+ _EVENT_HANDLER = re.compile(
43
+ r"<[a-z][^\s/<>]*"
44
+ r"(?:[\s/]+[^\s/<>=\"']+(?:\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s<>\"']*))?)*?"
45
+ r"(?:[\s/]+|(?<=[\"']))on[a-z]+\s*="
46
+ )
47
+ _URL_INVISIBLES = re.compile(r"[\t\n\r\x00]")
48
+
49
+
50
+ def _decode_markup(data: bytes) -> str:
51
+ if data.startswith((b"\xff\xfe", b"\xfe\xff")):
52
+ return data.decode("utf-16", "replace").lower()
53
+ return data.decode("utf-8", "replace").lower()
54
+
55
+
56
+ def _normalize_markup(text: str) -> str:
57
+ # Browsers decode character references and ignore tabs/newlines inside URLs, so
58
+ # "&#106;ava&#x09;script:" must be seen as "javascript:".
59
+ return _URL_INVISIBLES.sub("", html.unescape(text)).lower()
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class SvgActiveContent(Check):
64
+ """Refuse SVG images that can run script or load external resources.
65
+
66
+ SVG is XML that browsers execute like HTML; when served from your origin it is a
67
+ stored-XSS vector. Prefer rasterising user SVGs; if you accept them, this check
68
+ rejects scripts, event handlers, ``javascript:`` URIs, ``foreignObject``,
69
+ entities (XXE), XSLT and external references.
70
+ """
71
+
72
+ _deconstruct_module = "uploadshield.checks"
73
+ name = "svg"
74
+
75
+ max_bytes: int = 10 * MiB
76
+
77
+ def applies(self, file: InspectedFile) -> bool:
78
+ return file.file_type is not None and file.file_type.mime == "image/svg+xml"
79
+
80
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
81
+ raw = _decode_markup(file.read(self.max_bytes))
82
+ text = _normalize_markup(raw)
83
+ found = [label for label, rule in _SVG_RULES.items() if rule.search(text)]
84
+ if _EVENT_HANDLER.search(raw):
85
+ found.insert(0, "event handler attribute")
86
+ if found:
87
+ yield self.violation(
88
+ Code.SVG_ACTIVE_CONTENT, "The SVG image contains active content.", found=found
89
+ )
90
+
91
+
92
+ # Markers specific enough that they do not occur by chance in compressed media data
93
+ # (a 3-byte "<?=" alone would, about once per 20 MB): PHP open and short-echo tags,
94
+ # HTML scripts, JSP/ASP directives, server-side includes and PHAR stubs (polyglot
95
+ # images that PHP deserializes through phar://).
96
+ _CODE_MARKERS = re.compile(
97
+ rb"<\?php\s|<\?=\s{0,3}(?:[$`]|[a-z_]\w{0,30}\s*\()|<script[\s>]"
98
+ rb"|<%@\s*(?:page|language|import)\b|<jsp:"
99
+ rb"|<!--\s*#\s*(?:exec|include|echo|config|set|printenv)\b"
100
+ rb"|__halt_compiler\s*\(\s*\)",
101
+ re.IGNORECASE,
102
+ )
103
+ #: What a PHP ``include`` runs from *any* file, text included: open and short-echo tags.
104
+ _INCLUDABLE_CODE = re.compile(rb"<\?php\s|<\?=\s{0,3}(?:[$`]|[a-z_]\w{0,30}\s*\()", re.IGNORECASE)
105
+ _OVERLAP = 64
106
+ _EOCD = b"PK\x05\x06"
107
+ _CENTRAL_DIRECTORY = b"PK\x01\x02"
108
+ _EOCD_SEARCH_WINDOW = 65_557 # 22-byte record + maximum comment length
109
+
110
+
111
+ def _embedded_zip_offset(tail: bytes) -> int | None:
112
+ """Locate a *structurally valid* ZIP end record (not just its 4 magic bytes)."""
113
+ pos = tail.rfind(_EOCD)
114
+ while pos != -1:
115
+ if pos + 22 <= len(tail):
116
+ disk, cd_disk, on_disk, total, cd_size = struct.unpack_from("<HHHHI", tail, pos + 4)
117
+ cd_start = pos - cd_size
118
+ if (
119
+ disk == 0
120
+ and cd_disk == 0
121
+ and on_disk == total
122
+ and total > 0
123
+ and cd_start >= 0
124
+ and tail[cd_start : cd_start + 4] == _CENTRAL_DIRECTORY
125
+ ):
126
+ return pos
127
+ pos = tail.rfind(_EOCD, 0, pos)
128
+ return None
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class EmbeddedContent(Check):
133
+ """Refuse files that smuggle server-side code or a second file format.
134
+
135
+ In media it catches the classic "PHP in EXIF comment" image, HTML/script payloads
136
+ and polyglots: an image with a ZIP appended (GIFAR), or a PDF header hidden near the
137
+ start where PDF readers still find it. In text files (``scan_text``) it refuses PHP
138
+ tags anywhere, because PHP's ``include`` runs them from a ``.txt`` or ``.csv`` too.
139
+ Files larger than ``max_scan_bytes`` are refused rather than scanned in part.
140
+ """
141
+
142
+ _deconstruct_module = "uploadshield.checks"
143
+ name = "embedded"
144
+
145
+ categories: frozenset[str] = frozenset({"image", "audio", "video"})
146
+ scan_text: bool = True
147
+ max_scan_bytes: int = 1 * GiB
148
+
149
+ def applies(self, file: InspectedFile) -> bool:
150
+ file_type = file.file_type
151
+ if file_type is None:
152
+ return False
153
+ if file_type.is_text:
154
+ return self.scan_text
155
+ return file_type.category in self.categories
156
+
157
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
158
+ if file.size > self.max_scan_bytes:
159
+ raise ContentTooLargeError(f"{file.size} bytes exceeds {self.max_scan_bytes}")
160
+ text = file.file_type is not None and file.file_type.is_text
161
+ if marker := self._find_code(file, _INCLUDABLE_CODE if text else _CODE_MARKERS):
162
+ yield self.violation(
163
+ Code.EMBEDDED_CODE, "The file contains embedded code.", marker=marker
164
+ )
165
+ return
166
+ if text:
167
+ return
168
+ if b"%PDF-" in file.head(1024):
169
+ yield self.violation(Code.POLYGLOT, "The file also contains a PDF document.")
170
+ elif _embedded_zip_offset(file.tail(_EOCD_SEARCH_WINDOW)) is not None:
171
+ yield self.violation(Code.POLYGLOT, "The file also contains a ZIP archive.")
172
+
173
+ @staticmethod
174
+ def _find_code(file: InspectedFile, markers: re.Pattern[bytes]) -> str | None:
175
+ carry = b""
176
+ for chunk in file.iter_chunks():
177
+ window = carry + chunk
178
+ if match := markers.search(window):
179
+ return match.group(0).decode("latin-1").strip()
180
+ carry = window[-_OVERLAP:]
181
+ return None
@@ -0,0 +1,174 @@
1
+ """Macros and remote references in Office documents (OOXML, ODF, legacy OLE2)."""
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_member, zip_read, zip_search
11
+ from uploadshield.core.types import MiB
12
+ from uploadshield.core.upload import ContentTooLargeError
13
+ from uploadshield.core.violations import Code, Violation
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterator
17
+
18
+ from uploadshield.core.upload import InspectedFile
19
+
20
+ __all__ = ["OFFICE_ZIP_TYPES", "OfficeMacros"]
21
+
22
+ OFFICE_ZIP_TYPES = frozenset(
23
+ {
24
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
25
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
26
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
27
+ "application/vnd.ms-word.document.macroenabled.12",
28
+ "application/vnd.ms-excel.sheet.macroenabled.12",
29
+ "application/vnd.ms-powerpoint.presentation.macroenabled.12",
30
+ "application/vnd.oasis.opendocument.text",
31
+ "application/vnd.oasis.opendocument.spreadsheet",
32
+ "application/vnd.oasis.opendocument.presentation",
33
+ }
34
+ )
35
+
36
+ #: Relationship types that make Office fetch and run or embed remote content
37
+ #: ("remote template injection"). Plain hyperlinks are external too but harmless.
38
+ _REMOTE_RELATIONSHIPS = ("attachedtemplate", "oleobject", "frame", "subdocument")
39
+ # Every pattern stops at the next "<" (or cell delimiter), so scanning stays linear
40
+ # whatever the input: no quantifier can run across a tag boundary and backtrack.
41
+ _RELATIONSHIP = re.compile(rb"<relationship\b[^<>]*>", re.IGNORECASE)
42
+ _DDE_FIELD = re.compile(
43
+ rb"(?:instrtext[^<>]*>|instr=\")[ \t\r\n]*\"?[ \t\r\n]*dde(?:auto)?\b", re.IGNORECASE
44
+ )
45
+ #: Excel dynamic data exchange: ``=cmd|' /C calc'!A0`` runs a program when the sheet
46
+ #: opens. "|" is no Excel operator, so ``application|topic!item`` is a DDE reference.
47
+ _DDE_LINK = re.compile(rb"<ddeLink\b", re.IGNORECASE)
48
+ _DDE_FORMULA = re.compile(rb"<f\b[^<>]*>[^<|]*\|[^<]*!", re.IGNORECASE)
49
+ _DDE_CELL = re.compile(rb"(?:^|[,;\t\r\n])[ \t]*\"?[=+\-@][^,;\t\r\n|]*\|[^,;\t\r\n]*!")
50
+ _CSV_OVERLAP = 4096
51
+ _VBA_PROJECT_UTF16 = "_VBA_PROJECT".encode("utf-16-le")
52
+ _OLE_SCAN_CHUNK = 4 * MiB
53
+
54
+
55
+ def _is_macro_member(name: str) -> bool:
56
+ lowered = name.lower()
57
+ return (
58
+ lowered.endswith(("vbaproject.bin", "vbadata.xml"))
59
+ or "/activex/" in f"/{lowered}"
60
+ or lowered.startswith(("basic/", "scripts/")) # OpenDocument macros
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class OfficeMacros(Check):
66
+ """Refuse Office documents with VBA macros, ActiveX controls, DDE (Word fields, Excel
67
+ links and formulas, and formulas in CSV files, which open in Excel) or remote
68
+ templates.
69
+
70
+ For legacy OLE2 files (``.doc``/``.xls``) the presence of a ``_VBA_PROJECT``
71
+ stream is detected from the directory entries; XLM (Excel 4.0) macros in OLE2
72
+ files are not detected - use :class:`~uploadshield.checks.YaraRules` or
73
+ :class:`~uploadshield.checks.ClamAV` if you must accept those formats.
74
+ """
75
+
76
+ _deconstruct_module = "uploadshield.checks"
77
+ name = "office"
78
+
79
+ block_external_references: bool = True
80
+ max_part_bytes: int = 16 * MiB
81
+
82
+ def applies(self, file: InspectedFile) -> bool:
83
+ file_type = file.file_type
84
+ return file_type is not None and (
85
+ file_type.mime in OFFICE_ZIP_TYPES
86
+ or file_type.container == "ole"
87
+ or file_type.mime == "text/csv"
88
+ )
89
+
90
+ def run(self, file: InspectedFile) -> Iterator[Violation]:
91
+ file_type = file.file_type
92
+ if file_type is not None and file_type.container == "ole":
93
+ yield from self._run_ole(file)
94
+ elif file_type is not None and file_type.mime == "text/csv":
95
+ yield from self._run_csv(file)
96
+ else:
97
+ yield from self._run_zip(file)
98
+
99
+ def _run_csv(self, file: InspectedFile) -> Iterator[Violation]:
100
+ """CSV opens in Excel, which evaluates DDE formulas in cells."""
101
+ carry = b""
102
+ for chunk in file.iter_chunks():
103
+ window = carry + chunk
104
+ if _DDE_CELL.search(window):
105
+ yield self._dde()
106
+ return
107
+ carry = window[-_CSV_OVERLAP:]
108
+
109
+ def _run_ole(self, file: InspectedFile) -> Iterator[Violation]:
110
+ carry = b""
111
+ for chunk in file.iter_chunks(_OLE_SCAN_CHUNK):
112
+ if _VBA_PROJECT_UTF16 in carry + chunk:
113
+ yield self.violation(Code.OFFICE_MACROS, "Documents with macros are not allowed.")
114
+ return
115
+ carry = chunk[-len(_VBA_PROJECT_UTF16) :]
116
+
117
+ def _run_zip(self, file: InspectedFile) -> Iterator[Violation]:
118
+ entries = zip_entries(file) or []
119
+ names = [e.filename for e in entries]
120
+ if macros := [n for n in names if _is_macro_member(n)]:
121
+ yield self.violation(
122
+ Code.OFFICE_MACROS, "Documents with macros are not allowed.", parts=macros[:10]
123
+ )
124
+ return
125
+ # Fields run from every part Word renders: body, headers, footers, notes, ...
126
+ word_parts = [n for n in names if n.startswith("word/") and n.endswith(".xml")]
127
+ for part in word_parts:
128
+ if zip_search(file, part, _DDE_FIELD):
129
+ yield self.violation(
130
+ Code.OFFICE_MACROS, "Documents with DDE fields are not allowed.", parts=[part]
131
+ )
132
+ return
133
+ spreadsheet = [
134
+ n
135
+ for n in names
136
+ if n.startswith(("xl/externalLinks/", "xl/worksheets/")) and n.endswith(".xml")
137
+ ]
138
+ for part in spreadsheet:
139
+ pattern = _DDE_LINK if part.startswith("xl/externalLinks/") else _DDE_FORMULA
140
+ if zip_search(file, part, pattern):
141
+ yield self._dde(parts=[part])
142
+ return
143
+ if self.block_external_references and (targets := self._remote_targets(file, names)):
144
+ yield self.violation(
145
+ Code.OFFICE_EXTERNAL_REFERENCE,
146
+ "The document loads content from external locations.",
147
+ targets=targets[:10],
148
+ )
149
+
150
+ def _dde(self, **details: object) -> Violation:
151
+ return self.violation(
152
+ Code.OFFICE_MACROS,
153
+ "Spreadsheets with DDE links or formulas are not allowed.",
154
+ **details,
155
+ )
156
+
157
+ def _remote_targets(self, file: InspectedFile, names: list[str]) -> list[str]:
158
+ targets: list[str] = []
159
+ for rels in (n for n in names if n.endswith(".rels")):
160
+ info = zip_member(file, rels)
161
+ if info is not None and info.file_size > self.max_part_bytes:
162
+ # Unreadable means unchecked: refuse rather than skip (fails closed).
163
+ raise ContentTooLargeError(f"{rels} exceeds {self.max_part_bytes} bytes")
164
+ xml = zip_read(file, rels, self.max_part_bytes) or b""
165
+ for element in _RELATIONSHIP.findall(xml):
166
+ lowered = element.lower()
167
+ if b'targetmode="external"' not in lowered:
168
+ continue
169
+ type_match = re.search(rb'type="([^"]*)"', lowered)
170
+ rel_type = type_match.group(1).decode("latin-1") if type_match else ""
171
+ if rel_type.rsplit("/", 1)[-1] in _REMOTE_RELATIONSHIPS:
172
+ target = re.search(rb'target="([^"]*)"', element, re.IGNORECASE)
173
+ targets.append(target.group(1).decode("latin-1") if target else rels)
174
+ return targets