flowreassemble 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowreassemble-0.1.0/.github/build-libpcap.sh +21 -0
- flowreassemble-0.1.0/.github/workflows/ci.yml +24 -0
- flowreassemble-0.1.0/.github/workflows/release.yml +165 -0
- flowreassemble-0.1.0/.gitignore +9 -0
- flowreassemble-0.1.0/CLAUDE.md +252 -0
- flowreassemble-0.1.0/Cargo.lock +336 -0
- flowreassemble-0.1.0/Cargo.toml +20 -0
- flowreassemble-0.1.0/LICENSE +28 -0
- flowreassemble-0.1.0/Makefile +29 -0
- flowreassemble-0.1.0/PKG-INFO +294 -0
- flowreassemble-0.1.0/README.md +280 -0
- flowreassemble-0.1.0/build.rs +68 -0
- flowreassemble-0.1.0/flake.lock +27 -0
- flowreassemble-0.1.0/flake.nix +30 -0
- flowreassemble-0.1.0/pyproject.toml +31 -0
- flowreassemble-0.1.0/python/flowreassemble/__init__.py +33 -0
- flowreassemble-0.1.0/python/flowreassemble/_conversations.py +175 -0
- flowreassemble-0.1.0/python/flowreassemble/_native.pyi +34 -0
- flowreassemble-0.1.0/python/flowreassemble/_reassemble.py +177 -0
- flowreassemble-0.1.0/python/flowreassemble/htp.py +238 -0
- flowreassemble-0.1.0/python/flowreassemble/pcap.py +26 -0
- flowreassemble-0.1.0/python/flowreassemble/py.typed +0 -0
- flowreassemble-0.1.0/src/decode.rs +314 -0
- flowreassemble-0.1.0/src/flows.rs +577 -0
- flowreassemble-0.1.0/src/htp_shim.c +145 -0
- flowreassemble-0.1.0/src/http.rs +662 -0
- flowreassemble-0.1.0/src/lib.rs +359 -0
- flowreassemble-0.1.0/src/pcap_handle.rs +285 -0
- flowreassemble-0.1.0/src/segments.rs +158 -0
- flowreassemble-0.1.0/tests/packets.py +189 -0
- flowreassemble-0.1.0/tests/test_conversations.py +59 -0
- flowreassemble-0.1.0/tests/test_http.py +171 -0
- flowreassemble-0.1.0/tests/test_pcap.py +128 -0
- flowreassemble-0.1.0/tests/test_tcp.py +271 -0
- flowreassemble-0.1.0/tests/test_udp.py +34 -0
- flowreassemble-0.1.0/uv.lock +188 -0
- flowreassemble-0.1.0/vendor/libhtp/AUTHORS +6 -0
- flowreassemble-0.1.0/vendor/libhtp/LICENSE +30 -0
- flowreassemble-0.1.0/vendor/libhtp/README.md +16 -0
- flowreassemble-0.1.0/vendor/libhtp/VERSION +2 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/bstr.c +638 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/bstr.h +678 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/bstr_builder.c +121 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/bstr_builder.h +136 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp.h +690 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_base64.c +196 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_base64.h +74 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_config.c +962 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_config.h +727 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_config_auto.h +51 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_config_auto_gen.h +6 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_config_private.h +376 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection.c +168 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_parser.c +260 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_parser.h +218 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_parser_private.h +275 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_private.h +121 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_content_handlers.c +299 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_cookies.c +119 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_core.h +408 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_decompressors.c +495 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_decompressors.h +94 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_hooks.c +160 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_hooks.h +122 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_list.c +360 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_list.h +227 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_list_private.h +73 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_multipart.c +1615 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_multipart.h +345 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_multipart_private.h +203 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_parsers.c +214 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_php.c +116 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_private.h +269 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_request.c +1174 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_request_apache_2_2.c +64 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_request_generic.c +478 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_request_parsers.c +149 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_response.c +1450 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_response_generic.c +344 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_table.c +250 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_table.h +184 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_table_private.h +78 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_transaction.c +1564 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_transaction.h +529 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_transcoder.c +211 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_urlencoded.c +332 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_urlencoded.h +111 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_utf8_decoder.c +118 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_utf8_decoder.h +85 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_util.c +2608 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/htp_version.h +53 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/7zTypes.h +375 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/Compiler.h +33 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzFind.c +1127 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzFind.h +121 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzHash.h +57 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzmaDec.c +1223 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzmaDec.h +234 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/lzma/Precomp.h +10 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/strlcat.c +76 -0
- flowreassemble-0.1.0/vendor/libhtp/htp/strlcpy.c +72 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Builds a private, static libpcap for the Linux wheels (run inside the
|
|
3
|
+
# manylinux container, as root). Only the plain Linux capture backend is
|
|
4
|
+
# built, so the wheel depends on nothing but libc.
|
|
5
|
+
set -eu
|
|
6
|
+
|
|
7
|
+
VERSION=1.10.7
|
|
8
|
+
SHA256=68fa62cffb974f4275641ce14c2e2d75739251f30e00e6a0900903b247d76a03
|
|
9
|
+
|
|
10
|
+
dnf install -y -q flex bison
|
|
11
|
+
cd "$(mktemp -d)"
|
|
12
|
+
curl -sSfLO "https://www.tcpdump.org/release/libpcap-$VERSION.tar.xz"
|
|
13
|
+
echo "$SHA256 libpcap-$VERSION.tar.xz" | sha256sum -c -
|
|
14
|
+
tar xf "libpcap-$VERSION.tar.xz"
|
|
15
|
+
cd "libpcap-$VERSION"
|
|
16
|
+
# In /usr, so pkg-config finds it without extra environment.
|
|
17
|
+
CFLAGS="-O2 -fPIC" ./configure --prefix=/usr --libdir=/usr/lib64 \
|
|
18
|
+
--disable-shared --disable-usb --disable-netmap --disable-bluetooth \
|
|
19
|
+
--disable-dbus --disable-rdma --without-libnl --without-dpdk
|
|
20
|
+
make -j"$(nproc)"
|
|
21
|
+
make install
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: Lint and test (${{ matrix.os }})
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
os: [ubuntu-latest, macos-latest]
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v5
|
|
18
|
+
- uses: cachix/install-nix-action@v31
|
|
19
|
+
- name: Set up the dev environment
|
|
20
|
+
run: nix develop -c make dev
|
|
21
|
+
- name: Lint
|
|
22
|
+
run: nix develop -c make lint
|
|
23
|
+
- name: Test
|
|
24
|
+
run: nix develop -c make test
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# On a version tag (v0.1.0): build the wheels and the sdist, test the wheels
|
|
4
|
+
# on clean systems, then publish a GitHub release and upload to PyPI.
|
|
5
|
+
# Other triggers only build and test.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
tags: ["v*"]
|
|
9
|
+
pull_request:
|
|
10
|
+
paths:
|
|
11
|
+
- ".github/**"
|
|
12
|
+
- "build.rs"
|
|
13
|
+
- "Cargo.*"
|
|
14
|
+
- "pyproject.toml"
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
linux:
|
|
22
|
+
name: Wheel (Linux ${{ matrix.arch }})
|
|
23
|
+
strategy:
|
|
24
|
+
matrix:
|
|
25
|
+
include:
|
|
26
|
+
- { runner: ubuntu-latest, arch: x86_64 }
|
|
27
|
+
- { runner: ubuntu-24.04-arm, arch: aarch64 }
|
|
28
|
+
runs-on: ${{ matrix.runner }}
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v5
|
|
31
|
+
- uses: PyO3/maturin-action@v1
|
|
32
|
+
with:
|
|
33
|
+
container: quay.io/pypa/manylinux_2_28_${{ matrix.arch }}
|
|
34
|
+
manylinux: "2_28"
|
|
35
|
+
# A static libpcap, linked into the extension.
|
|
36
|
+
before-script-linux: sh .github/build-libpcap.sh
|
|
37
|
+
args: --release --out dist
|
|
38
|
+
- uses: actions/upload-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: wheels-linux-${{ matrix.arch }}
|
|
41
|
+
path: dist
|
|
42
|
+
|
|
43
|
+
macos:
|
|
44
|
+
name: Wheel (macOS universal2)
|
|
45
|
+
runs-on: macos-latest
|
|
46
|
+
env:
|
|
47
|
+
MACOSX_DEPLOYMENT_TARGET: "11.0"
|
|
48
|
+
# Link the system libpcap, never a Homebrew one found by pkg-config.
|
|
49
|
+
PKG_CONFIG: /usr/bin/false
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v5
|
|
52
|
+
- uses: PyO3/maturin-action@v1
|
|
53
|
+
with:
|
|
54
|
+
target: universal2-apple-darwin
|
|
55
|
+
args: --release --out dist
|
|
56
|
+
- name: Check that only system libraries are linked
|
|
57
|
+
run: |
|
|
58
|
+
unzip -q -o dist/*.whl -d unpacked
|
|
59
|
+
otool -L unpacked/flowreassemble/_native.abi3.so | tee libs.txt
|
|
60
|
+
# Library lines are indented; both architectures are listed, each
|
|
61
|
+
# starting with the extension's own install name.
|
|
62
|
+
if grep -E '^\s' libs.txt | grep -v -E '^\s+(/usr/lib/|/System/Library/|@rpath/flowreassemble\._native\.abi3\.so )'; then
|
|
63
|
+
echo "the extension links a library that is not part of macOS"
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
- uses: actions/upload-artifact@v4
|
|
67
|
+
with:
|
|
68
|
+
name: wheels-macos-universal2
|
|
69
|
+
path: dist
|
|
70
|
+
|
|
71
|
+
sdist:
|
|
72
|
+
name: Source distribution
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
steps:
|
|
75
|
+
- uses: actions/checkout@v5
|
|
76
|
+
- uses: PyO3/maturin-action@v1
|
|
77
|
+
with:
|
|
78
|
+
command: sdist
|
|
79
|
+
args: --out dist
|
|
80
|
+
- uses: actions/upload-artifact@v4
|
|
81
|
+
with:
|
|
82
|
+
name: sdist
|
|
83
|
+
path: dist
|
|
84
|
+
|
|
85
|
+
test-linux:
|
|
86
|
+
name: Test wheel (Linux ${{ matrix.arch }}, Python ${{ matrix.python }})
|
|
87
|
+
needs: linux
|
|
88
|
+
strategy:
|
|
89
|
+
fail-fast: false
|
|
90
|
+
matrix:
|
|
91
|
+
arch: [x86_64, aarch64]
|
|
92
|
+
python: ["3.10", "3.14"]
|
|
93
|
+
runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
|
94
|
+
# A bare system: no libpcap, no compiler.
|
|
95
|
+
container: python:${{ matrix.python }}-slim
|
|
96
|
+
steps:
|
|
97
|
+
- uses: actions/checkout@v5
|
|
98
|
+
- uses: actions/download-artifact@v4
|
|
99
|
+
with:
|
|
100
|
+
name: wheels-linux-${{ matrix.arch }}
|
|
101
|
+
path: dist
|
|
102
|
+
- run: pip install --root-user-action=ignore dist/*.whl pytest
|
|
103
|
+
- run: python -m pytest
|
|
104
|
+
|
|
105
|
+
test-macos:
|
|
106
|
+
name: Test wheel (${{ matrix.runner }}, Python ${{ matrix.python }})
|
|
107
|
+
needs: macos
|
|
108
|
+
strategy:
|
|
109
|
+
fail-fast: false
|
|
110
|
+
matrix:
|
|
111
|
+
runner: [macos-latest, macos-15-intel]
|
|
112
|
+
python: ["3.10", "3.14"]
|
|
113
|
+
runs-on: ${{ matrix.runner }}
|
|
114
|
+
steps:
|
|
115
|
+
- uses: actions/checkout@v5
|
|
116
|
+
- uses: actions/setup-python@v6
|
|
117
|
+
with:
|
|
118
|
+
python-version: ${{ matrix.python }}
|
|
119
|
+
- uses: actions/download-artifact@v4
|
|
120
|
+
with:
|
|
121
|
+
name: wheels-macos-universal2
|
|
122
|
+
path: dist
|
|
123
|
+
- run: pip install dist/*.whl pytest
|
|
124
|
+
- run: python -m pytest
|
|
125
|
+
|
|
126
|
+
github-release:
|
|
127
|
+
name: GitHub release
|
|
128
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
129
|
+
needs: [sdist, test-linux, test-macos]
|
|
130
|
+
runs-on: ubuntu-latest
|
|
131
|
+
permissions:
|
|
132
|
+
contents: write
|
|
133
|
+
steps:
|
|
134
|
+
- uses: actions/checkout@v5
|
|
135
|
+
- name: Check that the tag matches the package version
|
|
136
|
+
run: |
|
|
137
|
+
version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
|
|
138
|
+
test "${GITHUB_REF_NAME}" = "v${version}" || {
|
|
139
|
+
echo "tag ${GITHUB_REF_NAME} does not match version ${version} in pyproject.toml"
|
|
140
|
+
exit 1
|
|
141
|
+
}
|
|
142
|
+
grep -q "^version = \"${version}\"" Cargo.toml
|
|
143
|
+
- uses: actions/download-artifact@v4
|
|
144
|
+
with:
|
|
145
|
+
path: dist
|
|
146
|
+
merge-multiple: true
|
|
147
|
+
- run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes
|
|
148
|
+
env:
|
|
149
|
+
GH_TOKEN: ${{ github.token }}
|
|
150
|
+
|
|
151
|
+
pypi:
|
|
152
|
+
name: Publish to PyPI
|
|
153
|
+
needs: github-release
|
|
154
|
+
runs-on: ubuntu-latest
|
|
155
|
+
environment:
|
|
156
|
+
name: pypi
|
|
157
|
+
url: https://pypi.org/p/flowreassemble
|
|
158
|
+
permissions:
|
|
159
|
+
id-token: write
|
|
160
|
+
steps:
|
|
161
|
+
- uses: actions/download-artifact@v4
|
|
162
|
+
with:
|
|
163
|
+
path: dist
|
|
164
|
+
merge-multiple: true
|
|
165
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Early development. TCP/UDP reassembly, `reassemble()`, `conversations()`, the
|
|
8
|
+
`pcap` submodule, HTTP through libhtp (`htp` submodule, `http=` option), CI and
|
|
9
|
+
release wheels work. Not done yet: fuzzing the HTTP path, and tests of live
|
|
10
|
+
capture (they need capture privileges).
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
Everything runs inside the Nix dev shell, which provides Rust, maturin, uv,
|
|
15
|
+
Python 3.12, libpcap, zlib and pkg-config: `nix develop` (or prefix any command
|
|
16
|
+
with `nix develop -c`).
|
|
17
|
+
|
|
18
|
+
- `make dev`: create `.venv` (uv) and install the package in editable mode.
|
|
19
|
+
- `make test`: Rust unit tests, then rebuild and run pytest.
|
|
20
|
+
- `make lint` / `make format`: rustfmt, clippy (`-D warnings`), ruff.
|
|
21
|
+
- `make build`: release wheel (abi3, one wheel for Python 3.10+).
|
|
22
|
+
- Single Python test: `uv run --no-sync pytest tests/test_tcp.py::test_rst_closes_the_flow`
|
|
23
|
+
(run `maturin develop --uv` first if Rust code changed).
|
|
24
|
+
- Single Rust test: `cargo test segments::tests::covers_detects_holes`.
|
|
25
|
+
|
|
26
|
+
## CI and releases
|
|
27
|
+
|
|
28
|
+
GitHub Actions (`.github/workflows/`). `ci.yml` runs `make lint` and `make
|
|
29
|
+
test` in the Nix dev shell on Linux and macOS. `release.yml` builds the wheels
|
|
30
|
+
(manylinux_2_28 x86_64 and aarch64, macOS universal2) and the sdist, then
|
|
31
|
+
installs each wheel on a bare system (a `python:*-slim` container on Linux)
|
|
32
|
+
and runs pytest against it. On a `v*` tag it also creates the GitHub release
|
|
33
|
+
with those files and uploads them to PyPI (trusted publishing, environment
|
|
34
|
+
`pypi`).
|
|
35
|
+
|
|
36
|
+
To release: set the same version in `pyproject.toml` and `Cargo.toml`, commit,
|
|
37
|
+
then tag `vX.Y.Z` and push the tag. The release job fails if the tag and the
|
|
38
|
+
version differ.
|
|
39
|
+
|
|
40
|
+
Linux wheels link a private, static libpcap built by
|
|
41
|
+
`.github/build-libpcap.sh` (pinned version and checksum, only the plain Linux
|
|
42
|
+
capture backend, so no RDMA, D-Bus or libnl dependencies). macOS wheels link
|
|
43
|
+
the system libpcap (`PKG_CONFIG=/usr/bin/false` keeps a Homebrew one out), and
|
|
44
|
+
the job checks with `otool` that only system libraries are linked.
|
|
45
|
+
|
|
46
|
+
## Architecture
|
|
47
|
+
|
|
48
|
+
Two layers: a Rust engine (`src/`, compiled as `flowreassemble._native`) and
|
|
49
|
+
the public Python API (`python/flowreassemble/`).
|
|
50
|
+
|
|
51
|
+
- `src/decode.rs`: frame → TCP/UDP packet. Link layers (Ethernet, SLL, SLL2,
|
|
52
|
+
raw, null/loop), VLAN/QinQ, MPLS, PPPoE, GRE, VXLAN, IP-in-IP, IPv6
|
|
53
|
+
extension headers, IPv4/IPv6 defragmentation. Checksums are never checked.
|
|
54
|
+
- `src/segments.rs`: out-of-order byte ranges with first-copy-wins overlaps,
|
|
55
|
+
shared by defragmentation and TCP.
|
|
56
|
+
- `src/flows.rs`: flow table and TCP/UDP reassembly; emits `Event`s. It holds
|
|
57
|
+
the rules: picking the client (handshake, then `server=` hint, then lower
|
|
58
|
+
port), a packet's ACK applied before its payload (so released data keeps
|
|
59
|
+
its order), port reuse (a SYN whose sequence number differs from the
|
|
60
|
+
connection's ISN), gaps (skipped when the receiver ACKs past buffered data, when more
|
|
61
|
+
than 16 MiB is buffered, or at close), overlaps, idle timeout,
|
|
62
|
+
and closing everything at end of input. A FIN marks the end of its
|
|
63
|
+
direction's stream: an ACK reaching it proves the bytes before it, and
|
|
64
|
+
nothing past it is delivered. After a FIN/RST close, the connection's
|
|
65
|
+
sequence numbers are kept for 120 s (`Linger`), to drop its late packets
|
|
66
|
+
while still seeing a new connection on the same 4-tuple.
|
|
67
|
+
Encapsulation depth (`MAX_DEPTH` in `decode.rs`) also counts nested IP
|
|
68
|
+
fragment reassemblies; keep every recursive decode path bounded by it.
|
|
69
|
+
- `src/pcap_handle.rs`: the `pcap` submodule. One `Handle` (`pcap_t`) moves
|
|
70
|
+
through libpcap's states (inactive → active, or offline). A handle feeds one
|
|
71
|
+
reassembly at a time (`owner`); `breakloop()` only sets a flag, which works
|
|
72
|
+
without the capture lock. Live handles are made non-blocking when activated
|
|
73
|
+
and each read waits at most 100 ms, so Ctrl-C and `breakloop()` always get
|
|
74
|
+
control back: a blocked libpcap read can't be interrupted.
|
|
75
|
+
- `src/lib.rs`: `_native.Reassembler`. `push()` takes one frame, `run(handle)`
|
|
76
|
+
reads packets with the Python lock released and returns batches of event
|
|
77
|
+
tuples, `finish()` closes everything. The tuple format is documented at the
|
|
78
|
+
top of the file.
|
|
79
|
+
- `src/http.rs` + `src/htp_shim.c`: HTTP mode. A post-processor over the
|
|
80
|
+
reassembler's events: TCP `Data`/`Gap` go into one libhtp connection parser
|
|
81
|
+
per flow, and libhtp hooks copy each completed `htp_tx_t` into a `Tx` event
|
|
82
|
+
(incomplete ones are copied at close). Rust never touches libhtp structs
|
|
83
|
+
directly: the C shim exposes fields by number (the name lists in `http.rs`
|
|
84
|
+
must match the shim's `switch` order). libhtp's suspended parsing
|
|
85
|
+
(`HTP_STREAM_DATA_OTHER`) is handled with per-direction queues; on
|
|
86
|
+
`TUNNEL`/`ERROR`/`STOP` the rest of the connection becomes plain `Data`
|
|
87
|
+
(libhtp's read-but-unparsed bytes are recovered by `fr_connp_buffered` and
|
|
88
|
+
`fr_connp_current_unparsed`). Copy anything from libhtp's current data
|
|
89
|
+
before the chunk it points into is dropped.
|
|
90
|
+
- `vendor/libhtp/`: libhtp 0.5.53, built by `build.rs` with the `cc` crate
|
|
91
|
+
(no autotools). `vendor/libhtp/README.md` lists every local change; keep it
|
|
92
|
+
up to date.
|
|
93
|
+
- `python/flowreassemble/_reassemble.py`: wraps event tuples into `Opened`,
|
|
94
|
+
`Data`, `Gap` and `Closed` dataclasses and `Flow` objects. Overlap events are
|
|
95
|
+
dropped here, except for `conversations()`.
|
|
96
|
+
- `python/flowreassemble/_conversations.py`: builds `Conversation` objects
|
|
97
|
+
(turns, gaps with stream offsets, anomalies, `max_bytes`) from the same
|
|
98
|
+
event stream.
|
|
99
|
+
|
|
100
|
+
Tests: most behavior is tested with pytest (`tests/`). `tests/packets.py`
|
|
101
|
+
builds frames and pcap files in pure Python (`Conn` drives a TCP connection),
|
|
102
|
+
so tests need no fixture files or third-party packet libraries. Rust unit tests
|
|
103
|
+
cover small internals.
|
|
104
|
+
|
|
105
|
+
## Goal
|
|
106
|
+
|
|
107
|
+
A modern reimplementation of libnids (IP defragmentation + TCP stream
|
|
108
|
+
reassembly, delivering application-layer byte streams) written in Rust, with
|
|
109
|
+
first-class Python bindings. The main use case is incident response: writing
|
|
110
|
+
quick, throwaway decoders for custom C2 protocols from pcap files.
|
|
111
|
+
|
|
112
|
+
Keep what libnids taught; drop what it did only because of its era. There is
|
|
113
|
+
no compatibility requirement with libnids or pynids: don't reproduce their
|
|
114
|
+
API, names, or quirks.
|
|
115
|
+
|
|
116
|
+
**Target workflow:** the pcap is already filtered down to one protocol between
|
|
117
|
+
one client and one server. It can still contain many sequential connections
|
|
118
|
+
(for example, one per beacon), but never a mix of protocols. Don't build
|
|
119
|
+
multi-protocol dispatch.
|
|
120
|
+
|
|
121
|
+
## Decisions
|
|
122
|
+
|
|
123
|
+
- **Name:** `flowreassemble` for the PyPI package, the Python import, and the
|
|
124
|
+
Rust crate.
|
|
125
|
+
- **Python first.** About 90% of usage will be from Python. Design the Python
|
|
126
|
+
API first; the Rust crate is the engine, not a stable public API.
|
|
127
|
+
- **Layout:** plain maturin layout (PyO3). Don't use the author's cookiecutter
|
|
128
|
+
template. The project will be published on github.com.
|
|
129
|
+
- **Python API shape:** two layers over one core.
|
|
130
|
+
- *Event iterator:* `for ev in reassemble(handle, ...): match ev: ...` with `Opened`,
|
|
131
|
+
`Data(flow, direction, data)`, `Gap`, and `Closed(reason)`. UDP uses
|
|
132
|
+
`Data` too: one event per datagram, with boundaries preserved. In HTTP
|
|
133
|
+
mode, libhtp's transaction object (`htp.Tx`, for `htp_tx_t`) is yielded
|
|
134
|
+
directly as the event, with no wrapper event type. Per-flow user state
|
|
135
|
+
lives on `flow.state`, not in user dicts. Directions are a `Direction`
|
|
136
|
+
enum (a bare name in a `case` pattern would capture, not compare). There
|
|
137
|
+
is no anomaly event in this layer.
|
|
138
|
+
- *Conversations:* `conversations(...)` yields one object per connection,
|
|
139
|
+
once the connection closes. It has `client`, `server`, `start`, `end`,
|
|
140
|
+
`close_reason`, direction-merged `turns`, `to_server` / `to_client`
|
|
141
|
+
(concatenated bytes), `gaps`, `anomalies` (such as retransmissions with
|
|
142
|
+
different content), and in HTTP mode `transactions` (a list of `htp.Tx`).
|
|
143
|
+
Memory per connection is capped by `max_bytes=`. Past the cap, bytes are
|
|
144
|
+
dropped, the truncation is recorded in `anomalies`, and the conversation
|
|
145
|
+
is still yielded.
|
|
146
|
+
- The event iterator never holds back bytes for the user: each `Data`
|
|
147
|
+
carries only new bytes, and buffering partial messages is the user's job.
|
|
148
|
+
There is no equivalent of libnids's `discard`.
|
|
149
|
+
- Both layers take a libpcap handle (`reassemble(handle, ...)`,
|
|
150
|
+
`conversations(handle, ...)`) with the same options (`server=`, `http=`,
|
|
151
|
+
`max_bytes=`, ...). Opening and filtering are done on the handle, not
|
|
152
|
+
through options of ours.
|
|
153
|
+
- **No user callbacks.** Everything goes through the iterator. libhtp hooks
|
|
154
|
+
are used internally by the glue and are not exposed for registration.
|
|
155
|
+
- **Directions** are named by where the data goes: `to_server` and
|
|
156
|
+
`to_client`.
|
|
157
|
+
- **Packet source: libpcap, exposed, not hidden.** Linux wheels link their
|
|
158
|
+
own static libpcap; macOS wheels use the system one. As with `htp`, a `flowreassemble.pcap` submodule mirrors libpcap's
|
|
159
|
+
own model and names, without the `pcap_` prefix: `pcap.open_offline(path)`,
|
|
160
|
+
both live-capture APIs (`pcap.open_live(device, snaplen, promisc,
|
|
161
|
+
timeout_ms)`, and `pcap.create(device)` → `set_*` (snaplen, promisc,
|
|
162
|
+
timeout, immediate_mode, buffer_size, tstamp_precision, ...) →
|
|
163
|
+
`activate()`), `handle.setfilter(bpf)`, `handle.datalink()`. The user opens
|
|
164
|
+
and filters the handle, then passes it to `reassemble()` or
|
|
165
|
+
`conversations()`. Don't add `read_pcap()` / `capture()`-style shortcuts or
|
|
166
|
+
`bpf=` options that hide it. The reassembly core takes packets as input and
|
|
167
|
+
doesn't depend on libpcap.
|
|
168
|
+
- **Any frame source works too:** `reassemble()` and `conversations()` also
|
|
169
|
+
accept any iterable of `(timestamp, linktype, frame)` (scapy, dpkt, test
|
|
170
|
+
fixtures). It's slower than a `pcap` handle because every frame crosses from
|
|
171
|
+
Python.
|
|
172
|
+
- **End of input closes everything:** when the input ends, every open flow is
|
|
173
|
+
closed (reason `EOF`), so nothing is lost.
|
|
174
|
+
- **Idle timeout:** `idle_timeout=` in seconds, default 1 hour, with `None`
|
|
175
|
+
meaning never. Sparse C2 beacons on one long-lived connection must stay one
|
|
176
|
+
conversation, which is why the default is large. Time comes from packet
|
|
177
|
+
timestamps; during live capture it also advances on the wall clock.
|
|
178
|
+
- **Stopping live capture:** `handle.breakloop()` (libpcap's own mechanism,
|
|
179
|
+
safe to call from a signal handler) makes `reassemble()` and
|
|
180
|
+
`conversations()` close and yield the open flows, then end normally. A plain
|
|
181
|
+
Ctrl-C raises `KeyboardInterrupt` right away, and the flows still open are
|
|
182
|
+
not yielded.
|
|
183
|
+
- **Why `pcap` is a submodule and not a separate package:** the Rust core
|
|
184
|
+
reads packets from the handle directly. That keeps per-packet work out of
|
|
185
|
+
Python, releases the Python lock while blocking, and allows Ctrl-C. It's
|
|
186
|
+
only safe when the handle and the core share one libpcap copy, meaning one
|
|
187
|
+
compiled extension (Linux wheels link a private static libpcap into it;
|
|
188
|
+
on macOS, every user of libpcap in the process shares the system one). The
|
|
189
|
+
top-level `pcap` import name is also taken on PyPI.
|
|
190
|
+
- **Platforms:** Linux and macOS first. Windows is best effort: libpcap there
|
|
191
|
+
means Npcap, which users install themselves and which we can't bundle.
|
|
192
|
+
- **Python:** 3.10+ (the API relies on `match`).
|
|
193
|
+
- **Live capture is required in v1.** It needs capture privileges (root or
|
|
194
|
+
`CAP_NET_RAW` on Linux, BPF device access on macOS). Idle timeouts close
|
|
195
|
+
flows, so conversations are yielded even when a connection never ends.
|
|
196
|
+
Ctrl-C must stop a blocking capture cleanly.
|
|
197
|
+
- **HTTP is first-class, via the C libhtp (0.5.x, last release 0.5.53).** Its
|
|
198
|
+
full public API is needed: every hook, and the public `htp_tx_t` with
|
|
199
|
+
cookies, parsed URI, and auth. libhtp reached end-of-life in July 2026, so
|
|
200
|
+
security fixes are now this project's responsibility. Build it from vendored
|
|
201
|
+
source through `build.rs` (it needs zlib and iconv; it bundles its own LZMA
|
|
202
|
+
decoder), and fuzz the HTTP path
|
|
203
|
+
in CI. The Rust port (`suricata-htp`) was rejected because almost all of its
|
|
204
|
+
API is `pub(crate)`.
|
|
205
|
+
- **HTTP details (implemented):** `htp.Config()` defaults are those of
|
|
206
|
+
`htp_config_create()` (personality `MINIMAL`, response decompression on,
|
|
207
|
+
request decompression off, cookie parsing on). `htp.Tx` fields carry
|
|
208
|
+
libhtp's names; only `flow` and `ts` are ours, plus the opt-in
|
|
209
|
+
`request_body`/`response_body` (`keep_bodies`). Every TCP connection is
|
|
210
|
+
parsed as HTTP in HTTP mode; `Gap` events still pass through. Bytes libhtp
|
|
211
|
+
does not parse come as `Data`: after it stops (CONNECT or 101 tunnel, parse
|
|
212
|
+
error, or a gap it refuses, such as one inside a request line), the bytes it
|
|
213
|
+
had buffered and the rest of the connection; at close, bytes it still holds.
|
|
214
|
+
A stopped parser is never asked to finalize (no `htp_connp_close`).
|
|
215
|
+
`conversations(max_bytes=)` counts kept HTTP bodies too.
|
|
216
|
+
- **Expose libhtp, don't hide it.** Python sees libhtp's own model and names
|
|
217
|
+
(`htp.Config`, `htp.Tx`, its field names, raw `bytes` values). This library
|
|
218
|
+
adds only the glue (one connection parser per HTTP flow, feeding both
|
|
219
|
+
directions, gaps, timestamps, close) and conveniences that are opt-in and
|
|
220
|
+
explicitly named, such as keeping bodies in memory up to a cap. Don't add a
|
|
221
|
+
parallel HTTP abstraction.
|
|
222
|
+
- **Overlapping retransmissions:** keep the first copy of each byte. When a
|
|
223
|
+
later copy has different content, report it.
|
|
224
|
+
- **Mid-stream pickup:** connections whose handshake isn't in the capture are
|
|
225
|
+
still reassembled. Direction is guessed from ports unless the user names the
|
|
226
|
+
server.
|
|
227
|
+
|
|
228
|
+
## Reference: how the author uses pynids today
|
|
229
|
+
|
|
230
|
+
Workflow from
|
|
231
|
+
<https://justanothergeek.chdir.org/2020/11/Decoding-C2-Traffic-in-Python-or-HOWTO-eat-during-an-IR-engagement/>
|
|
232
|
+
(template:
|
|
233
|
+
<https://gist.github.com/nbareil/07307dee653ef3400cd3ece0c7a35799>):
|
|
234
|
+
pynids feeds reassembled TCP data into htpy (libhtp) to parse HTTP C2 traffic.
|
|
235
|
+
|
|
236
|
+
Pain points in that template, which the new design should eliminate:
|
|
237
|
+
|
|
238
|
+
- Per-connection state kept in global dicts keyed by `tcp.addr`, cleaned up by
|
|
239
|
+
hand (or leaked).
|
|
240
|
+
- Manual dispatch on `nids_state` (`JUST_EST` / `DATA` / `CLOSE` / `TIMEOUT` /
|
|
241
|
+
`RESET`) and `collect = 1` boilerplate.
|
|
242
|
+
- Confusing direction naming: `tcp.client.count_new` means data sent *by the
|
|
243
|
+
server*.
|
|
244
|
+
- Slicing `data[:count_new]` by hand.
|
|
245
|
+
- Callback exceptions swallowed by the C layer, so every callback is wrapped
|
|
246
|
+
in try/except.
|
|
247
|
+
- Global, string-keyed config (`nids.param(...)`, `nids.chksum_ctl(...)`), a
|
|
248
|
+
single global instance, and `nids.run()` owning the loop.
|
|
249
|
+
- Checksum verification has to be disabled explicitly for captures with NIC
|
|
250
|
+
offload.
|
|
251
|
+
- VLAN traffic needs an explicit `vlan and ...` BPF filter.
|
|
252
|
+
- Painful to build (the post resorts to Docker with pinned versions).
|