file-security 0.1.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 (46) hide show
  1. file_security/ADVANCED.md +410 -0
  2. file_security/CHANGELOG.md +49 -0
  3. file_security/LICENSE +21 -0
  4. file_security/README.md +934 -0
  5. file_security/SECURITY.md +56 -0
  6. file_security/__init__.py +101 -0
  7. file_security/__main__.py +71 -0
  8. file_security/api.py +676 -0
  9. file_security/cancellation.py +56 -0
  10. file_security/clamd_socket.py +40 -0
  11. file_security/cleanup.py +80 -0
  12. file_security/config.py +103 -0
  13. file_security/doctor.py +97 -0
  14. file_security/errors.py +56 -0
  15. file_security/failure_log.py +35 -0
  16. file_security/filenames.py +116 -0
  17. file_security/fingerprint.py +25 -0
  18. file_security/intake.py +624 -0
  19. file_security/mime.py +228 -0
  20. file_security/models.py +121 -0
  21. file_security/protocols.py +193 -0
  22. file_security/py.typed +0 -0
  23. file_security/scanners/__init__.py +3 -0
  24. file_security/scanners/archive.py +102 -0
  25. file_security/scanners/chain.py +300 -0
  26. file_security/scanners/clamav.py +553 -0
  27. file_security/scanners/conditional.py +115 -0
  28. file_security/scanners/default_chain.py +38 -0
  29. file_security/scanners/document_kinds.py +95 -0
  30. file_security/scanners/embedded_objects.py +324 -0
  31. file_security/scanners/image_metadata.py +225 -0
  32. file_security/scanners/lifecycle.py +208 -0
  33. file_security/scanners/macros.py +206 -0
  34. file_security/scanners/nested_objects.py +178 -0
  35. file_security/scanners/office.py +244 -0
  36. file_security/scanners/pdf.py +66 -0
  37. file_security/scanners/pdf_lexer.py +267 -0
  38. file_security/scanners/structural.py +220 -0
  39. file_security/scanners/zip_package.py +114 -0
  40. file_security/staging.py +325 -0
  41. file_security/version_parse.py +74 -0
  42. file_security-0.1.0.dist-info/METADATA +965 -0
  43. file_security-0.1.0.dist-info/RECORD +46 -0
  44. file_security-0.1.0.dist-info/WHEEL +5 -0
  45. file_security-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. file_security-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,410 @@
