Files
han-app/modules/module-05-message-safety.md
T

73 KiB
Raw Blame History

module-05. Проектная спецификация message-safety

Статус: нормативная постановка целевой production-реализации v2, готовая к разработке после прохождения Definition of Ready (§18). Текущий v1 stub остаётся test-only до отдельного cutover.
Канонические источники: README.md, arch-00-glossary.md, arch-01-system-architecture.md, arch-02-api-contracts.md, arch-03-docker-compose-blueprint.md, arch-04-settings-and-content.md, arch-05-agent-development-process.md, module-01-api-backend.md.

1. Назначение и приоритет

message-safety — внутренний сервис, который до отправки сообщения в Bitrix24 проверяет пользовательский текст, содержащиеся в нём ссылки и файлы из S3-quarantine.

Сервис закрывает угрозы, поступающие через пользовательское сообщение:

  • semantic prompt-injection конструкции, которые в MVP только наблюдаются и не блокируют сообщение;
  • опасные URL-схемы, URL с credentials и ссылки на private/link-local/metadata адреса;
  • HTML/script-like payloads, способные стать активным содержимым при небезопасном отображении;
  • подмену типа файла, несоответствие заявленного MIME фактическому формату и checksum;
  • вредоносные файлы, обнаруживаемые антивирусными сигнатурами.

Спецификация детализирует обновлённую двух-VM архитектуру. При конфликте приоритет имеют arch-00arch-06. Канонические domain outcomes v2:

  • 200 allow;
  • 403 deny;
  • 202 Accepted с Location/Retry-After и последующим sticky 200 или 403.

Test-only правила по первому символу, случайные verdict и terminal 400 stub_final_error в production-контракт не входят.

1.1. Владение решениями

Роль Ответственность
Product Owner бизнес-приёмка generic deny UX и мнемоники safety.chat.blocked
Safety Service Owner lifecycle v2, API/data contracts, service-owned config, capacity и cutover sign-off
Rule Pack Owner версия rules bundle, corpus, monitor rollout и release notes
Security Owner approval monitor → deny и config changes, ослабляющих policy; threat model, egress/secrets и risk acceptance
Operations Owner VM2, ClamAV signatures, alerts, rollback/reprovision и restore rehearsal

Один человек может выполнять несколько ролей, но для каждого production release роли и approvals должны быть записаны в release checklist.

2. Границы ответственности

2.1. Сервис отвечает за

  • строгую валидацию internal DTO;
  • нормализацию и rule-based проверку текста;
  • извлечение и local-only проверку ссылок с versioned link cache;
  • валидацию file metadata и фактического формата;
  • чтение файла из S3-quarantine по read-only credentials;
  • вычисление authoritative SHA-256;
  • антивирусную проверку файла через ClamAV;
  • выбор sync/async режима;
  • создание и исполнение async safety tasks;
  • sticky final verdict, verdict cache и audit в схеме message_safety;
  • versioned text-rules cache по hash analysis form без хранения текста;
  • PostgreSQL task queue/leases; Redis Safety только hot cache/rate/wakeup;
  • internal API, health, метрики, трассировку и безопасные JSON-логи.

2.2. Сервис не отвечает за

  • JWT пользователя, согласия и авторизацию доступа пользователя к диалогу;
  • edge/API rate limits;
  • загрузку файла и выдачу presigned URL;
  • запись пользовательского сообщения и статусов в han_app;
  • copy/promote файла из quarantine в S3-data и удаление объекта;
  • доставку в Bitrix24, realtime и пользовательский текст ошибки;
  • анализ входящих сообщений оператора;
  • ML-модерацию смысла, токсичности или правдивости текста.

Этими операциями владеет api-backend или соответствующий архитектурный модуль.

2.3. Аварийный режим MOCK

Режим предназначен для ручного controlled bypass при поломке или неприемлемой деградации полного pipeline.

Настройки Text result File result
MOCK=false стандартный pipeline этого документа стандартный pipeline этого документа
MOCK=true, TEXT_FREE=true, FILE_FREE=true forced 200 allow forced 200 allow
MOCK=true, TEXT_FREE=true, FILE_FREE=false forced 200 allow forced 403 deny
MOCK=true, TEXT_FREE=false, FILE_FREE=true forced 403 deny forced 200 allow
MOCK=true, TEXT_FREE=false, FILE_FREE=false forced 403 deny forced 403 deny

При MOCK=true не выполняются normalization/rules, URL extraction/DNS, S3 read/checksum/format/ClamAV, verdict caches, task creation и async worker. Service authentication, body-size/JSON/strict DTO validation, idempotency conflict protection, PostgreSQL audit и rate limits остаются обязательными controls.

Forced allow использует rule_id=safety.mock_forced_allow; forced deny — rule_id=safety.mock_forced_deny, reason_code=message_blocked. Mock никогда не возвращает 202. Internal response содержит processing_mode=mock; api-backend не раскрывает mode/rule клиенту. Mode фиксируется при первом принятии message_id: ранее созданный standard task/idempotency result не переклассифицируется и завершается в standard, а новый mock request не создаёт task. Это исключает смену verdict посередине обработки.

MOCK не включается HTTP endpoint-ом. Root-owned helper атомарно меняет защищённый config и перезапускает фиксированную Message Safety API operation внутри root Compose project; пользователь deploy может через sudo запускать только этот helper, без доступа к Docker/config:

sudo /usr/local/sbin/han-message-safety-mode standard
sudo /usr/local/sbin/han-message-safety-mode mock --text-free true|false --file-free true|false

Helper принимает только указанные enum/boolean arguments, не принимает пути/commands/env expansion, пишет /etc/han-chat/message-safety-mode.env как root:han-message-safety 0640 (dedicated GID 10001 совпадает с primary GID контейнера), проверяет config, выполняет restart и health verification. Ошибка включает rollback к предыдущему файлу. Ограничения по времени нет: MOCK действует до явного standard, но всё время формирует audit/метрики и active alert. Команда standard атомарно записывает MOCK=false, TEXT_FREE=false, FILE_FREE=false; скрыто сохранять предыдущие free flags запрещено.

3. Threat model MVP

3.1. Текст и ссылки

Угроза Контроль Результат
Prompt/control injection Версионированные RU/EN semantic rules 200 allow + monitor audit/metric
Попытка выдать текст за system/developer instruction Нормализация + monitor rule pack 200 allow + monitor audit/metric
Script/active-content payload Правила для script, event-handler и опасных embedding-конструкций 403 deny
Опасная URL-схема Разрешены только http и https для распознанных web URL 403 deny
URL с userinfo/credentials Запрет user:password@host 403 deny
SSRF-ссылка DNS/IP classification, запрет private, loopback, link-local, multicast, unspecified и metadata endpoints 403 deny
Известный phishing/malware URL Внешний reputation provider в MVP отсутствует; риск явно принят Вне покрытия MVP
Обход Unicode/whitespace NFKC, CRLF→LF, Unicode whitespace handling Проверка нормализованного текста
ReDoS/DoS правилами Линейные/ограниченные regex, лимиты текста, URL и времени invalid limits → 400; internal rule timeout → 500
SSRF через fetch содержимого Запрет HTTP GET/HEAD/render/redirect follow к пользовательским URL Не выполняется

Rules не заменяют безопасный rendering. Frontend и Bitrix integration обязаны экранировать текст; safety является дополнительным барьером, а не HTML sanitizer.

3.2. Файлы

