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.
Files changed (101) hide show
  1. flowreassemble-0.1.0/.github/build-libpcap.sh +21 -0
  2. flowreassemble-0.1.0/.github/workflows/ci.yml +24 -0
  3. flowreassemble-0.1.0/.github/workflows/release.yml +165 -0
  4. flowreassemble-0.1.0/.gitignore +9 -0
  5. flowreassemble-0.1.0/CLAUDE.md +252 -0
  6. flowreassemble-0.1.0/Cargo.lock +336 -0
  7. flowreassemble-0.1.0/Cargo.toml +20 -0
  8. flowreassemble-0.1.0/LICENSE +28 -0
  9. flowreassemble-0.1.0/Makefile +29 -0
  10. flowreassemble-0.1.0/PKG-INFO +294 -0
  11. flowreassemble-0.1.0/README.md +280 -0
  12. flowreassemble-0.1.0/build.rs +68 -0
  13. flowreassemble-0.1.0/flake.lock +27 -0
  14. flowreassemble-0.1.0/flake.nix +30 -0
  15. flowreassemble-0.1.0/pyproject.toml +31 -0
  16. flowreassemble-0.1.0/python/flowreassemble/__init__.py +33 -0
  17. flowreassemble-0.1.0/python/flowreassemble/_conversations.py +175 -0
  18. flowreassemble-0.1.0/python/flowreassemble/_native.pyi +34 -0
  19. flowreassemble-0.1.0/python/flowreassemble/_reassemble.py +177 -0
  20. flowreassemble-0.1.0/python/flowreassemble/htp.py +238 -0
  21. flowreassemble-0.1.0/python/flowreassemble/pcap.py +26 -0
  22. flowreassemble-0.1.0/python/flowreassemble/py.typed +0 -0
  23. flowreassemble-0.1.0/src/decode.rs +314 -0
  24. flowreassemble-0.1.0/src/flows.rs +577 -0
  25. flowreassemble-0.1.0/src/htp_shim.c +145 -0
  26. flowreassemble-0.1.0/src/http.rs +662 -0
  27. flowreassemble-0.1.0/src/lib.rs +359 -0
  28. flowreassemble-0.1.0/src/pcap_handle.rs +285 -0
  29. flowreassemble-0.1.0/src/segments.rs +158 -0
  30. flowreassemble-0.1.0/tests/packets.py +189 -0
  31. flowreassemble-0.1.0/tests/test_conversations.py +59 -0
  32. flowreassemble-0.1.0/tests/test_http.py +171 -0
  33. flowreassemble-0.1.0/tests/test_pcap.py +128 -0
  34. flowreassemble-0.1.0/tests/test_tcp.py +271 -0
  35. flowreassemble-0.1.0/tests/test_udp.py +34 -0
  36. flowreassemble-0.1.0/uv.lock +188 -0
  37. flowreassemble-0.1.0/vendor/libhtp/AUTHORS +6 -0
  38. flowreassemble-0.1.0/vendor/libhtp/LICENSE +30 -0
  39. flowreassemble-0.1.0/vendor/libhtp/README.md +16 -0
  40. flowreassemble-0.1.0/vendor/libhtp/VERSION +2 -0
  41. flowreassemble-0.1.0/vendor/libhtp/htp/bstr.c +638 -0
  42. flowreassemble-0.1.0/vendor/libhtp/htp/bstr.h +678 -0
  43. flowreassemble-0.1.0/vendor/libhtp/htp/bstr_builder.c +121 -0
  44. flowreassemble-0.1.0/vendor/libhtp/htp/bstr_builder.h +136 -0
  45. flowreassemble-0.1.0/vendor/libhtp/htp/htp.h +690 -0
  46. flowreassemble-0.1.0/vendor/libhtp/htp/htp_base64.c +196 -0
  47. flowreassemble-0.1.0/vendor/libhtp/htp/htp_base64.h +74 -0
  48. flowreassemble-0.1.0/vendor/libhtp/htp/htp_config.c +962 -0
  49. flowreassemble-0.1.0/vendor/libhtp/htp/htp_config.h +727 -0
  50. flowreassemble-0.1.0/vendor/libhtp/htp/htp_config_auto.h +51 -0
  51. flowreassemble-0.1.0/vendor/libhtp/htp/htp_config_auto_gen.h +6 -0
  52. flowreassemble-0.1.0/vendor/libhtp/htp/htp_config_private.h +376 -0
  53. flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection.c +168 -0
  54. flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_parser.c +260 -0
  55. flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_parser.h +218 -0
  56. flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_parser_private.h +275 -0
  57. flowreassemble-0.1.0/vendor/libhtp/htp/htp_connection_private.h +121 -0
  58. flowreassemble-0.1.0/vendor/libhtp/htp/htp_content_handlers.c +299 -0
  59. flowreassemble-0.1.0/vendor/libhtp/htp/htp_cookies.c +119 -0
  60. flowreassemble-0.1.0/vendor/libhtp/htp/htp_core.h +408 -0
  61. flowreassemble-0.1.0/vendor/libhtp/htp/htp_decompressors.c +495 -0
  62. flowreassemble-0.1.0/vendor/libhtp/htp/htp_decompressors.h +94 -0
  63. flowreassemble-0.1.0/vendor/libhtp/htp/htp_hooks.c +160 -0
  64. flowreassemble-0.1.0/vendor/libhtp/htp/htp_hooks.h +122 -0
  65. flowreassemble-0.1.0/vendor/libhtp/htp/htp_list.c +360 -0
  66. flowreassemble-0.1.0/vendor/libhtp/htp/htp_list.h +227 -0
  67. flowreassemble-0.1.0/vendor/libhtp/htp/htp_list_private.h +73 -0
  68. flowreassemble-0.1.0/vendor/libhtp/htp/htp_multipart.c +1615 -0
  69. flowreassemble-0.1.0/vendor/libhtp/htp/htp_multipart.h +345 -0
  70. flowreassemble-0.1.0/vendor/libhtp/htp/htp_multipart_private.h +203 -0
  71. flowreassemble-0.1.0/vendor/libhtp/htp/htp_parsers.c +214 -0
  72. flowreassemble-0.1.0/vendor/libhtp/htp/htp_php.c +116 -0
  73. flowreassemble-0.1.0/vendor/libhtp/htp/htp_private.h +269 -0
  74. flowreassemble-0.1.0/vendor/libhtp/htp/htp_request.c +1174 -0
  75. flowreassemble-0.1.0/vendor/libhtp/htp/htp_request_apache_2_2.c +64 -0
  76. flowreassemble-0.1.0/vendor/libhtp/htp/htp_request_generic.c +478 -0
  77. flowreassemble-0.1.0/vendor/libhtp/htp/htp_request_parsers.c +149 -0
  78. flowreassemble-0.1.0/vendor/libhtp/htp/htp_response.c +1450 -0
  79. flowreassemble-0.1.0/vendor/libhtp/htp/htp_response_generic.c +344 -0
  80. flowreassemble-0.1.0/vendor/libhtp/htp/htp_table.c +250 -0
  81. flowreassemble-0.1.0/vendor/libhtp/htp/htp_table.h +184 -0
  82. flowreassemble-0.1.0/vendor/libhtp/htp/htp_table_private.h +78 -0
  83. flowreassemble-0.1.0/vendor/libhtp/htp/htp_transaction.c +1564 -0
  84. flowreassemble-0.1.0/vendor/libhtp/htp/htp_transaction.h +529 -0
  85. flowreassemble-0.1.0/vendor/libhtp/htp/htp_transcoder.c +211 -0
  86. flowreassemble-0.1.0/vendor/libhtp/htp/htp_urlencoded.c +332 -0
  87. flowreassemble-0.1.0/vendor/libhtp/htp/htp_urlencoded.h +111 -0
  88. flowreassemble-0.1.0/vendor/libhtp/htp/htp_utf8_decoder.c +118 -0
  89. flowreassemble-0.1.0/vendor/libhtp/htp/htp_utf8_decoder.h +85 -0
  90. flowreassemble-0.1.0/vendor/libhtp/htp/htp_util.c +2608 -0
  91. flowreassemble-0.1.0/vendor/libhtp/htp/htp_version.h +53 -0
  92. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/7zTypes.h +375 -0
  93. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/Compiler.h +33 -0
  94. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzFind.c +1127 -0
  95. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzFind.h +121 -0
  96. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzHash.h +57 -0
  97. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzmaDec.c +1223 -0
  98. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/LzmaDec.h +234 -0
  99. flowreassemble-0.1.0/vendor/libhtp/htp/lzma/Precomp.h +10 -0
  100. flowreassemble-0.1.0/vendor/libhtp/htp/strlcat.c +76 -0
  101. 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,9 @@
1
+ /target/
2
+ /.venv/
3
+ /.direnv/
4
+ __pycache__/
5
+ *.so
6
+ *.pyd
7
+ *.pcap
8
+ *.pcapng
9
+ !tests/data/*.pcap
@@ -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).