1
+ # Расширенный вызов
2
+
3
+ Короткий вызов через `FileSecurity` описан в [README.md](README.md). Здесь — приём потока, политика, цепочка, владение временным файлом и коды. Это нужно, когда сборки по умолчанию недостаточно и вызывающий код собирает сканеры сам.
4
+
5
+ Библиотека решает, можно ли сохранить загруженный файл. Она принимает поток байтов, имя и заявленный MIME-тип (`declared_mime`), проверяет файл сама и возвращает результат. Вызывающий код не повторяет эти проверки и не подменяет вывод.
6
+
7
+ Библиотека не знает, кто прислал файл и куда его потом положат. Постоянное хранилище, учёт пользователей и тексты для человека остаются у вызывающего кода. Библиотека отдаёт статус и код. Сокет демона только локальный, по Unix-сокету. TCP к `clamd` нет.
8
+
9
+ `staged_file` заполнен только при `clean`; во всех остальных случаях он равен `None`. Библиотека пытается удалить временный файл до возврата результата. Если удаление не удалось, исходный статус и код сохраняются, а в `cleanup_error_code` указывается `quarantine_discard_failed`. Вызывающий код при `clean` копирует байты сам и после этого вызывает `discard()`.
10
+
11
+ **Приём** — этап, на котором библиотека читает входной поток и записывает его во временный карантин до запуска сканеров. Дальше в этом документе «приём» означает именно этот этап (или один такой вызов для одного файла).
12
+
13
+ Термины срока: `scan_timeout` и `max_upload_duration` — публичные таймауты в настройках. После записи файла для цепочки задаётся общий **дедлайн**: `scan_timeout` от текущего момента; он передаётся всем сканерам и для каждого из них заново не начинается. `max_upload_duration` — таймаут записи входного потока, он к дедлайну цепочки не прибавляется.
14
+
15
+ ## Пример вызова
16
+
17
+ Настройки, политика, цепочка и каталог приёма живут столько же, сколько процесс. На каждый файл вызывается `secure_ingest`.
18
+
19
+ ```python
20
+ import asyncio
21
+ from collections.abc import AsyncIterable
22
+ from datetime import timedelta
23
+ from pathlib import Path
24
+
25
+ from file_security import (
26
+ ArchiveScanner,
27
+ ClamAVScanner,
28
+ FilePolicy,
29
+ FileSecuritySettings,
30
+ ImageMetadataScanner,
31
+ MacroScanner,
32
+ NestedObjectScanner,
33
+ OfficeScanner,
34
+ PDFScanner,
35
+ ScannerChain,
36
+ ScanStatus,
37
+ Staging,
38
+ secure_ingest,
39
+ )
40
+
41
+ settings = FileSecuritySettings(
42
+ quarantine_dir=Path("/var/lib/file-security/quarantine"),
43
+ socket_path="/run/clamav/clamd.ctl",
44
+ max_file_bytes=25 * 1024 * 1024,
45
+ clamav_stream_max_length=25 * 1024 * 1024,
46
+ scan_timeout=60.0,
47
+ max_active_uploads=8,
48
+ quarantine_max_files=1000,
49
+ quarantine_max_total_bytes=2 * 1024 * 1024 * 1024,
50
+ )
51
+ policy = FilePolicy(
52
+ allowed_types=frozenset(
53
+ {
54
+ "text/plain",
55
+ "application/pdf",
56
+ "image/png",
57
+ "image/jpeg",
58
+ "application/zip",
59
+ }
60
+ )
61
+ )
62
+ clamav = ClamAVScanner(
63
+ settings.socket_path,
64
+ timeout=settings.scan_timeout,
65
+ chunk_size=settings.socket_chunk_size,
66
+ max_concurrent_scans=settings.max_concurrent_scans,
67
+ max_signature_age=timedelta(days=7),
68
+ )
69
+ chain = ScannerChain(
70
+ (
71
+ ArchiveScanner(timeout=settings.scan_timeout),
72
+ OfficeScanner(timeout=settings.scan_timeout),
73
+ PDFScanner(timeout=settings.scan_timeout),
74
+ ImageMetadataScanner(timeout=settings.scan_timeout),
75
+ MacroScanner(timeout=settings.scan_timeout),
76
+ NestedObjectScanner(timeout=settings.scan_timeout),
77
+ clamav,
78
+ ),
79
+ required_names=("clamav",),
80
+ )
81
+ staging = Staging(settings)
82
+
83
+
84
+ async def accept_upload(
85
+ chunks: AsyncIterable[bytes],
86
+ filename: str,
87
+ declared_mime: str,
88
+ ) -> bytes | None:
89
+ result = await secure_ingest(
90
+ chunks,
91
+ filename=filename,
92
+ declared_mime=declared_mime,
93
+ scanner=chain,
94
+ staging=staging,
95
+ policy=policy,
96
+ settings=settings,
97
+ )
98
+ scan = result.scan
99
+ staged = result.staged_file
100
+ if scan.status is ScanStatus.CLEAN and staged is not None:
101
+ with staged.open_for_copy() as handle:
102
+ # Только для демонстрации. В реальном приложении
103
+ # копируйте handle непосредственно в постоянное хранилище.
104
+ saved = handle.read()
105
+ staged.discard()
106
+ return saved
107
+ # scan.error_code — причина отказа, угрозы, таймаута или сбоя
108
+ return None
109
+
110
+
111
+ def shutdown() -> None:
112
+ chain.close(timeout=settings.scan_timeout)
113
+
114
+
115
+ async def main() -> None:
116
+ async def chunks():
117
+ yield b"hello\n"
118
+ yield b"second line\n"
119
+
120
+ saved = await accept_upload(chunks(), "notes.txt", "text/plain")
121
+ shutdown()
122
+ print(saved)
123
+
124
+
125
+ asyncio.run(main())
126
+ ```
127
+
128
+ Всё, что используется в примере, импортируется из корня пакета. Внутренние модули `file_security.scanners.*` контрактом не являются и могут меняться.
129
+
130
+ Цепочку из примера, такую же, как у `FileSecurity`, собирает одна функция: `default_scanner_chain(clamav, timeout=settings.scan_timeout)`; запрет макросов — `reject_macros=True`, строгий запрет вставленных объектов — `reject_embedded_objects=True`. Свою цепочку собирают, когда нужен другой порядок или свой сканер.
131
+
132
+ `secure_ingest_sync` принимает те же аргументы и обычный итератор байтов или открытый двоичный файл. Один сканер без цепочки тоже можно передать в `scanner`: тогда работает только он. В скрипте выше файл один, поэтому `shutdown()` стоит сразу после него. Пока процесс принимает новые файлы, цепочку не закрывают.
133
+
134
+ Три предела каталога обязательны, иначе приём не стартует: `max_active_uploads`, `quarantine_max_files`, `quarantine_max_total_bytes`. `clamav_stream_max_length` не может быть меньше `max_file_bytes`. Каталог карантина к моменту вызова уже существует и доступен на запись процессу, который вызывает библиотеку.
135
+
136
+ ## С чего начать в коде
137
+
138
+ | Вопрос | Файл |
139
+ | --- | --- |
140
+ | Как вызвать проверку | `intake.py`, функции `secure_ingest` и `secure_ingest_sync` |
141
+ | Настройки и политика типов | `config.py` |
142
+ | Отказ по имени | `filenames.py` |
143
+ | Отказ по началу файла и синонимы типов | `mime.py` |
144
+ | Временный файл и квота каталога | `staging.py` |
145
+ | Порядок сканеров | `scanners/chain.py` |
146
+ | Цепочка по умолчанию | `scanners/default_chain.py` |
147
+ | Владение сканером и закрытие | `scanners/lifecycle.py` |
148
+ | ClamAV | `scanners/clamav.py` |
149
+ | Стандартные пути сокета и проверка его прав | `clamd_socket.py` |
150
+ | Разбор архива, Office, PDF, картинки, макросов и вложенных объектов | каталог `scanners/` |
151
+ | Поиск имён PDF и распаковка потоков объектов | `scanners/pdf_lexer.py` |
152
+ | Вид пакета ZIP по содержимому и бюджет оглавления | `scanners/zip_package.py` |
153
+ | Ожидание, которое не теряет отмену | `cancellation.py` |
154
+ | Проверка окружения перед стартом | `doctor.py` |
155
+ | Удаление забытых временных файлов | `cleanup.py` |
156
+ | Коды и статусы | `models.py` |
157
+
158
+ `secure_ingest` — асинхронный вход. `secure_ingest_sync` делает те же шаги для обычного итератора байтов. Оба возвращают `IngestResult`: внутри `scan` (`ScanResult`) и `staged_file`. `staged_file` заполнен только при `clean`; во всех остальных случаях он равен `None`. Библиотека пытается удалить временный файл до возврата результата. Если удаление не удалось, исходный статус и код сохраняются, а в `cleanup_error_code` указывается `quarantine_discard_failed`.
159
+
160
+ Исключение библиотека поднимает, когда сломан сам приём: нет каталога, неверная настройка, временный файл подменён. Обычный отказ, угроза, таймаут и недоступный демон — это `ScanResult`, не исключение.
161
+
162
+ ## Что происходит с потоком
163
+
164
+ Один вызов — один `operation_id`. Его видно в результате и в журнале сбоев.
165
+
166
+ 1. Если сканер или цепочка уже останавливаются, приём сразу возвращает `error` / `scanner_shutting_down`. Временный файл не создаётся.
167
+ 2. Настройки проверяются до записи. Без трёх пределов каталога приём не стартует: сколько приёмов одновременно, сколько файлов в каталоге, сколько байт в каталоге суммарно. Предел потока демона не может быть меньше предела файла.
168
+ 3. Приём занимает место в квоте каталога. Места нет — `error` / `quarantine_full`.
169
+ 4. В каталоге карантина создаётся новый файл. Имя случайное, с префиксом `.file-security-`. Флаги создания не дают открыть уже существующий файл и не дают перейти по символической ссылке. Режим `0600`. На том же дескрипторе берётся блокировка. Путь этого файла в результат и в сканер не попадает.
170
+ 5. Поток пишется на диск кусками, целиком в память не собирается. На запись отведён `max_upload_duration` (по умолчанию 60 секунд). Файл больше `max_file_bytes` — запись прекращается, временный файл удаляется, статус `rejected`, код `too_large`. Поток оборвался или не пришёл за отведённое время — `error` / `input_stream_failed`. В `secure_ingest` таймаут прерывает ожидание следующего куска. В `secure_ingest_sync` он проверяется между кусками: блокирующий `read()` источника библиотека прервать не может, поэтому таймаут чтения синхронного входа задаёт сам источник. Запись на диск в асинхронном приёме идёт в отдельном потоке, цикл событий её не ждёт. Отмена задачи на этом шаге удаляет временный файл и пробрасывает отмену дальше.
171
+ 6. Считаются размер и SHA-256. После этого слот квоты «приём ещё идёт» освобождается. Сам файл остаётся, пока не кончится проверка.
172
+ 7. Политика файла, до любого сканера. Слот демона она не занимает. Порядок фиксированный: пустой файл, размер, имя, затем содержимое. Первый отказ удаляет временный файл и заканчивает вызов.
173
+ 8. Если политика файл пропустила, для цепочки задаётся общий дедлайн: `scan_timeout` от текущего момента. Этот дедлайн не продолжает часы записи и между сканерами не обнуляется.
174
+ 9. Сканеры идут строго по очереди. Следующий не стартует, пока предыдущий не вернул вердикт. Первый вердикт, который не чистый и не пропуск, останавливает цепочку.
175
+ 10. Чистый результат без кода ошибки переводит временный файл в состояние «можно копировать» и отдаёт его вызывающему коду. Любой другой результат файл удаляет.
176
+
177
+ Копировать можно только так. `staged_file.open_for_copy()` ещё раз проверяет устройство, inode, размер, SHA-256 и то, что путь остался внутри каталога карантина. После копирования вызывающий код сам вызывает `discard()`. Повторный `discard()` безопасен. Пока копирование не закончилось, удалить файл нельзя.
178
+
179
+ Если удалить отклонённый файл не удалось, в результате остаётся исходный код отказа, а рядом `cleanup_error_code` = `quarantine_discard_failed`. Отказ из-за этого не превращается в успех.
180
+
181
+ `on_result`, если передан, получает уже готовый `ScanResult`. Исключение в этом вызове пишется в журнал сбоев и результат не меняет. Обратный вызов сам файл не публикует.
182
+
183
+ ## Политика до сканеров
184
+
185
+ `FilePolicy` передаёт вызывающий код. Своего списка разрешённых типов у библиотеки нет.
186
+
187
+ Имя проверяется дважды по смыслу одной функции: как пришло и после нормализации NFKC. Такое имя приводит к результату `rejected` с кодом `invalid_filename`: нулевой байт, управляющие символы, пустое имя, `.` и `..`, абсолютный путь, слэш, обратный слэш, имя длиннее 255 символов, пробел или точка в конце. Опасное расширение из `DANGEROUS_EXTENSIONS` или из `FilePolicy.denied_extensions` даёт `dangerous_content` по правилу двух последних расширений: опасное последнее — всегда отказ, опасное предпоследнее — только если оно замаскировано пробельным символом внутри него или внутри последнего расширения. Примеры — в [README.md](README.md#правило-расширений). Физическое имя в карантине от этого имени не зависит.
188
+
189
+ Для обычного распознавания начала файла библиотека читает первые 4096 байт. Это закрытый набор, не универсальный определитель: PDF, PNG, JPEG, GIF, WEBP, ZIP, GZIP, OLE, ELF, PE. `libmagic` нет. PE проверяется отдельно: после чтения заголовка DOS библиотека дополнительно проверяет подпись `PE\0\0` по указанному в нём смещению, даже если это смещение дальше первых 4096 байт.
190
+
191
+ Синонимы типов (`application/x-zip-compressed`, `image/jpg` и другие из `MIME_SYNONYMS`) приводятся к канону до сравнения и в заявленном MIME-типе, и в списках политики. Таблица — в [README.md](README.md#синонимы-типов). Дальше в этом разделе «заявленный тип» значит заявленный MIME-тип. Его не следует смешивать с форматом документа, который определяется по содержимому пакета (OOXML, ODF, старый Office).
192
+
193
+ Порядок внутри проверки содержимого:
194
+
195
+ 1. Заголовок ELF или PE при включённом флаге — `dangerous_content`. До таблицы типов дело не доходит.
196
+ 2. ZIP с битом шифрования в первом локальном заголовке при включённом флаге — `encrypted_zip`. Архив при этом не распаковывается.
197
+ 3. Заявленный тип пустой по форме, с пробелами или с `*` — `mime_mismatch`.
198
+ 4. Начало распознано и не совместимо с заявленным типом — `mime_mismatch`. Новый Office и ODF совместимы с ZIP, старый Office совместим с OLE. В списке разрешённых при этом должен быть заявленный тип документа, а не «вместо него» сам ZIP.
199
+ 5. Запрещающий список сильнее разрешающего.
200
+ 6. Начало не распознано. Это не то же самое, что несовпадение. Если список разрешённых не задан, такой файл идёт дальше, кроме исполняемого заголовка и опасного имени. Если список задан, файл идёт дальше только когда заявленный тип в списке и для него в наборе нет обязательной подписи. Сейчас это обычный текст, CSV, Markdown, RTF и 7z. PDF, картинка, ZIP, GZIP и Office без своей подписи получают `mime_mismatch`, даже если заявленный тип разрешён. Нет заявленного типа при заданном списке — тоже `mime_mismatch`. Пустой список разрешённых отклоняет всё.
201
+
202
+ 7z и RTF по началу файла эта версия не подтверждает. Их подпись можно добавить в закрытый набор отдельно.
203
+
204
+ ## Цепочка
205
+
206
+ Цепочка сама сканером не является. Её собирает вызывающий код и передаёт в `secure_ingest` вместо одного сканера. Один сканер без цепочки тоже допустим: приём оборачивает его вердикт в тот же результат.
207
+
208
+ Обычный порядок в примере выше: `archive`, `office`, `pdf`, `image_metadata`, `macros`, `nested_objects`, затем `clamav`. Обязательное имя `clamav` значит, что демон должен реально начать работу, если предыдущие сканеры файл пропустили. Другой порядок допустим. Имена сканеров в одной цепочке должны быть уникальными. Цепочку внутрь цепочки класть нельзя. В одной цепочке не может быть двух сканеров с именем `clamav`.
209
+
210
+ Пропуск (`skipped`) значит «этому сканеру файл не подходит». Пропуск не останавливает цепочку и не считается выполненной проверкой. Обязательное имя должно и встретиться в цепочке, и реально начать работу. Если цепочка дошла до конца, а обязательный сканер не выполнялся, результат `error` / `required_scanner_missing`. Если раньше сработал отказ, цепочка на этом и заканчивается: обязательный сканер в таком вызове уже не нужен.
211
+
212
+ Ни один сканер не начал работу — `error` / `no_scanner_ran`.
213
+
214
+ `ConditionalScanner` запускает внутренний сканер только когда предикат по `ScanInput` истинен. Иначе вердикт — пропуск. В вердикте имя внутреннего сканера. Обёртка не может закрывать обязательное имя: обязательный сканер стоит в цепочке сам. Обёртку внутрь обёртки класть нельзя. Внутренним сканером владеет обёртка, а обёрткой — цепочка.
215
+
216
+ Общий дедлайн проверяется перед каждым сканером. Истечение общего срока даёт `timeout` / `scanner_timeout`. Отмена вызывающей задачи не превращается в `ScanResult`: после завершения необходимой очистки наружу снова передаётся `CancelledError`. Исключение внутри сканера становится `error` / `scanner_failed` и тоже останавливает цепочку.
217
+
218
+ Имя `chain` в вердикт не попадает. У чистого итога `scanner_name` пустой. У отказа там имя сканера, который остановил цепочку. `executed_scanners` — имена, которые реально начали проверку.
219
+
220
+ После того как сканер передан в цепочку, его останавливает только `close()` цепочки. Повторный `close()` того же сканера снаружи поднимает `ScannerOwnershipError`. Встроенные сканеры наследуют `OwnedScanner` и проверяют владельца сами. Сломанная сборка на середине отпускает уже занятые сканеры, их можно отдать другой цепочке. Уже закрытый сканер в новую цепочку не принимается.
221
+
222
+ Владелец запоминается по идентичности объекта, а не по равенству: два равных сканера-датакласса принадлежат разным цепочкам, а сканер без хеша тоже допустим. Сканер должен поддерживать слабые ссылки (обычный класс и датакласс без `slots=True` поддерживают). Реестр владения хранит слабые ссылки на цепочки и сканеры, чтобы не удерживать объекты, которые перестали использовать без вызова `close()`. Сама цепочка, пока она жива, владеет переданными в неё сканерами и удерживает их сильными ссылками. Если цепочку перестали использовать без `close()`, её ресурсы (потоки, сокеты) не закрываются автоматически, поэтому `close()` всё равно обязателен.
223
+
224
+ ## Сканеры содержимого
225
+
226
+ Каждый из шести сканеров читает уже принятый файл через дубликат дескриптора. Исходный номер он не закрывает. Перед чтением смещение ставится в 0. На диск распакованное не пишется, отдельный процесс не запускается. Файл другого формата сканер пропускает.
227
+
228
+ Сканер выбирает файл и по заявленному MIME-типу, и по тому, что приём нашёл в начале файла. ZIP, заявленный как архив, сканеры Office всё равно открывают, а формат документа решает содержимое пакета: `[Content_Types].xml` — OOXML, файл `mimetype` с `application/vnd.oasis.opendocument.` — ODF, иначе обычный архив. Старый Office распознаётся по контейнеру OLE.
229
+
230
+ Разбор идёт в пуле потоков сканера, по два файла на сканер. Место в пуле занимается до передачи работы в пул и освобождается, когда работа кончилась. Если до общего дедлайна места не дождались — `error` / `scanner_busy`, файл в очередь не ставится. Работа, которая уже идёт, следит за тем же дедлайном и заканчивается `timeout` / `scanner_timeout`.
231
+
232
+ Бюджеты по умолчанию. Пределы архива (`max_entries`, `max_uncompressed`, `max_member`), PDF (`max_bytes` или `max_pdf_bytes`, `max_inflated_bytes`) и картинки (`max_bytes`, `max_metadata`) меняются аргументами конструктора сканера. Пределы Office и OLE зашиты в коде.
233
+
234
+ | Что | Предел | Код при превышении |
235
+ | --- | --- | --- |
236
+ | Членов ZIP | 10 000 | `archive_limit` |
237
+ | Заявленный несжатый размер ZIP на архив и на член | 64 МиБ | `archive_limit` |
238
+ | Распакованный поток GZIP | 64 МиБ | `archive_limit` |
239
+ | Файл связей Office (`.rels`, `manifest.xml`) | 1 МиБ | `content_limit` |
240
+ | `[Content_Types].xml` при поиске макросов | 1 МиБ | `content_limit` |
241
+ | Поток `Workbook` старого Excel | 64 МиБ | `content_limit` |
242
+ | Записей в оглавлении OLE | 4096 | `office_invalid` |
243
+ | Файл PDF при разборе | 64 МиБ | `content_limit` |
244
+ | Распакованные потоки объектов PDF, всего на файл | 64 МиБ | `content_limit` |
245
+ | Картинка целиком при разборе | 64 МиБ | `content_limit` |
246
+ | Служебные блоки картинки (EXIF, XMP, комментарии и т.п.) | 1 МиБ | `metadata_oversize` |
247
+ | Глубина вложенных объектов | 2 ступени | `content_limit` |
248
+ | Один вложенный объект в память | 16 МиБ | `content_limit` |
249
+ | Все вложенные объекты файла | 64 МиБ, 64 штуки | `content_limit` |
250
+
251
+ `content_limit` — отказ файла, а не сбой: файл, который сканер не смог разобрать до конца, дальше не идёт. Для картинки это отдельный случай от `metadata_oversize`: слишком большой сам файл — `content_limit`, слишком большие только служебные блоки при нормальном размере файла — `metadata_oversize`. Повреждённый пакет тоже отказ файла (`archive_invalid` или `office_invalid`), а не `scanner_failed`: сканеры ловят все ошибки, которыми `zipfile` и `olefile` отвечают на испорченные данные. `scanner_failed` остаётся для ошибки в самом сканере.
252
+
253
+ ### Сканер `archive`
254
+
255
+ Срабатывает на ZIP, GZIP и на пакет Office, потому что новый документ Office — это ZIP.
256
+
257
+ Читает оглавление ZIP или поток GZIP. Отказ:
258
+
259
+ | Код | Когда |
260
+ | --- | --- |
261
+ | `archive_limit` | ZIP: слишком много членов, слишком большой заявленный в оглавлении размер члена или архива. GZIP: распакованный в памяти поток вышел за бюджет |
262
+ | `archive_path` | Имя члена с `..`, абсолютный путь, буква диска, нулевой байт или символическая ссылка Unix |
263
+ | `encrypted_zip` | Бит шифрования у любого члена, не только у первого заголовка |
264
+ | `archive_invalid` | Оглавление ZIP или поток GZIP повреждены |
265
+
266
+ Для ZIP размеры берутся из оглавления, члены не распаковываются. Архив, который заявляет маленький размер, а распаковывается в большой, этот сканер не поймает: члены обычного zip он не открывает. Их и файлы внутри него проверяет ClamAV со своими пределами распаковки. 7z сканер архива не разбирает.
267
+
268
+ ### Сканер `office`
269
+
270
+ Срабатывает на заявленный новый Office, ODF, старый Office, а также на любой распознанный ZIP или OLE.
271
+
272
+ Формат документа определяется по содержимому пакета. Если заявлен конкретный формат Office, он обязан совпасть с содержимым и в пакете должна быть главная часть: `word/document.xml`, `xl/workbook.xml` или `ppt/presentation.xml` для OOXML, `content.xml` для ODF. Не совпало — `office_invalid`. ZIP без признаков документа, заявленный как архив, сканер пропускает.
273
+
274
+ Дальше читаются файлы связей. У Office Open XML это каждый `.rels`, связь разбирается по атрибутам `Type`, `Target` и `TargetMode`:
275
+
276
+ - внешняя связь, которую Office загружает сам при открытии документа (`attachedTemplate`, `oleObject`, `frame`, `subDocument`, `externalLinkPath`), — `external_target` при любом адресе, даже `https`;
277
+ - другая внешняя связь, например гиперссылка, допустима с адресом `http`, `https` или `mailto`: Office открывает её только по щелчку. Адрес `file:`, путь UNC (`\\server\share`), путь без схемы или другая схема — `external_target`;
278
+ - внутренняя связь, которая после разбора `..` выходит за корень пакета, — `external_target`. Имя части, начинающееся с `/`, отсчитывается от корня пакета. Обычная внутренняя ссылка вроде `../customXml/item1.xml` из `word/_rels/` остаётся.
279
+
280
+ У ODF читается `META-INF/manifest.xml` по тем же правилам для адресов и путей. Ссылки внутри `content.xml` и `styles.xml` не разбираются. Файл связей больше 1 МиБ — `content_limit`.
281
+
282
+ Старый Office открывается как OLE. Поток `EncryptionInfo` или `EncryptedPackage` — `encrypted_document`. Так же выглядит и зашифрованный документ нового Office: он хранится внутри OLE. Слишком большое оглавление или повреждённый контейнер — `office_invalid`.
283
+
284
+ ### Сканер `pdf`
285
+
286
+ Срабатывает на заявленный или распознанный PDF. Объекты и страницы не разбираются. Файл читается кусками своим лексером на стандартной библиотеке, и в нём ищутся имена PDF по правилам записи спецификации: имя заканчивается на любом разделителе, поэтому компактная запись `/JS(…)` и `/S/JavaScript` находится; последовательности `#xx` раскрываются, поэтому `/J#61vaScript` — то же имя `/JavaScript`. Имя на границе кусков не теряется.
287
+
288
+ Потоки объектов (`/Type /ObjStm`), в которых PDF 1.5 и новее прячет словари, распаковываются в памяти, если сжаты одним `FlateDecode` без предсказателя, и в распакованном тексте ищутся те же имена. Общий вывод распаковки на файл ограничен бюджетом.
289
+
290
+ | Код | Когда |
291
+ | --- | --- |
292
+ | `encrypted_document` | есть `/Encrypt`, в том числе в компактном трейлере |
293
+ | `active_content` | есть `/JavaScript` или `/Launch`, либо `/JS` со значением |
294
+ | `content_limit` | файл больше предела разбора; распаковка потоков объектов вышла за бюджет; потоки с неподдерживаемыми фильтрами (`ASCII85Decode`, `ASCIIHexDecode`, `LZWDecode`, `RunLengthDecode`, `Crypt`), предикторами или несколькими последовательными `FlateDecode` не распаковываются и приводят к `content_limit` |
295
+
296
+ `/OpenAction` и `/AA` сами по себе не отказ: так обычно пишут «открыть на первой странице». Опасным действие делает тип `/JavaScript` или `/Launch`, а его сканер видит по имени.
297
+
298
+ Ограничения, которые нужно знать:
299
+
300
+ - имена ищутся везде, в том числе в строках и комментариях; PDF, где слово `/JavaScript` написано текстом в несжатом потоке, будет отклонён;
301
+ - обычные потоки (страницы, картинки, шрифты) не распаковываются: словарь действия в них не хранится, но текст страницы сканер не видит;
302
+ - проверка ищет известные имена, а не разбирает документ. Её можно обойти приёмом, которого нет в списке выше. Последний рубеж — демон ClamAV, а для недоверенных PDF — открытие в просмотрщике без JavaScript.
303
+
304
+ ### Сканер `image_metadata`
305
+
306
+ Срабатывает на JPEG, PNG, GIF и WEBP. Картинку не перекодирует и целиком в память не читает: идёт по заголовкам блоков окнами по 64 КиБ и проверяет общий дедлайн между окнами. Картинка больше предела разбора — `content_limit`. Служебные блоки больше бюджета или длина блока, которая не помещается в файл и больше бюджета, — `metadata_oversize`. Для PNG это текстовые и цветовые служебные блоки, для JPEG — комментарий и блоки APP, для GIF — комментарий и расширения, для WEBP — EXIF и XMP.
307
+
308
+ ### Сканер `macros`
309
+
310
+ Срабатывает на те же файлы, что и `office`, и так же определяет формат документа по содержимому. Поэтому `.docm`, заявленный как `application/zip`, проверяется как документ.
311
+
312
+ Сканер находит макросы, но решение о запрете принимает политика приложения. При `reject_macros=False` (значение по умолчанию) находка записывается в журнал `file_security` (`event=file_security_macros_found operation_id=… code=…`), а сканер не отклоняет файл и передаёт его дальше по цепочке. Итоговый `clean` появляется только если остальные проверки тоже завершились успешно. С `reject_macros=True` те же находки дают `rejected` с кодом из таблицы. В `FileSecurity` и `default_scanner_chain` флаг называется так же.
313
+
314
+ | Формат документа | `macros_present` | `active_content` |
315
+ | --- | --- | --- |
316
+ | OOXML | часть `vbaProject.bin`; листы макросов Excel 4.0 в `xl/macrosheets/`; тип макросов или VBA в `[Content_Types].xml` | части в каталоге `activeX` |
317
+ | ODF | каталог `Basic` или `Scripts` | — |
318
+ | Старый Office (OLE) | поток, в имени которого есть `vba`; в книге Excel лист макросов или модуль VBA по записи `BoundSheet8` | — |
319
+
320
+ Книгу старого Excel сканер читает по записям потока `Workbook` или `Book` до первой записи `EOF`. Запись `FilePass` — `encrypted_document` при любом значении `reject_macros`, даже если раньше нашёлся макрос. Поток больше 64 МиБ — `content_limit`. Обычный zip без признаков документа этот сканер пропускает. Повреждённый пакет — `archive_invalid`, повреждённый OLE — `office_invalid`.
321
+
322
+ ### Сканер `nested_objects`
323
+
324
+ Срабатывает на документ Office и на PDF. Тип встроенного объекта и повторный разбор — модуль `scanners/embedded_objects.py`. Где объекты ищутся: в OOXML — под каталогами `embeddings` и `objectpool`; в ODF — `Object N` (файл) и `Object N/` (подчинённый документ); в OLE — хранилища `ObjectPool/<id>` и `MBD*`. Каталог `ObjectReplacements/` в ODF — картинки-заменители, не объекты. Объекты `.ppt` внутри потока `PowerPoint Document` не извлекаются.
325
+
326
+ Распознанный вложенный документ Office (пакет OOXML или ODF, документ старого Office с потоком `WordDocument` / `Workbook` / `Book` / `PowerPoint Document`, подчинённый документ ODF) фиксируется как находка `nested_object`, записывается в журнал и при `reject_embedded_objects=False` не останавливает цепочку. После этого вложенный документ разбирается дальше теми же проверками, что и основной файл: пределы пакета, внешние связи, шифрование, макросы и ActiveX, его собственные объекты. «Разрешить» здесь значит не «считать безопасным», а не отклонять только за сам факт распознавания. Итоговый статус будет `clean` только если остальные проверки тоже завершились успешно. С `reject_embedded_objects=True` тот же объект даёт `rejected` / `nested_object`.
327
+
328
+ Поток `Package` отклоняется как произвольное вложение, если его содержимое не распознано как документ Office. Если внутри распознан пакет OOXML или ODF, он обрабатывается как вложенный документ Office. Наличие потока `Ole10Native` всегда трактуется как вложенный произвольный файл и приводит к отказу `unverifiable_embedded_object`. Так же отклоняются неизвестный OLE, обычный ZIP и любой другой файл. Причина в журнале: `ole10native`, `ole_package`, `unknown_ole`, `unknown_file`, `plain_zip`.
329
+
330
+ `reject_macros=True` у `NestedObjectScanner`, `default_scanner_chain` и `FileSecurity` отклоняет макросы и ActiveX и в основном документе, и во вложенных распознанных документах. Иначе обход через вставленный `.xlsm`.
331
+
332
+ Повторный разбор ограничен: глубина две ступени, один объект не больше 16 МиБ, все объекты файла не больше 64 МиБ и не больше 64 штук; глубже или больше — `content_limit`. Дедлайн общий с остальным разбором. Вложенный ZIP проходит те же пределы оглавления, что и архив верхнего уровня.
333
+
334
+ Вложение PDF (`/EmbeddedFile`, `/EmbeddedFiles`) всегда даёт `pdf_attachment`. PDF читается тем же лексером, что и в сканере `pdf`, поэтому вложение внутри сжатого потока объектов тоже находится, а непрозрачный поток объектов даёт `content_limit`. Формат вложения пока не различается: это блокирует, в частности, ZUGFeRD/Factur-X.
335
+
336
+ ## ClamAV
337
+
338
+ `ClamAVScanner` говорит с демоном своим клиентом на `socket` стандартной библиотеки. Чужой Python-библиотеки для демона нет. Дополнительная зависимость для этого сканера не нужна. Команды имеют форму с завершающим нулевым байтом: `zINSTREAM` проверяет файл, `zVERSION` возвращает дату базы, а `doctor` использует `zPING` и `zVERSIONCOMMANDS`. Служебные команды ограничены своим таймаутом `command_timeout` (5 секунд), а не общим дедлайном проверки. Ответ демона читается не длиннее 64 КиБ. Длиннее — `scanner_unexpected_reply`.
339
+
340
+ Слот демона занимается на весь сеанс и отпускается, когда сеанс закончен. Цепочка второй семафор не добавляет. Если база старше значения `max_signature_age`, переданного в `ClamAVScanner`, слот не занимается, а результатом становится `error` / `stale_signature_database`. В примере выше это семь суток. `None` отключает эту проверку. Дата базы без часового пояса или из будущего — `scanner_invalid_result`, до вычитания возраста.
341
+
342
+ Удачный ответ `VERSION` запоминается на `version_cache_seconds`. Сбой запроса не запоминается: следующий файл спросит демон снова. Дата в ответе без пояса, в местном времени хоста демона. По умолчанию предполагается локальный Unix-сокет и одинаковая системная временная зона процесса и `clamd`; дата тогда читается в поясе процесса. Для другой конфигурации пояс задаётся через `signature_date_timezone`. Подробности — в [README.md](README.md#часовой-пояс-даты-базы).
343
+
344
+ Демон не ответил — `scanner_unavailable`. Все слоты заняты до конца общего дедлайна — `scanner_busy`. Ответ `FOUND` — `infected` / `malware_detected` и имя сигнатуры в `signature_name`. Ответ о превышении лимита потока — `scanner_limit`. Непонятный ответ — `scanner_unexpected_reply`. Блокирующий ввод-вывод сокета идёт в пуле потоков сканера, цикл событий его не ждёт.
345
+
346
+ Сканеры не удаляют файлы из карантина. После получения вердикта это делает приём.
347
+
348
+ ## Кто чем владеет
349
+
350
+ Сканер владеет своим сокетом, потоком и слотом. Дескриптор приёма он не закрывает и `discard()` не вызывает.
351
+
352
+ Приём владеет дескриптором, временным файлом и удалением. При чистом результате файл не удаляет: его забирает вызывающий код.
353
+
354
+ Цепочка владеет порядком, общим дедлайном и списком обязательных имён. Файл не удаляет и сокет демона не открывает.
355
+
356
+ `close(timeout=...)` останавливает сканер или цепочку. Таймаут конечный и не меньше нуля. Для цепочки `timeout=None` нельзя. Состояния: открыт, затем закрывается, затем либо закрыт, либо ещё дочитывает сеанс (`draining`). `close(timeout=0)` не ждёт рабочий поток.
357
+
358
+ ## Отмена
359
+
360
+ Истечение общего срока даёт `timeout` / `scanner_timeout`. Отмена вызывающей задачи не превращается в `ScanResult`: после завершения необходимой очистки наружу снова передаётся `CancelledError`. Отмена во время записи удаляет временный файл и уходит наверх как `CancelledError`. Отмена во время сканера помечает сигнал этого приёма; ожидание рабочего потока само по себе отмену в успешный результат не превращает.
361
+
362
+ Для своего кода с тем же правилом есть `await_despite_cancel(work, on_cancel=None)`. Он ждёт работу до конца, даже если задачу отменяют, и возвращает `AwaitOutcome` с полями `value`, `error`, `cancelled` и `cancellation_count`. Сам `CancelledError` он не бросает: после уборки его бросает вызывающий код, если `cancelled` истинно. Счётчик отмен задачи (`Task.cancelling()`) помощник не снимает и `Task.uncancel()` не вызывает, поэтому `asyncio.timeout` и `TaskGroup` вокруг вызова видят отмену так же, как без него. Отмена, пришедшая в ту же итерацию цикла, что и результат работы, тоже засчитывается. Отмена самой работы (а не вызывающей задачи) даёт `cancelled` без увеличения счётчика.
363
+
364
+ ```python
365
+ outcome = await await_despite_cancel(asyncio.to_thread(copy_file))
366
+ cleanup()
367
+ if outcome.cancelled:
368
+ raise asyncio.CancelledError
369
+ if outcome.error is not None:
370
+ raise outcome.error
371
+ ```
372
+
373
+ Если чистый результат уже собран, но задача к этому моменту отменена, временный файл всё равно удаляется и наружу идёт отмена, а не публикация.
374
+
375
+ ## Проверка окружения и уборщик
376
+
377
+ `doctor` смотрит каталог, сокет, ответ `PING`, список команд, дату базы и короткий `INSTREAM`. Нет `INSTREAM` — критическая ошибка. Неизвестная `VERSIONCOMMANDS` — предупреждение. Запуск от root — одна критическая ошибка, без повторной жалобы на владельца каталога. Путь сокета не найден, ведёт не на сокет или в сокет может писать любой пользователь (бит записи для остальных) — критическая ошибка. Права проверяются у файла, на который указывает символическая ссылка. Демон опрашивается, только если с сокетом всё в порядке. База старше `warning_after` (по умолчанию трое суток) — предупреждение, текст называет этот порог. Пробный файл в карантине получает случайное имя и удаляется сразу. Цепочку пользователя `doctor` не гоняет.
378
+
379
+ Код выхода: есть критическая ошибка — 2, передан строгий режим и есть предупреждение — 1, иначе 0. Команда разработки: `python -m file_security doctor` и `python -m file_security cleanup`.
380
+
381
+ Уборщик удаляет обычные файлы с префиксом `.file-security-` старше времени жизни. По умолчанию это час, и первые пять секунд файл не трогается. Символические ссылки не удаляются и по ним уборщик не ходит. Файл с чужой блокировкой остаётся. На `atexit` уборка не завязана.
382
+
383
+ Прежде чем что-то удалять, `cleanup` проверяет сам каталог. Каталога нет, это символическая ссылка, не каталог или права шире `0700` — `ConfigurationError`, ничего не удаляется. `python -m file_security cleanup` в этом случае пишет `critical:` в stderr и выходит с кодом 2. Тихий код 0 при сломанной настройке скрыл бы, что карантин не убирается.
384
+
385
+ ## Статусы
386
+
387
+ Их пять: `clean`, `infected`, `rejected`, `error`, `timeout`.
388
+
389
+ Копировать можно только при `clean`, пустом коде ошибки и непустом `staged_file`. `is_clean` смотрит на статус. Перед копированием вызывающий ещё проверяет, что `staged_file` не `None`.
390
+
391
+ | Группа | Коды |
392
+ | --- | --- |
393
+ | Политика, статус `rejected` | `empty_file`, `too_large`, `invalid_filename`, `dangerous_content`, `mime_mismatch`, `encrypted_zip` |
394
+ | Разбор содержимого, статус `rejected` | `archive_limit`, `archive_path`, `archive_invalid`, `office_invalid`, `external_target`, `encrypted_document`, `active_content`, `macros_present` (только при `reject_macros=True`), `nested_object` (только при `reject_embedded_objects=True`), `unverifiable_embedded_object`, `pdf_attachment`, `metadata_oversize`, `content_limit` |
395
+ | Демон нашёл угрозу, статус `infected` | `malware_detected` |
396
+ | Проверку не удалось довести, статус `error` или `timeout` | `scanner_unavailable`, `scanner_busy`, `scanner_limit`, `scanner_unexpected_reply`, `scanner_timeout`, `scanner_shutting_down`, `stale_signature_database`, `scanner_failed`, `scanner_invalid_result`, `required_scanner_missing`, `no_scanner_ran`, `input_stream_failed`, `quarantine_full` |
397
+
398
+ `quarantine_discard_failed` не является самостоятельным кодом результата: он появляется только в `cleanup_error_code` рядом с исходным статусом и кодом.
399
+
400
+ Имя сигнатуры бывает только у `infected`. Путь карантина не бывает нигде в результате.
401
+
402
+ Коды — члены `ErrorCode(StrEnum)`. В `ScanResult.error_code` и `FileCheckResult.error_code` лежит строка, и сравнение с членом перечисления и с обычной строкой даёт одно и то же. Значения кодов стабильны: новые коды добавляются, существующие не переименовываются.
403
+
404
+ ## Чего в библиотеке нет
405
+
406
+ YARA, очистка опасных частей файла с повторным приёмом и песочница в эту версию не входят. Оценка риска и ручная проверка сюда не относятся.
407
+
408
+ Полная распаковка архива на диск и обход любой глубины не делаются. Шифрованный 7z библиотека не отличает. Демон может ответить `OK`, если содержимое такого архива он не смотрел.
409
+
410
+ PDF не разбирается как документ: лексер ищет известные имена и раскрывает только потоки объектов с одним `FlateDecode`. Ссылки внутри содержимого ODF (`content.xml`) не проверяются, только манифест. Модель угроз целиком — в [SECURITY.md](SECURITY.md).
@@ -0,0 +1,49 @@
1
+ # Изменения file_security
2
+
3
+ Формат — по [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
4
+
5
+ ## [0.1.0] — 2026-11-10
6
+
7
+ Первая версия пакета `file-security` для публикации.
8
+
9
+ ### Изменено, может потребовать правки у вызывающего
10
+
11
+ - Всё, что нужно приложению, импортируется из корня пакета: сканеры, `ScannerChain`, `ConditionalScanner`, `ScanInput`, `ScanContext`, `ScanVerdict`, `ErrorCode`, `default_scanner_chain`, `await_despite_cancel`, `DANGEROUS_EXTENSIONS`, `MIME_SYNONYMS`. Модуль `file_security.scanners.base` удалён. Внутренние модули контрактом не являются.
12
+ - `ErrorCode` стал `StrEnum`. Сравнение со строкой работает как раньше.
13
+ - Макросы и ActiveX в Office по умолчанию больше не отказ. Сканер `macros` их находит, пишет в журнал `event=file_security_macros_found` и пропускает документ дальше. Прежний запрет включается аргументом `reject_macros=True` у `FileSecurity`, `default_scanner_chain` и `MacroScanner`. Зашифрованная книга старого Excel и повреждённый документ отклоняются как раньше, в том числе когда в книге есть лист макросов.
14
+ - Встроенные объекты больше не отклоняются безусловно. Распознанный документ Office (пакет OOXML или ODF, старый Office в OLE, подчинённый документ ODF) по умолчанию проходит дальше с повторным разбором. Упаковщик (`Ole10Native`, `Package` не с документом Office), неизвестный объект и вложение PDF отклоняются всегда новыми кодами `unverifiable_embedded_object` и `pdf_attachment`. Строгий запрет любых вставленных объектов — `reject_embedded_objects=True` у `FileSecurity`, `default_scanner_chain` и `NestedObjectScanner`. `reject_macros=True` действует и на макросы с ActiveX во вложенных документах.
15
+ - Правило расширений: смотрятся только два последних расширения. Опасное последнее — отказ. Опасное предпоследнее — отказ, только если оно замаскировано пробельным символом. `invoice.example.com.pdf` теперь проходит, `document.pdf.exe` и `file.tar.gz.exe` по-прежнему отклоняются.
16
+ - Office: обычная гиперссылка `http`, `https` или `mailto` больше не отказ. `external_target` дают внешние связи, которые Office загружает сам (`attachedTemplate`, `oleObject`, `frame`, `subDocument`, `externalLinkPath`), адреса `file:` и UNC, пути без схемы и выход за корень пакета.
17
+ - PDF: `/OpenAction` и `/AA` сами по себе больше не `active_content`. Отказ дают `/JavaScript`, `/Launch` и `/JS` со значением.
18
+ - Новый код `content_limit` (`rejected`): файл слишком большой или сложный для разбора в пределах бюджета. Раньше такие случаи давали `archive_limit` или проходили без разбора.
19
+ - Сканеры содержимого без свободного места до срока возвращают `scanner_busy`, а не ставят файл в очередь.
20
+ - `StagedFile.discard()` при неудачном удалении поднимает `QuarantineDiscardError` и переходит в состояние, из которого повторный `discard()` пробует удалить файл снова.
21
+ - `open_for_copy()` во время уже идущего копирования поднимает `UsageError` (наследник прежнего `FileSecurityError`).
22
+ - `cleanup` при отсутствующем каталоге, символической ссылке, не каталоге или правах шире `0700` поднимает `ConfigurationError`, а команда `python -m file_security cleanup` выходит с кодом 2.
23
+ - `ClamAVScanner` и сканеры содержимого наследуют `OwnedScanner`: закрыть сканер, которым владеет цепочка, может только она.
24
+
25
+ ### Добавлено
26
+
27
+ - Свой лексер PDF на стандартной библиотеке: имена по разделителям спецификации, раскрытие `#xx`, перенос имени через границу кусков, распаковка потоков объектов с одним `FlateDecode` в пределах бюджета.
28
+ - Вид пакета Office по содержимому (`[Content_Types].xml`, `mimetype` ODF, контейнер OLE), а не по заявленному типу. Документ с макросами, заявленный как ZIP, проверяется как документ.
29
+ - Макросы: листы макросов Excel 4.0, типы макросов в `[Content_Types].xml`, ActiveX (`active_content`), записи `BoundSheet8` и `FilePass` в книге старого Excel.
30
+ - Таблица синонимов типов `MIME_SYNONYMS`: `application/x-zip-compressed`, `image/jpg`, `image/pjpeg`, `image/x-png`, `application/x-pdf`, `application/x-gzip`.
31
+ - Проверка прав сокета: сокет, в который может писать любой пользователь, и путь не на сокет — ошибка старта и критическая запись `doctor`.
32
+ - `default_scanner_chain(clamav, timeout=...)` — цепочка по умолчанию одной функцией.
33
+ - Коды `unverifiable_embedded_object` и `pdf_attachment`; модуль `scanners/embedded_objects.py` с разбором вида вложенного объекта и пределами повторного разбора.
34
+ - `await_despite_cancel` — ожидание, которое не теряет отмену и не снимает счётчик отмен задачи.
35
+ - Аргумент `signature_date_timezone` у `ClamAVScanner`.
36
+ - Разделы README: часовой пояс даты базы, синонимы типов, правило расширений, срок чтения синхронного входа. Файл SECURITY.md с моделью угроз.
37
+
38
+ ### Исправлено
39
+
40
+ - `doctor` читает список команд так, как его пишет `clamd`: одна строка, версия, затем `| COMMANDS:` и имена через пробел. Раньше из этой строки бралось первое слово `ClamAV`, и старт останавливался с «демон не объявил команду INSTREAM».
41
+ - Счётчик отмен задачи больше нигде не снимается. `Task.uncancel()` остался в одном месте и только в паре с `cancel()`: когда `asave_to` уже опубликовал путь, он возвращает этот путь и отдаёт отмену на следующем `await`, не меняя счётчик. Отмена, пришедшая одновременно с результатом работы, не теряется.
42
+ - Повреждённый пакет ZIP или контейнер OLE даёт отказ файла (`archive_invalid`, `office_invalid`), а не `scanner_failed`. `macros` и `nested_objects` больше не пропускают непрочитанный OLE как чистый.
43
+ - Сканеры содержимого читают файл окнами и проверяют срок по ходу чтения. Картинка больше предела разбора — `content_limit`.
44
+ - Слот сканера содержимого занимается до передачи работы в пул и освобождается рабочим потоком, поэтому отменённая проверка не освобождает его раньше времени.
45
+ - Владение сканером хранится по идентичности объекта: равные сканеры-датаклассы не сливаются, сканер без хеша допустим, брошенная цепочка освобождается сборщиком мусора.
46
+ - Признак служебного потока ставит инициализатор пула, а не имя потока.
47
+ - `doctor`: пробный файл со случайным именем, одна запись про root, текст предупреждения о базе называет настроенный порог, стандартные пути сокета не включают `/tmp`.
48
+ - Ввод-вывод асинхронного приёма вынесен из цикла событий. Служебные команды ClamAV ограничены своим сроком, ответ демона — 64 КиБ. Сбой `VERSION` не запоминается.
49
+ - Документация приведена в соответствие с кодом: место проверки `olefile`, полная сигнатура `FileSecurity`, предел потока демона, блокировки `open_for_copy`, бюджеты и коды сканеров.
file_security/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonid Kushkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.