Угроза Контроль Результат
Недопустимый размер/MIME Сверка DTO с allow-list и лимитами 403 deny
Подмена MIME Magic-byte/content sniffing, сверка declared MIME 403 deny
Подмена содержимого после complete Version-specific read + ETag + полный SHA-256 403 deny
Malware ClamAV scan актуальными сигнатурами 403 deny
Архивная бомба/ресурсное истощение Лимиты размера, stream scan, ClamAV limits/timeouts deny при policy hit; error при сбое
Polyglot/неоднозначный формат Строгий формат detector и deny при mismatch/ambiguity 403 deny
Повтор известного файла Cache по SHA-256 + versions Sticky cached verdict

api-backend применяет изменяемый бизнес allow-list han_app.app_settings:chat.attachments.*. message-safety не читает чужую схему han_app: он применяет active message_safety.config_versions.file_policy и immutable detector manifest §7.4. Эффективный allow — пересечение business allow-list, enabled MIME active safety config и форматов detector manifest; MIME/size в DTO должны пройти все слои. Config может только отключить MIME или ужесточить limits относительно manifest hard limits, но не добавить неподдерживаемый формат и не увеличить hard limit. Internal DTO не содержит имени файла, поэтому сервис не выводит расширение из object key.

3.3. Вне threat model MVP

  • zero-day malware, отсутствующий в сигнатурах и эвристиках выбранного AV;
  • OCR изображений и semantic analysis PDF;
  • password-protected/encrypted containers: в MVP они запрещаются, если содержимое нельзя полностью проверить;
  • DLP/поиск персональных данных, токсичности и запрещённой тематики;
  • загрузка HTML/ресурсов страницы по пользовательской ссылке (browser-like fetch, redirect follow, screenshot, headless render);
  • phishing/malware reputation URL без внешнего threat feed.

4. Общий pipeline

flowchart TD
  postCheck[POST_check] --> auth[Auth_DTO_Idempotency]
  auth --> mockMode{MOCK_enabled}
  mockMode -->|true| mockKind{content_kind}
  mockKind -->|text| mockText{TEXT_FREE}
  mockKind -->|file| mockFile{FILE_FREE}
  mockText -->|true| allow200[200_allow]
  mockText -->|false| deny403[403_deny]
  mockFile -->|true| allow200
  mockFile -->|false| deny403
  mockMode -->|false| kind{content_kind}
  kind -->|text| normalizeText[Normalize_text]
  normalizeText --> textCache{Text_rules_cache}
  textCache -->|deny_hit| deny403
  textCache -->|allow_or_monitor_hit| replayMonitor[Replay_monitor_audit]
  textCache -->|miss| textRules[Text_rules]
  textRules -->|deny| deny403
  textRules -->|allow_or_monitor| persistTextCache[Persist_text_rules_result]
  replayMonitor --> extractUrls[Extract_URLs]
  persistTextCache --> extractUrls
  extractUrls --> hasUrls{URLs_found}
  hasUrls -->|no| allow200
  hasUrls -->|yes| linkCache{Link_cache}
  linkCache -->|allow_hit| refreshDns[Refresh_DNS_if_expired]
  linkCache -->|deny_hit| deny403
  linkCache -->|miss| linkChecks[Local_URL_checks]
  refreshDns --> linkResult{Link_result}
  linkChecks --> dnsClassify[DNS_IP_classification]
  dnsClassify --> linkResult
  linkResult -->|allow_or_monitor| allow200
  linkResult -->|policy_deny| deny403
  linkResult -->|dependency_error| error503[503_dependency]
  kind -->|file| metadata[Metadata_validation]
  metadata --> cache{SHA256_cache}
  cache -->|hit| cached[Sticky_200_or_403]
  cached --> cachedFileResult{Cached_file_verdict}
  cachedFileResult -->|allow| allow200
  cachedFileResult -->|deny| deny403
  cache -->|miss| task[202_and_task]
  task --> worker[File_worker]
  worker --> objectRead[S3_stream_and_SHA256]
  objectRead --> formatCheck[Format_validation]
  formatCheck --> avScan[ClamAV_scan]
  avScan --> finalVerdict[Persist_sticky_verdict]
  finalVerdict --> taskGet[GET_task_200_or_403]

Приоритет:

  1. service authentication, body/content-type/size и DTO validation;
  2. idempotency/fingerprint conflict;
  3. mode snapshot: MOCK forced result либо standard pipeline;
  4. нормализация;
  5. обязательные проверки для соответствующего content_kind;
  6. любой deny имеет приоритет над allow;
  7. инфраструктурная ошибка не превращается ни в allow, ни в domain deny.

5. Текстовый pipeline

5.1. Нормализация

Pipeline детерминирован:

  1. принять только JSON UTF-8;
  2. заменить CRLF/CR на LF;
  3. Unicode normalization NFKC;
  4. построить analysis form: унифицировать Unicode whitespace, удалить/маркировать default-ignorable и zero-width controls, отдельно выявить bidi controls;
  5. построить TR39 confusable skeleton и mixed-script signal только для detection; mixed-script сам по себе не является deny;
  6. сохранить display form, исходный регистр и punctuation без изменения пользовательского текста;
  7. применить IDNA2008/UTS-46 non-transitional к hostname;
  8. применить абсолютный hard ceiling 10 000 Unicode code points; значение не может быть увеличено runtime-настройкой, даже если бизнес-лимит станет больше;
  9. вычислить SHA-256 analysis form для correlation/cache без хранения текста.

Превышение 10 000 code points после normalization → 400 validation_error, details.field=text. Flags default_ignorable, zero_width, bidi_control, mixed_script записываются только как bounded normalization_flags[] в audit/metrics и не влияют на verdict MVP.

Strict union проверяется повторно независимо от upstream:

  • content_kind=text требует непустой text и attachment=null;
  • content_kind=file требует text=="" и непустой attachment;
  • mixed/unknown shape → 400 validation_error до создания task или idempotency side effect.

5.2. Rule engine

Rules поставляются как статический read-only bundle app/rules/{rules_version}/rules.yaml, валидируемый committed JSON Schema. Динамический код, regex или rule definitions из запроса запрещены.

Каждое правило содержит:

  • стабильный rule_id;
  • reason_code;
  • severity;
  • scope (text, url, file_metadata);
  • action (deny | monitor);
  • rules_version;
  • тестовые positive/negative cases.

Нормативный каталог MVP:

rule_id Scope Action Условие
text.prompt_instruction_override text monitor RU/EN попытка переопределить инструкции
text.prompt_role_impersonation text monitor имитация system/developer/tool instruction
text.prompt_secret_extraction text monitor запрос внутренних инструкций/credentials
text.active_script text deny script tag, inline handler или active embedding
url.malformed url deny распознанная ссылка не проходит canonical parser
url.forbidden_scheme url deny scheme кроме HTTP/HTTPS
url.credentials_present url deny URL userinfo/credentials
url.confusable_host url deny malformed/confusable IDNA hostname
url.private_destination url deny literal/resolved private, loopback, link-local или metadata IP
url.reserved_destination url deny multicast, unspecified или reserved IP
url.nxdomain url monitor DNS NXDOMAIN; сообщение разрешается
file.unsupported_mime file_metadata deny MIME отсутствует в технической matrix
file.size_limit file_metadata deny размер превышает hard format limit
file.object_changed file_content deny version/ETag/size/checksum mismatch
file.format_mismatch file_content deny detector не совпал с declared MIME
file.polyglot_or_ambiguous file_content deny detector ambiguity/polyglot
file.encrypted_content file_content deny encrypted/password-protected container
file.active_content file_content deny PDF JavaScript/OpenAction/Launch/XFA/embedded
file.parser_limit file_content deny parser resource/decompression/object limit
file.malware_detected file_content deny ClamAV FOUND

Все domain deny используют reason_code=message_blocked; детализация остаётся во внутреннем rule_id. DTO/schema/authorization errors не получают rule_id.

Совпадение должно учитывать границы токенов и контекст, чтобы обычное обсуждение терминов не блокировалось простым substring match. Regex обязаны иметь ограниченную сложность и проходить ReDoS tests. Bundle содержит schema_version, rules_version, правила и embedded positive/negative vectors. Startup валидирует schema, уникальность rule_id, action/scope, компилирует patterns и запускает smoke vectors; любая ошибка завершает startup и даёт core not-ready.

monitor hit возвращает 200 allow, пишет только message_id, hash, rule_id, version и метрику, не попадает в sticky deny cache. Retention такого audit — 180 дней. Перевод semantic rule в deny требует минимум 30 дней monitor rollout, corpus tests, precision ≥99.5%, false-positive rate ≤0.5% и approvals Rule Pack Owner + Security Owner. Неподдерживаемый язык не получает semantic deny.

Обычный текст без срабатывания enforce-rules получает 200 allow. Неуверенное отсутствие совпадения не является deny. Ошибка rule engine является 500, но не allow.

5.3. Кэш текстовых правил

После normalization и до link pipeline сервис проверяет durable PostgreSQL + hot Redis cache:

text_rules:{sha256(analysis_form)}:{rules_version}

Cache хранит только результат text rule engine: allow|deny, deny rule_id, список monitor rule_id, normalization flags, rules_version, timestamps/expiry. Raw text не хранится.

  • deny hit сразу возвращает тот же 403;
  • allow/monitor hit пропускает повторный rule evaluation, но для каждого нового message_id повторно пишет monitor audit/metric;
  • после allow/monitor text-cache сервис выполняет URL extraction: при отсутствии ссылок сразу возвращает 200, при наличии запускает link pipeline. Текстовый cache не кэширует DNS/link verdict и не позволяет обойти fresh DNS;
  • transient/internal errors не кэшируются;
  • смена rules_version автоматически инвалидирует key.

Начальный TTL active config — 48 часов (cache.text_rule_ttl_sec=172800).

6. Pipeline ссылок

После успешной text-rule phase сервис извлекает URL Unicode-aware parser-ом, а не одним regex. Если URL не обнаружены, text check сразу завершается 200 allow; link pipeline не запускается. При наличии URL проверка выполняется синхронно и не создаёт отдельный async task.

6.1. Запрет перехода по ссылкам

Сервис никогда не выполняет переход по пользовательской ссылке для анализа. Запрещено:

  • HTTP/HTTPS GET, HEAD, POST и любые browser-like запросы к URL из сообщения;
  • следование redirects (301/302/meta refresh/JavaScript redirect chains);
  • загрузка HTML, JS, CSS, изображений или иных ресурсов страницы;
  • headless browser, screenshot, OCR или render страницы;
  • использование пользовательского URL как webhook/callback target внутри safety.

Допустимые исходящие сетевые вызовы для link pipeline ограничены:

  • DNS resolve через доверенный resolver (только A/AAAA, без произвольного TCP к порту URL);
  • служебные вызовы к PostgreSQL, Redis, OTLP и internal dependencies сервиса.

Это жёсткое архитектурное ограничение. Внешний reputation/threat-feed provider в MVP отсутствует; известные phishing/malware URL вне покрытия. Policy проверяется только по URL/host/DNS/IP metadata.

6.2. Кэш ссылок

По аналогии с file verdict cache каждая каноническая ссылка проверяется через durable/hot cache до повторного полного pipeline.

Cache key:

link_policy:{sha256(canonical_url)}:{rules_version}:{config_version}

Где canonical_url — результат нормализации (scheme/host/path/query без фрагмента #..., IDNA, lowercase host, согласованное кодирование). Полный URL в PostgreSQL/Redis/logs не хранится; для audit допустим hash и category.

link_verdict_cache (PostgreSQL) и hot cache Redis Safety хранят:

  • canonical URL hash;
  • sticky verdict (allow / deny);
  • rule_id, reason_code;
  • rules_version, config_version;
  • first_seen_at, last_seen_at, hit_count;
  • TTL/expiry.

Stable syntax/policy cache использует TTL active config (seed 48 часов). Он кэширует parsing, scheme, credentials, IDNA и literal-IP policy, но не разрешает обход свежей DNS classification. На stable allow hit при истёкшем DNS cache сервис повторяет A/AAAA lookup. DNS answers кэшируются отдельно не дольше фактического DNS TTL и active hard max (seed 900 секунд); NXDOMAIN negative cache — максимум active config (seed 60 секунд). Transient dependency errors в cache не попадают.

Инвалидация происходит при смене rules_version immutable bundle или active config_version.

6.3. Local URL checks

Для каждой ссылки без cache hit:

  1. ограничить сообщение до 5 URL, каждый не длиннее 2048 code points; превышение → 400 validation_error, details.field=urls;
  2. выполнить canonical parsing без автоматического исправления malformed URL;
  3. разрешить только http и https;
  4. запретить userinfo/credentials;
  5. нормализовать IDNA host и отклонить malformed/confusable host;
  6. для literal IP применить IP policy; IPv4-mapped IPv6 ::ffff:0:0/96 сначала нормализовать к IPv4;
  7. для hostname параллельно выполнить DNS resolve через configured trusted resolver и проверить все A/AAAA, включая mapped IPv6;
  8. запретить loopback, RFC1918/ULA, link-local, multicast, unspecified, reserved ranges и cloud metadata endpoints.

Malformed URL или policy hit → sync 403 deny с rule_id из local link rules. NXDOMAIN → 200 allow с monitor event url.nxdomain; resolver timeout/SERVFAIL → dependency error 503.

Metadata deny-list как минимум включает 169.254.169.254/32, fd00:ec2::254/128, 100.100.100.200/32 и соответствующие provider-specific addresses из versioned policy bundle. Literal и resolved IP проходят одну policy.

6.4. Timeout budget local-only pipeline

Общий POST budget остаётся 5 секунд:

  • normalization + text rules: до 1.5 с;
  • URL extraction/local parsing: до 0.5 с;
  • параллельные A/AAAA lookup: 1 с на lookup, до 2 с на весь link pipeline;
  • cache/DB/margin: оставшийся budget.

Превышение internal rule budget → 500; DNS dependency timeout → 503. Сервис не превращает timeout в allow или domain deny.

7. Файловый pipeline

Precondition production file flow: Selectel S3 spike подтверждает signed If-None-Match: *, checksum headers, versioning, чтение конкретного version_id и conditional source match при promote. До успешного gate file capability не включается.

7.1. Fast path POST

До создания задачи сервис:

  • валидирует attachment DTO;
  • проверяет допустимый MIME и size_bytes;
  • проверяет checksum sha256:<64 lowercase hex>;
  • проверяет canonical key quarantine/users/{user_uuid}/dialogs/{dialog_uuid}/{attachment_uuid}: lowercase RFC 4122 UUID, ASCII, длина ≤1024, без %, .., backslash и control characters;
  • требует quarantine_version_id и quarantine_etag, зафиксированные api-backend при complete;
  • ищет cache по (sha256, config_version, rules_version, detector_version, scanner_engine, signatures_version).

Явно недопустимые metadata дают sync 403 deny. Cache hit возвращает sticky 200 или 403. Cache miss создаёт задачу и возвращает 202 Accepted.

Если capability files=unavailable, file POST синхронно возвращает 503 dependency_unavailable, terminal=false, retryable=true и не создаёт task. Для text с URL аналогично действует links=unavailable; text без URL продолжает text-only pipeline.

Сервис не доверяет key как path и не строит произвольный URL. S3 client обращается только к configured quarantine bucket с virtual-hosted addressing и read-only credentials.

7.2. Worker

Worker:

  1. атомарно claim-ит pending task с lease;
  2. открывает ровно указанную S3 object version с conditional ETag match и ограничением bytes/time;
  3. вычисляет authoritative SHA-256;
  4. сверяет фактический размер и checksum с DTO;
  5. определяет реальный формат по содержимому;
  6. сверяет detector result с declared MIME;
  7. запрещает encrypted/password-protected и неподдерживаемые containers;
  8. передаёт поток в clamd через internal network;
  9. сохраняет sticky final verdict и audit;
  10. записывает cache только для terminal результата;
  11. завершает lease.

Чистый файл получает allow только если успешно завершились все обязательные проверки. ClamAV FOUND, checksum mismatch, format mismatch, unsupported encrypted content или policy limit дают deny с отдельным rule_id.

ClamAV timeout, protocol error, недоступность S3/DB/Redis или потеря lease не являются deny. Task остаётся pending/retryable в пределах deadline; после исчерпания retry получает terminal infrastructure failure, который API отдаёт как 503, а не 403.

7.3. AV runtime

MVP использует отдельный clamd sidecar/service в private Docker network:

  • порт не публикуется наружу;
  • сигнатуры обновляет freshclam;
  • readiness требует daemon PING и допустимый возраст signatures;
  • limits согласованы с максимальным размером файла;
  • контейнер non-root, read-only root filesystem где возможно, отдельный writable volume только для signatures/runtime;
  • worker не передаёт в clamd object key, имя пользователя или иные PII.

Недоступность AV переводит capability files в unavailable и запрещает новые file allow, но оставляет core/text readiness доступной.

7.4. Исполнимая матрица форматов

Declared MIME Обязательная проверка Limits Deny
image/jpeg JPEG magic + полный bounded decode ≤5 MiB, ≤25 MP, dimension ≤10000 truncation, decode error, polyglot
image/png PNG signature/chunks + полный bounded decode ≤5 MiB, ≤25 MP invalid chunks, decompression budget
image/webp RIFF/WEBP + bounded decode ≤5 MiB, ≤25 MP, ≤100 frames invalid/oversized animation
image/heic, image/heif ISO BMFF ftyp brand + libheif bounded decode ≤5 MiB, ≤25 MP, ≤100 items unknown brand, ambiguity
application/pdf %PDF-, bounded structural parser + EOF/xref validation ≤5 MiB, ≤500 pages, ≤100000 objects, ≤100 MiB decoded budget encryption/password, JavaScript, OpenAction, Launch, XFA, embedded files, malformed/polyglot

Parser выполняется с CPU/memory/wall-time limits в отдельном sandboxed subprocess. Detector ambiguity, resource limit и disagreement detector↔declared MIME дают соответствующий 403 rule_id. Immutable detector-manifest.json содержит bundle version, package/native library versions, supported MIME и hard limits; detector_version вычисляется как hash manifest, а не задаётся env. Active config ссылается на доступные rules_version/detector_version и может только сузить MIME/limits. Изменение manifest или active file policy инвалидирует file verdict cache через versioned cache key.

scanner_engine=clamav. signatures_version формируется из ClamAV engine version и CVD/CLD metadata/hash после успешного freshclam activate/reload; значение входит в verdict/cache/audit и readiness.

8. Internal API

Все /internal/safety/v2/* доступны только api-backend через private TLS gateway ВМ2.

Headers:

X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN}
X-Request-ID: UUID/ULID; при отсутствии генерируется сервисом
traceparent: optional W3C
Content-Type: application/json; charset=utf-8

Token сравнивается constant-time. Missing/invalid token → 401 service_unauthorized без подсказок.

8.1. POST /internal/safety/v2/messages/check

{
  "message_id": "uuid",
  "content_kind": "text",
  "text": "Текст сообщения",
  "attachment": null
}
{
  "message_id": "uuid",
  "content_kind": "file",
  "text": "",
  "attachment": {
    "attachment_id": "uuid",
    "quarantine_object_key": "quarantine/users/00000000-0000-4000-8000-000000000001/dialogs/00000000-0000-4000-8000-000000000002/00000000-0000-4000-8000-000000000003",
    "quarantine_version_id": "opaque-version-id",
    "quarantine_etag": "\"etag\"",
    "mime_type": "application/pdf",
    "size_bytes": 12345,
    "checksum": "sha256:<64-lowercase-hex>"
  }
}

rule_id и reason_code — internal diagnostic contract. api-backend не проксирует их клиенту и для любого domain deny использует public 422 message_blocked и мнемонику safety.chat.blocked.

Неизвестные поля запрещены. text и file — строгий discriminated union.

200:

{
  "verdict": "allow",
  "processing_mode": "standard",
  "config_version": 1,
  "rule_id": "safety.all_checks_passed",
  "rules_version": "2026-01-01"
}

expires_at = created_at + active_config.task_execution_deadline_sec; task сохраняет config_version, поэтому последующая активация config не меняет его deadline. Это terminal execution deadline, а не inline public wait budget.

403:

{
  "verdict": "deny",
  "processing_mode": "standard",
  "config_version": 1,
  "rule_id": "file.malware_detected",
  "reason_code": "message_blocked",
  "rules_version": "2026-01-01"
}

202 Accepted:

Headers:

Location: /internal/safety/v2/messages/tasks/{task_id}
Retry-After: 2
Cache-Control: no-store
{
  "verdict": "pending",
  "processing_mode": "standard",
  "config_version": 1,
  "task_id": "uuid",
  "poll_after_ms": 2000,
  "expires_at": "2026-07-29T15:00:00Z",
  "rules_version": "2026-01-01"
}

8.2. GET /internal/safety/v2/messages/tasks/{task_id}

  • running/retryable → 202 pending с тем же body, Location, Retry-After и expires_at;
  • sticky allow → 200 allow;
  • sticky deny → 403 deny;
  • terminal infrastructure failed → 503 task_failed с task_id, task_status=failed, terminal=true, retryable=false;
  • malformed UUID → 400 validation_error;
  • неизвестный task или task после retention expiry → 404 task_not_found;
  • transient dependency failure до terminal state → 503 dependency_unavailable, terminal=false, retryable=true.

При достижении expires_at без verdict task атомарно становится failed, а GET возвращает terminal 503 task_failed. После final verdict повторный GET возвращает тот же HTTP status, verdict, rule_id и rules_version. Task не удаляется до retention expiry.

8.3. Error envelope

{
  "error": {
    "code": "validation_error",
    "message": "Request is invalid",
    "request_id": "uuid",
    "details": {}
  }
}
HTTP Code/смысл Retry
400 malformed DTO/path нет без исправления
401 service_unauthorized нет без исправления secret
403 domain deny нет
404 task_not_found/expired только краткий reconciliation; затем terminal fail
409 safety_request_conflict нет
429 rate_limit_exceeded по Retry-After
500 internal_error да
503 dependency unavailable или terminal task_failed по error.details.retryable

Domain 403 не считается circuit breaker failure.

Обязательные error.details:

Code Fields
validation_error field, constraint без echo пользовательского значения
safety_request_conflict message_id, terminal=true, retryable=false
rate_limit_exceeded retryable=true, retry_after_sec
dependency_unavailable dependency_category, terminal=false, retryable=true
task_failed task_id, task_status=failed, terminal=true, retryable=false
internal_error terminal=false, retryable=true

8.4. MOCK responses

После auth/DTO/idempotency forced text/file allow возвращает sync 200:

{"verdict":"allow","processing_mode":"mock","config_version":1,"rule_id":"safety.mock_forced_allow","rules_version":"mock"}

Forced deny возвращает sync 403:

{"verdict":"deny","processing_mode":"mock","config_version":1,"rule_id":"safety.mock_forced_deny","reason_code":"message_blocked","rules_version":"mock"}

processing_mode и config_version обязательны во всех v2 verdict/pending responses и сохраняются в internal audit/checkpoint. Значение mock не доказывает прохождение safety controls.

9. Idempotency и state model

Fingerprint = SHA-256 от RFC 8785/JCS serialization нормализованного DTO без token/request-id/trace headers. Field order, null, nested attachment и Unicode test vectors входят в contract tests.

  • одинаковый (message_id, fingerprint) возвращает тот же активный task или sticky final verdict;
  • тот же message_id с другим fingerprint → 409 safety_request_conflict;
  • concurrent duplicate создаёт одну task;
  • final verdict изменять запрещено;
  • transient error не кэшируется как allow/deny;
  • повторный worker delivery безопасен через task state compare-and-set.

Task states:

pending -> processing -> allowed
                      -> denied
processing -> pending (retry with lease expiry)
processing -> failed  (infrastructure retries exhausted)

allowed, denied, failed terminal и sticky. failed не является safety deny.

Inline poll api-backend ограничен caller-параметром MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300 на ВМ1. Timeout/disconnect не отменяет task: han_app.safety_tasks recovery продолжает GET до terminal state или expires_at, что наступит раньше. Safety execution deadline берётся из snapshot active config (seed 1200 с); после него retry запрещён.

10. Хранение данных

10.1. PostgreSQL schema message_safety

Минимальный logical contract safety_tasks:

Поля Тип/constraint
id, message_id, attachment_id UUID; PK id, UNIQUE message_id; attachment required для file task
request_fingerprint, content_sha256 bytea(32), NOT NULL
processing_mode `standard
config_version bigint NOT NULL, FK → immutable config_versions.version
status `pending
attempt_count, lease_generation integer ≥0; generation увеличивается на каждый claim
lease_owner, lease_until, next_attempt_at nullable text/timestamptz
expires_at timestamptz NOT NULL
quarantine_object_key, quarantine_version_id, quarantine_etag text NOT NULL для file task
declared_mime, declared_size_bytes, declared_checksum bounded text/bigint, NOT NULL
verdict, rule_id, reason_code nullable до terminal; CHECK согласован со status
rules_version, detector_version, scanner_engine, signatures_version bounded text NOT NULL; snapshot фактически использованных artifacts
created_at, updated_at, finished_at, purge_after timestamptz; finished/purge_after только terminal

Обязательные индексы: UNIQUE (message_id), queue (status, next_attempt_at, created_at), lease recovery (status, lease_until), retention (finished_at). Final write использует WHERE lease_generation=:generation AND lease_owner=:owner.

file_verdict_cache: (content_sha256, config_version, rules_version, detector_version, scanner_engine, signatures_version) UNIQUE; verdict allow|deny, rule_id, reason_code, created_at, expires_at; transient/failed result запрещён constraint-ом.

text_rules_cache: (analysis_sha256, rules_version) UNIQUE; result allow|deny, nullable deny rule_id, bounded monitor rule ids/normalization flags, created_at, expires_at. Это cache только text rule phase, не полного сообщения и не link verdict.

link_verdict_cache: (canonical_url_sha256, rules_version, config_version) UNIQUE; только stable syntax/policy allow|deny, rule_id, reason_code, first/last seen, hit count, expiry. DNS answers и NXDOMAIN monitor не являются durable sticky verdict.

safety_audit: UUID PK, nullable request/message/task identifiers, event enum (received, task_created, rule_hit, rule_hit_monitor, scan_completed, dependency_failed, mock_forced_allow, mock_forced_deny, config_activated), processing_mode, config_version, verdict/rule/version, normalization flags, duration/error category, created_at, purge_after; индекс (purge_after) для retention и (message_id, created_at) для controlled review.

Service-owned runtime config хранится не как EAV, а версионированным документом:

config_versions: id UUID PK, version bigint UNIQUE, schema_version integer, state draft|active|retired, config jsonb, config_sha256 bytea(32), created_by/created_at, approved_by/approved_at, activated_at/retired_at. Partial UNIQUE допускает не более одной active, а activation transaction и readiness требуют ровно одну; trigger запрещает UPDATE config/config_sha256/schema_version/version, active/retired rows не удаляются, пока на них ссылаются tasks/cache/audit.

Activation выполняет отдельный migration/config-admin role транзакционно: JSON Schema + cross-field validation, проверка referenced rules/detector artifacts, advisory lock, retire прежней версии, activate новой и config_activated audit. Runtime DB role имеет только SELECT config и не может активировать policy. HTTP admin endpoint для config отсутствует.

API загружает active config при startup и обновляет snapshot с max staleness 5 с (poll/optional LISTEN/NOTIFY); отсутствие/невалидность active version делает core readiness 503. Каждый POST атомарно фиксирует одну config_version; worker читает immutable version задачи, поэтому activation влияет только на новые requests и не меняет in-flight verdict.

Raw message text, file bytes, filename, полный URL, service token и AV stream в PostgreSQL не сохраняются. Quarantine key/version/ETag хранятся только в active/final task row для исполнения/recovery и удаляются вместе с task retention; в audit/cache/logs они запрещены.

PostgreSQL — единственный task queue/lease source. Claim выполняется FOR UPDATE SKIP LOCKED/atomic UPDATE; lease/heartbeat/max attempts/deadline берутся из immutable config snapshot задачи (seed 90/30 с, 3, 1200 с), каждый claim увеличивает fencing generation. 5 параллельных file-worker slots — deployment capacity из env/Compose, не policy в БД. Worker с потерянным lease не может записать final state.

10.2. Redis Safety

Redis используется для:

  • hot text-rules cache;
  • hot verdict cache;
  • hot link verdict cache;
  • optional worker wake-up;
  • internal rate limits.

Redis находится на ВМ2 отдельным instance от DB0/DB1 ВМ1. Потеря Redis не меняет task/lease/sticky verdict и не делает core service not-ready: сервис читает PostgreSQL, временно теряет только cache/rate/wakeup acceleration.

Retention/TTL jobs используют active config для новых expiry и уже сохранённые expires_at для созданных rows: seed final task 30 дней, safety audit 180 дней, file verdict cache 30 дней, text-rules/stable link cache 48 часов. han_app checkpoint 7 дней и failed/orphan quarantine 48 часов принадлежат api-backend config. Deny-файл удаляет api-backend сразу best-effort. Raw text/URL/file в Safety не сохраняются.

11. Согласование с api-backend

POST check
200 -> allow -> text accepted or file promote -> Bitrix delivery
403 -> blocked/rejected -> no Bitrix -> public 422 message_blocked
202 -> persist han_app.safety_tasks -> poll Location
GET 202 -> continue
GET 200 -> allow branch
GET 403 -> deny branch
terminal task_failed -> stop poll, public 503, no promote, no Bitrix
timeout/retryable 5xx -> public 503/504, no promote, no Bitrix

message-safety не перемещает и не удаляет S3 object. На deny это идемпотентно делает api-backend. Клиент не получает internal 202: публичный POST продолжает ждать final result. Internal 409 safety_request_conflict считается нарушением инварианта caller, маппится api-backend в 500 internal_error + alert и не повторяет POST автоматически.

Public 422 содержит стандартный error envelope без internal rule_id/reason_code. api-backend создаёт локальную company-реплику с текстом из text_resources по единой мнемонике safety.chat.blocked; Message Safety не формирует пользовательский текст. Client timeout/disconnect не отменяет task: recovery продолжает poll до terminal state/expires_at.

При processing_mode=mock api-backend сохраняет mode в Message/audit. Forced file allow разрешает promote, но attachment получает scan_status=bypassed, а не clean; forced deny идёт по обычной deny-ветке. Mode не показывается пользователю в public DTO.

12. Security controls

  • private HTTPS через private listener nginx ВМ2; public routes для Message Safety отсутствуют;
  • unique service token только из env/secret mount;
  • strict DTO, body/text/URL/file limits и запрет unknown fields;
  • read-only S3-quarantine access, без list/write/delete;
  • запрет HTTP fetch/render/redirect follow к пользовательским URL; link pipeline использует только доверенный DNS;
  • egress по назначению: worker → DNS/S3/PostgreSQL, app → local Redis/OTLP/clamd; внешнего reputation API нет;
  • parameterized SQL и least-privilege DB role только на schema message_safety;
  • Redis Safety ACL только prefixes han:safety:*;
  • dependency pinning, SBOM/image scanning;
  • non-root, read-only root fs, tmpfs /tmp, dropped capabilities, no-new-privileges;
  • OpenAPI UI выключен в production;
  • безопасные generic errors без stack/internal addresses;
  • правила и AV signatures обновляются только trusted deployment process.
  • MOCK не имеет runtime/public API; mode file root-owned, а deploy имеет sudo только на argument-validating helper и approved restart.

13. Observability и privacy

JSON logs:

  • timestamp, level, service.name=message-safety, module, event;
  • request_id, trace_id, span_id, route, status, duration;
  • processing_mode, verdict, rule_id, rules/scanner/signatures version;
  • task state, attempt, age/size bucket;
  • dependency/error code.

Запрещено логировать message text, extracted URL целиком, file bytes, object key/name, checksum целиком, DTO body и service token. URL host и hash допустимы только при утверждённой retention policy; по умолчанию логируется category/hash.

Metrics:

  • request rate/latency/status по route;
  • checks/verdicts по content_kind, allow|deny|pending|error;
  • message_safety_mock_enabled gauge и forced outcomes по text|file/allow|deny;
  • rule hits по low-cardinality rule_id;
  • task queue/age/attempts/lease conflicts/timeouts;
  • scan latency/bytes buckets/AV outcome;
  • signatures age/version info;
  • cache hit/miss;
  • PostgreSQL/Redis/S3/ClamAV latency and errors;
  • active/used config_version, activation result и config refresh age;
  • auth rejects/rate limit/readiness.

Telemetry collector unavailable не влияет на safety verdict и readiness.

Переход mode пишет security audit с actor deploy, old/new flags, release, host, timestamp и helper result. При MOCK=true постоянно активен high-severity alert; каждое forced allow отдельно считается, чтобы оценить объём непроверенного контента.

14. Health

GET /health/live проверяет только process/event loop.

GET /health/ready возвращает core readiness и capability map:

  • ровно одна valid active config, доступность/verification referenced rules/detector artifacts и обязательных secrets;
  • PostgreSQL read/write в schema message_safety;
  • Redis Safety status как degraded accelerator, не core gate;
  • worker heartbeat/lease processing;
  • S3-quarantine Head/Get read permission на безопасный canary object;
  • ClamAV PING и допустимый возраст signatures;
  • trusted DNS resolver для links;
  • OpenAPI/runtime parity проверяется на startup/CI, а не сетевым probe каждого ready request.

Пример partial degradation (HTTP 200, core готов):

{
  "status": "degraded",
  "processing_mode": "standard",
  "config_version": 1,
  "components": {
    "postgres": "ok",
    "redis": "degraded",
    "s3_quarantine": "ok",
    "worker": "ok",
    "antivirus": "down",
    "dns": "ok",
    "rules": "ok"
  },
  "capabilities": {
    "text": "ready",
    "links": "ready",
    "files": "unavailable",
    "worker": "ready"
  }
}

Core HTTP 503 status=not_ready используется только для invalid config/rules/PostgreSQL. Потеря worker heartbeat делает worker=unavailable и files=unavailable, но сохраняет text/links; ClamAV/S3 down выключает только files; DNS down — только links; Redis down прогревается из PostgreSQL и не выключает core. Каждый POST повторно проверяет требуемую capability и остаётся источником correctness; api-backend может кэшировать health snapshot не дольше 5 с только для fast-fail. Health доступен только через private SG ВМ1/ops, не требует service token и не раскрывает credentials/hostnames.

В MOCK health всегда явно возвращает processing_mode=mock, mock_policy.text=allow|deny, mock_policy.file=allow|deny и status=degraded, даже если forced responses доступны. Normal pipeline dependencies показываются как bypassed, не ok. Active alert не закрывается до возврата в standard.

15. Configuration

Конфигурация разделена на четыре источника без дублирования:

  1. han_app.app_settings — business allow-list/размеры, применяемые api-backend;
  2. message_safety.config_versions — service-owned runtime policy;
  3. env/secret files — bootstrap, topology, credentials и deployment capacity;
  4. root-owned mode file — только emergency MOCK.

15.1. Seed active config в message_safety

schema_version: 1
rules_bundle_ref: rules-2026-01-01
detector_manifest_ref: detector-2026-08-03
task:
  file_scan_timeout_sec: 60
  lease_sec: 90
  heartbeat_sec: 30
  max_attempts: 3
  execution_deadline_sec: 1200
  max_pending: 100
rate:
  text_rps: 10
  file_rps: 2
retention:
  task_days: 30
  audit_days: 180
cache:
  file_verdict_ttl_sec: 2592000
  text_rule_ttl_sec: 172800
  link_ttl_sec: 172800
  dns_max_ttl_sec: 900
  dns_negative_ttl_sec: 60
link:
  max_per_message: 5
  url_max_length: 2048
  dns_lookup_timeout_sec: 1
  pipeline_timeout_sec: 2
clamav:
  scan_timeout_sec: 45
  max_signature_age_hours: 24
file_policy:
  enabled_mime_types:
    - image/jpeg
    - image/png
    - image/webp
    - image/heic
    - image/heif
    - application/pdf
  max_size_bytes: 5242880

JSON Schema задаёт типы/ranges и cross-field constraints: heartbeat_sec < lease_sec < execution_deadline_sec, scan/pipeline timeout не больше execution deadline, TTL/retention положительны. enabled_mime_types — непустое уникальное подмножество detector manifest; max_size_bytes и последующие format overrides не превышают hard limits manifest. Referenced rules/detector artifacts обязаны быть доступны и пройти hash/signature verification до activation.

15.2. Env и secrets Message Safety на ВМ2

APP_ENV=production-like
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
MESSAGE_SAFETY_CLAMAV_HOST=clamd
MESSAGE_SAFETY_CLAMAV_PORT=3310
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

Runtime secrets MESSAGE_SAFETY_DATABASE_URL, MESSAGE_SAFETY_REDIS_URL, MESSAGE_SAFETY_SERVICE_TOKEN, S3 read credentials и internal TLS key доставляются отдельными secret files. Bootstrap/topology env не переносятся в ту же БД, подключение к которой они обеспечивают.

MESSAGE_SAFETY_POST_TIMEOUT_SEC=5, MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2 и MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300 — caller env api-backend на ВМ1, не настройки Message Safety. Nginx timeout выводится из caller poll budget.

15.3. Emergency mode

MESSAGE_SAFETY_MOCK_ENABLED, MESSAGE_SAFETY_MOCK_TEXT_FREE, MESSAGE_SAFETY_MOCK_FILE_FREE читаются только из root-owned /etc/han-chat/message-safety-mode.env, не из repository .env и не из БД. Startup отклоняет placeholders, insecure production defaults, отсутствующий/невалидный active config и комбинацию MOCK=false при любом *_FREE=true.

15.4. Performance acceptance MVP

Availability SLO для MVP не утверждается. До cutover обязателен воспроизводимый load test:

Поток Нагрузка Gate
Text без URL/с URL sustained 10 checks/s, URL в 30% запросов p95 ≤2 с, p99 ≤5 с, error budget теста 0 для internal 500
File sustained 2 checks/s, 5 worker slots среднее processing ≤2.5 с, p95 final ≤60 с, p99/public wait ≤300 с
Pending/backpressure до 100 active tasks queue age p95 ≤5 с; при 100 новый file POST получает retryable 503 без task

File corpus: 70% JPEG/PNG/WebP до 1 MiB, 20% PDF/HEIC до 2 MiB, 10% boundary samples до 5 MiB; включает clean, EICAR, malformed, encrypted и parser-limit cases. Если 5 slots не подтверждают 2 file/s и среднее ≤2.5 с, Safety Service Owner до cutover увеличивает slots/CPU/scan lanes и повторяет тест.

Превышение text/file token bucket → 429 rate_limit_exceeded + Retry-After. Pending считается authoritative запросом PostgreSQL; Redis используется как быстрый счётчик. При Redis outage rate limiter использует conservative in-process limits, а pending gate остаётся в PostgreSQL.

16. OpenAPI

message-safety/openapi.yaml OpenAPI 3.1 обязателен и содержит:

  • X-Service-Token security scheme;
  • common request/trace headers;
  • strict discriminated union text/file;
  • обязательные internal processing_mode и config_version во всех verdict/pending responses;
  • exact POST responses 200/202/400/401/403/409/429/500/503;
  • exact GET responses 200/202/400/401/403/404/429/500/503;
  • domain deny отдельно от error envelope;
  • UUID/checksum/max length examples;
  • health contracts.

Runtime schema сравнивается с committed artifact contract test. Terminal 400 stub_final_error отсутствует.

17. Тестовая матрица

Unit

  • NFKC, CRLF, Unicode whitespace, max length;
  • positive/negative cases каждого text rule;
  • границы токенов и false-positive corpus;
  • ReDoS/time budget всех regex;
  • text-rules cache hit/miss/version invalidation и monitor replay без raw text;
  • URL parsing, IDNA, schemes, credentials и IP ranges IPv4/IPv6;
  • stable link policy cache и short DNS cache hit/miss/TTL/invalidation;
  • NXDOMAIN monitor allow, SERVFAIL/timeout 503, mapped IPv6/IP metadata deny;
  • DTO union, fingerprint и rule priority;
  • MIME/magic/checksum decision table;
  • sticky state transitions;
  • log redaction.

Integration

  • PostgreSQL migration/constraints/idempotency/audit;
  • config draft/activate/retire, concurrent activation lock, invalid/cross-field reject и runtime read-only denial;
  • in-flight task сохраняет прежнюю config version, новый request получает новую;
  • PostgreSQL claim/lease/fencing concurrency/recovery; Redis cache loss;
  • pending=100 backpressure, token buckets и 5 worker slots;
  • S3 versioned read, ETag/version changed/missing/timeout;
  • ClamAV clean, EICAR, FOUND, timeout, daemon down, stale signatures;
  • full SHA-256 and size mismatch;
  • duplicate concurrent request and worker retry;
  • dependency recovery without changed final verdict.

Contract

  • text allow and each deny category;
  • text cache allow без URL сразу возвращает 200; с URL запускает link pipeline; deny hit возвращает тот же rule;
  • local link cache hit, DNS private-address deny и DNS dependency timeout;
  • capability degradation: text without URL works while files/links unavailable;
  • explicit absence of HTTP fetch to user URLs in integration tests;
  • file cache hit, 202 then sticky 200, 202 then sticky 403, terminal failed 503;
  • no random/non-sticky outcomes;
  • malformed 400 never treated as deny;
  • auth missing/wrong/correct;
  • request-id and trace propagation;
  • config_version присутствует и совпадает в POST/GET/checkpoint/audit;
  • api-backend maps only domain 403 to public 422 message_blocked;
  • deny/timeout never calls Bitrix and never promotes quarantine;
  • OpenAPI runtime parity.

Security/failure

  • EICAR and safe corpus;
  • malformed/polyglot/encrypted files;
  • decompression and parser resource limits;
  • URL private/link-local/metadata/IPv4-mapped-IPv6 cases;
  • DNS rebinding simulation;
  • no raw text/token/URL/object key/checksum in logs/traces/errors;
  • S3 credentials cannot list/write/delete;
  • Redis ACL and PostgreSQL schema isolation;
  • telemetry outage does not affect verdict;
  • performance corpus and gates §15.4.

18. Definition of Ready и Definition of Done

18.1. Definition of Ready

  • роли §1.1 назначены в release/project checklist;
  • target OpenAPI v2, migrations design, config/rules JSON Schema и detector manifest reviewed;
  • Selectel S3 capability spike §7 пройден;
  • corpus positive/negative/EICAR/boundary утверждён Rule Pack Owner и Security Owner;
  • VM2 private DNS/TLS/SG/egress design reviewed Operations Owner;
  • root-owned MOCK helper, five exact sudo commands и audit/alert route reviewed Security/Operations Owner;
  • api-backend mapping, safety.chat.blocked, M8 redaction и company-replica contract согласованы;
  • implementation backlog §20 оценён, зависимости/порядок cutover назначены;
  • отсутствуют открытые TBD, меняющие DTO, verdict, retention, hard limits или security boundary.

18.2. Definition of Done

  • production v2 200/202/403 contract реализован без stub divergence;
  • MOCK 2×2 text/file matrix даёт только sync 200/403; тест доказывает отсутствие calls к rules/DNS/S3/ClamAV/cache/workers и сохранение auth/DTO/idempotency/audit/rate limits;
  • text-rules cache, text rules, link cache и URL pipeline покрывают threat model и false-positive corpus;
  • fetch/render/redirect follow к пользовательским URL отсутствует по design и тестам;
  • files проходят metadata, authoritative SHA-256, format detector и ClamAV;
  • final task verdict sticky и durable;
  • idempotency/concurrency/recovery доказаны тестами;
  • PG schema message_safety, Redis Safety и S3 version-specific read работают по least privilege;
  • versioned config activation/rollback, one-active invariant, immutable task snapshot и invalid-config readiness покрыты migration/integration tests;
  • cache versioned rules/scanner/signatures и не сохраняет transient errors;
  • api-backend mapping allow/deny/timeout проверен end-to-end;
  • mock mode сохраняется internal, не раскрывается public; forced file allow получает scan_status=bypassed, не clean;
  • public deny использует safety.chat.blocked и не раскрывает internal rule;
  • blocked/failed content не попадает в Bitrix и не promote-ится;
  • health/readiness и capability-specific POST отражают PG/Redis/S3/workers/ClamAV/DNS/rules;
  • performance acceptance §15.4 пройден на target sizing;
  • immutable/versioned S3 negative tests и conditional promote пройдены;
  • OpenAPI 3.1 и runtime parity зелёные;
  • logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
  • hardened containers запускаются без public port;
  • runbook описывает signature update, stale signatures, AV outage, retry и rollback.
  • deploy может выполнить все пять exact mode commands, но не читать/писать mode config и не получить Docker/general sudo; helper rollback, persistent MOCK alert и возврат в standard испытаны.

19. Размещение проекта и вынос на отдельную ВМ

19.1. Расположение репозитория

Production-проект Message Safety размещается вне codebase/backend/:

codebase/
  message-safety/          # целевой production-сервис (отдельный deployable)
    app/
    tests/
    openapi.yaml
    Dockerfile
    docker-compose.yml       # service include root Compose ВМ2
  backend/
    message-safety/          # текущая test stub; подлежит замене/удалению после миграции

Stub в codebase/backend/message-safety/ не является целевой реализацией. После готовности production-сервиса stub исключается из compose HAN_CHAT и удаляется или архивируется отдельной задачей.

19.2. Целевая топология

Message Safety и bitrix-sync выносятся за пределы ВМ HAN_CHAT (ВМ1) на самостоятельную ВМ2 Processing в той же private network/VPC. Цель — снять с ВМ1 ClamAV/file scan, DNS classification, S3 streaming, CRM sync и входящий CRM webhook. Message Safety остаётся private API; Bitrix24 обращается напрямую к отдельному public host nginx ВМ2.

flowchart LR
  subgraph vm1 [VM1_HAN_CHAT]
    nginx[nginx]
    api[api-backend]
    redis1[Redis_DB0_DB1]
    appDbRef[(han_app_checkpoint_reference)]
  end
  subgraph vm2 [VM2_Processing]
    gateway[VM2_nginx_public_private]
    safety[message-safety_api_worker]
    sync[bitrix-sync]
    clamav[clamd]
    redis2[Redis_Safety]
    collector[otel-collector]
  end
  managedPg[(Managed_PostgreSQL)]
  s3q[(S3_quarantine)]
  bitrix[Bitrix24]
  signoz[Private_SigNoz]
  api -->|"HTTPS 8443 + service token"| gateway
  bitrix -->|"CRM webhook HTTPS 443"| gateway
  api --> appDbRef
  gateway --> safety
  gateway --> sync
  safety --> redis2
  safety --> managedPg
  safety --> s3q
  safety --> clamav
  sync --> managedPg
  sync --> bitrix
  collector --> signoz

На ВМ2 один root Compose включает собственный nginx с public webhook 443 и private 8443, message-safety API/worker, clamd/freshclam, bitrix-sync, Redis Safety и local OTEL Collector. Managed PostgreSQL и S3 остаются вне VM. Внешнего URL reputation provider нет.

19.3. Исключение из docker compose HAN_CHAT

Сервисы message-safety и bitrix-sync удаляются из root Compose ВМ1:

  • нет их build/image на ВМ1;
  • нет depends_on: message-safety health gate у api-backend на локальный контейнер;
  • вместо этого api-backend обращается к remote URL через private network.

api-backend не делает remote Safety обязательным для общей read readiness. Send path читает capability status и fail-closed возвращает dependency error только для требуемой проверки.

19.4. Затронутые компоненты (impact analysis)

Компонент Текущее состояние Изменение при выносе
api-backend MESSAGE_SAFETY_URL=http://message-safety:8080 URL → https://processing.internal:8443, internal CA + token; сохраняет processing_mode, file MOCK allow → scan_status=bypassed
han_app.safety_tasks checkpoint poll/recovery в App DB таблица остаётся владельцем checkpoint; migration добавляет Location/version/ETag/rules/last-status fields, новой queue table нет
message_safety schema в managed PG остаётся; подключение с ВМ2 по TLS/private network
Redis DB2 на ВМ1 compose отдельный Redis Safety ВМ2; PG task queue остаётся authoritative
S3-quarantine read key использовался локальным контейнером credentials монтируются на ВМ2; firewall allow S3 endpoint
nginx только edge ВМ1 самостоятельный nginx ВМ2: public exact CRM webhook на 443 и private Message Safety на 8443
Observability collector ВМ1 local collector ВМ2 → private SigNoz
Deployment/runbook compose all-in-one два root Compose/systemd stack, cutover/rollback и reprovision ВМ2
arch-03 один Compose ВМ1 один root Compose на каждой VM
arch-04 local Docker URLs remote HTTPS URL ВМ1; service-specific env/secrets ВМ2
CI/CD build из backend tree отдельный pipeline artifact codebase/message-safety
Security groups intra-docker network internet→nginx ВМ2:80/443 с exact route policy; ВМ1→ВМ2:8443; ВМ2→PG/S3/DNS/Bitrix/SigNoz/signature CDN по назначению

Схема han_app не получает новых таблиц для remote safety: orchestration и recovery по-прежнему владеет api-backend. Меняется только сетевой адрес internal API и deployment boundary.

19.5. Сетевые и эксплуатационные требования ВМ2

  • только private IP; публичный ingress запрещён;
  • inbound: private TCP 8443 только с SG ВМ1/ops; server-auth TLS internal CA + service token;
  • outbound по container identity: worker→PostgreSQL/S3/DNS; freshclam→signature CDN; bitrix-sync→Bitrix24; collector→SigNoz;
  • health /health/live и /health/ready доступны api-backend и ops из private network;
  • при недоступности ВМ2 api-backend возвращает 503 dependency_unavailable, не отправляет сообщения в Bitrix.

ВМ2 — принятый SPOF MVP. Initial sizing: 4 vCPU, 8 GiB RAM, 80 GiB SSD. Меры: immutable images, PG/PITR durable truth, rebuildable Redis, resource/PID limits, queue backpressure, alerts, reprovision/restore rehearsal и RTO ≤4h. Scale-out/ВМ3 рассматриваются при sustained CPU/RAM >70%, queue age >30 с, провале performance gates §15.4, contention bitrix-sync или необходимости независимого release cadence.

20. Implementation backlog и переход с v1

Текущая реализация в codebase/backend/message-safety/ остаётся test stub и не соответствует этой постановке. Реализация разбивается на reviewable tracks:

  1. API/schema foundation — production project, OpenAPI v2, versioned config schema/activation, settings validation, migrations и error contracts.
  2. Tasks/queue — PostgreSQL claim/lease/fencing/deadline, 5 slots, recovery и Redis wakeup/cache.
  3. Text/rules — normalization, versioned bundle/schema, monitor corpus и hard active-content deny.
  4. URL policy — parser/IDNA/IP/DNS, cache split, NXDOMAIN monitor и запрет fetch.
  5. File/S3 — immutable version precondition, authoritative checksum, detector manifest и hard matrix.
  6. ClamAV — clamd streaming, freshclam activate/rollback/signature age и EICAR tests.
  7. api-backend integration — v2 adapter, Location polling, M8/company replica/mnemonic, conditional promote.
  8. VM2/observability/MOCK — root Compose, internal nginx/TLS, egress, local collector, root-owned five-command mode helper, persistent alert и dashboards.
  9. Acceptance — contract/security/failure/load tests §1518 и S3 negative gate.
  10. Cutover — route v1→v2, reconcile active tasks, rollback rehearsal, затем удалить stub/references.

Каждый track имеет owner role, dependencies, tests и ссылку на §18. Merge track не означает production enable; cutover разрешён только после полного DoD.

До выполнения перехода контейнер stub на ВМ1 должен быть явно маркирован как non-production security control и не использоваться как целевой safety layer.