Solo para leer y verificar. No puedes copiar, usar, modificar ni redistribuir este código. Aviso completo en la página principal.
scripts/e2e_pcap.py
| 1 | #!/usr/bin/env python3 |
| 2 | """Does a plaintext marker cross the tunnel server? Reads a tcpdump capture (classic pcap) and looks for markers in it. |
| 3 | |
| 4 | This is the proof tool of the ALL end-to-end experiment (the 1.1 experiment plan section 2.9, test 2): the lead puts synthetic markers |
| 5 | into the bodies of requests and responses that go ChatGPT (or any HTTPS client) -> tunnel server -> device, captures the traffic on |
| 6 | the tunnel server (with its capture command) and this module says whether any marker is visible there. It is standard library |
| 7 | only, so it also runs from release_readiness later. |
| 8 | |
| 9 | What it does, so that "no marker found" is not an empty statement: |
| 10 | * decodes Ethernet / raw IP / Linux cooked (SLL, SLL2) frames, IPv4 and IPv6, TCP; |
| 11 | * searches every marker in (a) the raw bytes of the file, (b) each TCP payload, (c) each TCP stream reassembled per direction, so a |
| 12 | marker split across two segments is still found; |
| 13 | * counts what WAS captured (flows, payload bytes per port, TLS records by type, the SNI names of the ClientHellos) and refuses to call |
| 14 | the capture clean when it holds no payload at all: a capture that saw nothing proves nothing; |
| 15 | * reads the hellos that cross in the clear with the ONE parser of the device (src/all_mcp/tls_policy.py, WP16): the version, cipher suite and selected group of every |
| 16 | ServerHello (and the alert of a refused handshake), and what each ClientHello offered, so the blindness gate records them in e2e-proof.json. |
| 17 | Not covered: pcapng files (tcpdump -w writes classic pcap) and IP fragments. |
| 18 | |
| 19 | WP23 made it the library of the blindness gate, so it also: |
| 20 | * searches every marker in its ENCODED forms (`forms=True`, on in the command line): upper and lower case (DNS folds case), base64 at the three |
| 21 | alignments, URL-safe base64, hex, UTF-16, and in what a stream holds once it is unwrapped: the TLS record headers taken out (a marker split across two |
| 22 | records), gzip and zlib streams inflated, base64 runs decoded, DNS names joined across their labels; |
| 23 | * reads UDP too (DNS on the node's resolver port is where a node could leak a marker without a TCP connection of its own); |
| 24 | * refuses a capture that lost something: a hole in a flow's sequence numbers, a flow whose first or last bytes are missing, a packet cut by the snap length, |
| 25 | a packet count that differs from what tcpdump says it captured, or kernel drops in tcpdump's own counters, is INCOMPLETE and never CLEAN (a marker may |
| 26 | have been in the part that is missing); |
| 27 | * lists every TLS flow with its SNI, the ServerHello's version, cipher and group, and the bytes each way (`flow_table`). |
| 28 | |
| 29 | python3 scripts/e2e_pcap.py FILE --marker TEXT [--marker TEXT ...] [--ports 443,8443] [--min-bytes N] [--tcpdump-log FILE] [--allow-midflow] [--exact] [--json] |
| 30 | |
| 31 | Exit codes: 0 clean (markers given, none found, traffic seen, nothing missing); 5 a marker was found; 1 not conclusive (no markers given, no payload |
| 32 | captured, a capture that lost packets, or a damaged one); 2 unreadable file or bad usage. |
| 33 | """ |
| 34 | from __future__ import annotations |
| 35 | |
| 36 | import argparse |
| 37 | import base64 |
| 38 | import binascii |
| 39 | import dataclasses |
| 40 | import json |
| 41 | import re |
| 42 | import struct |
| 43 | import sys |
| 44 | import zlib |
| 45 | from dataclasses import dataclass, field |
| 46 | from pathlib import Path |
| 47 | from typing import Optional, Sequence |
| 48 | |
| 49 | PCAP_MAGICS = { |
| 50 | b"\xd4\xc3\xb2\xa1": ("<", False), b"\xa1\xb2\xc3\xd4": (">", False), |
| 51 | b"\x4d\x3c\xb2\xa1": ("<", True), b"\xa1\xb2\x3c\x4d": (">", True), |
| 52 | } |
| 53 | PCAPNG_MAGIC = b"\x0a\x0d\x0d\x0a" |
| 54 | LINK_NULL, LINK_ETHERNET, LINK_RAW, LINK_SLL, LINK_SLL2 = 0, 1, 101, 113, 276 |
| 55 | # BSD address families of a loopback frame (macOS numbers AF_INET6 as 30, FreeBSD as 28): what a `tcpdump -i lo0` file holds |
| 56 | BSD_FAMILY_ETHERTYPE = {2: 0x0800, 24: 0x86DD, 28: 0x86DD, 30: 0x86DD} |
| 57 | MAX_FILE_BYTES = 400 * 1024 * 1024 |
| 58 | TLS_TYPES = {20: "change_cipher_spec", 21: "alert", 22: "handshake", 23: "application_data"} |
| 59 | MAX_EXAMPLES = 5 |
| 60 | MAX_HELLOS_LISTED = 20 |
| 61 | MAX_FLOWS_LISTED = 200 |
| 62 | MAX_VIEW_BYTES = 64 * 1024 * 1024 # of one stream that is unwrapped (inflated, decoded) for the search |
| 63 | MAX_INFLATE_TRIES = 256 # offsets of one stream where a gzip or zlib header is tried |
| 64 | BASE64_RUN = re.compile(rb"[A-Za-z0-9+/_-]{24,}={0,2}") |
| 65 | TCP_FIN, UDP_DNS_PORT = 0x01, 53 |
| 66 | _TLS_POLICY = None |
| 67 | |
| 68 | |
| 69 | def tls_policy(): |
| 70 | """The device's TLS policy module (the hello parser lives there, once): imported from this checkout's src/ the first time it is needed.""" |
| 71 | global _TLS_POLICY |
| 72 | if _TLS_POLICY is None: |
| 73 | src = str(Path(__file__).resolve().parents[1] / "src") |
| 74 | if src not in sys.path: |
| 75 | sys.path.insert(0, src) |
| 76 | from all_mcp import tls_policy as module |
| 77 | _TLS_POLICY = module |
| 78 | return _TLS_POLICY |
| 79 | |
| 80 | |
| 81 | class PcapError(Exception): |
| 82 | """The file cannot be read as a classic pcap capture.""" |
| 83 | |
| 84 | |
| 85 | @dataclass |
| 86 | class Segment: |
| 87 | src: str |
| 88 | sport: int |
| 89 | dst: str |
| 90 | dport: int |
| 91 | seq: int |
| 92 | syn: bool |
| 93 | payload: bytes |
| 94 | fin: bool = False |
| 95 | proto: str = "tcp" |
| 96 | ack: int = 0 |
| 97 | has_ack: bool = False |
| 98 | |
| 99 | |
| 100 | @dataclass |
| 101 | class MarkerResult: |
| 102 | marker: str # what is shown: the marker itself, or the label of a secret (its value is never stored or printed) |
| 103 | secret: bool = False |
| 104 | raw_hits: int = 0 |
| 105 | packet_hits: int = 0 |
| 106 | stream_hits: int = 0 |
| 107 | where: list = field(default_factory=list) |
| 108 | forms: list = field(default_factory=list) # the forms in which it was found ("raw", "base64+1", "tls-records", ...) |
| 109 | flows: list = field(default_factory=list) # the connections it was found in, as flow_key() writes them |
| 110 | |
| 111 | @property |
| 112 | def found(self) -> bool: |
| 113 | return bool(self.raw_hits or self.packet_hits or self.stream_hits) |
| 114 | |
| 115 | |
| 116 | @dataclass |
| 117 | class PcapReport: |
| 118 | file: str = "" |
| 119 | link: str = "" |
| 120 | packets: int = 0 |
| 121 | tcp_packets: int = 0 |
| 122 | skipped_frames: int = 0 |
| 123 | truncated_tail: bool = False |
| 124 | payload_bytes: int = 0 |
| 125 | flows: int = 0 |
| 126 | ports: dict = field(default_factory=dict) # port -> {"flows": n, "payload_bytes": n} |
| 127 | tls_records: dict = field(default_factory=dict) |
| 128 | tls_flows: int = 0 |
| 129 | non_tls_flows: list = field(default_factory=list) # [{"flow": ..., "first_bytes": ...}] a plaintext start is worth a look |
| 130 | sni_names: dict = field(default_factory=dict) |
| 131 | server_hellos: list = field(default_factory=list) # [{"flow", "version", "cipher", "group", "hybrid", "hello_retry_request"}] read from the ServerHello bytes |
| 132 | client_hellos: list = field(default_factory=list) # [{"flow", "server_name", "offers_hybrid", "groups", ...}] |
| 133 | alerts: list = field(default_factory=list) # [{"flow", "alert"}] a server that refused the handshake |
| 134 | tls_summary: dict = field(default_factory=dict) # counts of version, cipher and group over the ServerHellos: what e2e-proof.json records |
| 135 | flow_table: list = field(default_factory=list) # one entry per TCP connection: SNI, TLS version, cipher and group, bytes each way, records |
| 136 | udp_packets: int = 0 |
| 137 | udp_payload_bytes: int = 0 |
| 138 | dns_names: dict = field(default_factory=dict) # query name -> count, read from UDP and TCP port 53 |
| 139 | markers: list = field(default_factory=list) |
| 140 | min_bytes: int = 1 |
| 141 | incomplete: list = field(default_factory=list) # [{"where", "reason"}] everything that says part of the traffic is missing from the capture |
| 142 | stats: dict = field(default_factory=dict) # what the capture tool itself said (tcpdump's counters), when it was given |
| 143 | secrets_are_markers: bool = False # the secrets are all there is to search for (a marker file read by `e2e_blindness.py analyze`): they count as the markers of the verdict |
| 144 | |
| 145 | @property |
| 146 | def found(self) -> bool: |
| 147 | return any(m.found for m in self.markers) |
| 148 | |
| 149 | @property |
| 150 | def traffic_seen(self) -> bool: |
| 151 | return self.payload_bytes >= self.min_bytes |
| 152 | |
| 153 | @property |
| 154 | def verdict(self) -> str: |
| 155 | if self.found: |
| 156 | return "FOUND" |
| 157 | if not any(self.secrets_are_markers or not m.secret for m in self.markers): |
| 158 | return "NO-MARKERS" # a secret that is searched for in addition does not replace the markers the caller asked for |
| 159 | if self.incomplete: |
| 160 | return "INCOMPLETE" |
| 161 | if not self.traffic_seen: |
| 162 | return "NO-TRAFFIC" |
| 163 | return "CLEAN" |
| 164 | |
| 165 | @property |
| 166 | def exit_code(self) -> int: |
| 167 | return {"CLEAN": 0, "FOUND": 5}.get(self.verdict, 1) |
| 168 | |
| 169 | def summary(self) -> str: |
| 170 | ports = ", ".join(f"{p}: {v['flows']} flow(s) {v['payload_bytes']} B" for p, v in sorted(self.ports.items())) or "none" |
| 171 | tls = ", ".join(f"{k} {v}" for k, v in sorted(self.tls_records.items())) or "none" |
| 172 | text = (f"{self.packets} packet(s), {self.tcp_packets} TCP, {self.flows} flow(s), {self.payload_bytes} payload bytes; " |
| 173 | f"by port: {ports}; TLS records: {tls}; SNI names seen: {', '.join(sorted(self.sni_names)) or 'none'}") |
| 174 | if self.skipped_frames: |
| 175 | text += f"; {self.skipped_frames} frame(s) not decoded" |
| 176 | if self.truncated_tail: |
| 177 | text += "; the last record of the file was cut" |
| 178 | return text |
| 179 | |
| 180 | def to_dict(self) -> dict: |
| 181 | data = dataclasses.asdict(self) |
| 182 | data.update(verdict=self.verdict, found=self.found, traffic_seen=self.traffic_seen) |
| 183 | for entry, result in zip(data["markers"], self.markers): |
| 184 | entry["found"] = result.found |
| 185 | return data |
| 186 | |
| 187 | |
| 188 | # --------------------------------------------------------------------------- decoding |
| 189 | |
| 190 | def read_frames(data: bytes) -> tuple: |
| 191 | """(link type, [frame bytes], truncated_tail) of a classic pcap file.""" |
| 192 | link, frames, truncated, _snapped = read_frames_ex(data) |
| 193 | return link, frames, truncated |
| 194 | |
| 195 | |
| 196 | def read_frames_ex(data: bytes) -> tuple: |
| 197 | """(link type, [frame bytes], truncated_tail, snapped): `snapped` counts the frames the capture cut short (captured length below the length on the wire).""" |
| 198 | if data[:4] == PCAPNG_MAGIC: |
| 199 | raise PcapError("this is a pcapng file; capture with `tcpdump -w` (classic pcap) or convert it with editcap -F pcap") |
| 200 | if len(data) < 24 or data[:4] not in PCAP_MAGICS: |
| 201 | raise PcapError("not a pcap capture (unknown magic number)") |
| 202 | endian, _nano = PCAP_MAGICS[data[:4]] |
| 203 | link = struct.unpack_from(endian + "I", data, 20)[0] |
| 204 | frames, offset, truncated, snapped = [], 24, False, 0 |
| 205 | while offset < len(data): |
| 206 | if offset + 16 > len(data): |
| 207 | truncated = True |
| 208 | break |
| 209 | incl, orig = struct.unpack_from(endian + "II", data, offset + 8) |
| 210 | offset += 16 |
| 211 | if incl > 262144 * 4 or offset + incl > len(data): |
| 212 | truncated = True |
| 213 | break |
| 214 | snapped += 1 if orig > incl else 0 |
| 215 | frames.append(data[offset:offset + incl]) |
| 216 | offset += incl |
| 217 | return link, frames, truncated, snapped |
| 218 | |
| 219 | |
| 220 | def _ip_version(frame: bytes, link: int) -> Optional[tuple]: |
| 221 | """(ip packet bytes, ethertype) or None.""" |
| 222 | if link == LINK_ETHERNET: |
| 223 | if len(frame) < 14: |
| 224 | return None |
| 225 | kind, start = struct.unpack_from(">H", frame, 12)[0], 14 |
| 226 | while kind in (0x8100, 0x88A8) and len(frame) >= start + 4: # VLAN tags |
| 227 | kind, start = struct.unpack_from(">H", frame, start + 2)[0], start + 4 |
| 228 | return frame[start:], kind |
| 229 | if link == LINK_SLL: |
| 230 | return (frame[16:], struct.unpack_from(">H", frame, 14)[0]) if len(frame) >= 16 else None |
| 231 | if link == LINK_SLL2: |
| 232 | return (frame[20:], struct.unpack_from(">H", frame, 0)[0]) if len(frame) >= 20 else None |
| 233 | if link == LINK_RAW and frame: |
| 234 | return frame, {4: 0x0800, 6: 0x86DD}.get(frame[0] >> 4, 0) |
| 235 | if link == LINK_NULL and len(frame) >= 4: |
| 236 | # the family is written in the capturing host's byte order: little-endian first (every Mac this runs on), then big-endian |
| 237 | family = struct.unpack_from("<I", frame, 0)[0] |
| 238 | if family not in BSD_FAMILY_ETHERTYPE: |
| 239 | family = struct.unpack_from(">I", frame, 0)[0] |
| 240 | return frame[4:], BSD_FAMILY_ETHERTYPE.get(family, 0) |
| 241 | return None |
| 242 | |
| 243 | |
| 244 | def _ip_text(raw: bytes) -> str: |
| 245 | if len(raw) == 4: |
| 246 | return ".".join(str(b) for b in raw) |
| 247 | return ":".join(f"{raw[i]:02x}{raw[i + 1]:02x}" for i in range(0, 16, 2)) |
| 248 | |
| 249 | |
| 250 | def _ip_payload(frame: bytes, link: int, protocol: int) -> Optional[tuple]: |
| 251 | """(source address, destination address, the IP payload) of an IPv4 or IPv6 frame that carries `protocol` (6 TCP, 17 UDP), or None.""" |
| 252 | found = _ip_version(frame, link) |
| 253 | if not found: |
| 254 | return None |
| 255 | ip, kind = found |
| 256 | if kind == 0x0800 and len(ip) >= 20 and ip[0] >> 4 == 4: |
| 257 | header = (ip[0] & 15) * 4 |
| 258 | total = struct.unpack_from(">H", ip, 2)[0] |
| 259 | fragment = struct.unpack_from(">H", ip, 6)[0] & 0x1FFF |
| 260 | if ip[9] != protocol or header < 20 or fragment: |
| 261 | return None |
| 262 | return _ip_text(ip[12:16]), _ip_text(ip[16:20]), ip[header:total if 0 < total <= len(ip) else len(ip)] |
| 263 | if kind == 0x86DD and len(ip) >= 40 and ip[0] >> 4 == 6: |
| 264 | length, nxt, start = struct.unpack_from(">H", ip, 4)[0], ip[6], 40 |
| 265 | while nxt in (0, 43, 60) and len(ip) >= start + 8: # hop-by-hop, routing, destination options |
| 266 | nxt, start = ip[start], start + (ip[start + 1] + 1) * 8 |
| 267 | if nxt != protocol: |
| 268 | return None |
| 269 | return _ip_text(ip[8:24]), _ip_text(ip[24:40]), ip[start:40 + length if 0 < length else len(ip)] |
| 270 | return None |
| 271 | |
| 272 | |
| 273 | def decode_tcp(frame: bytes, link: int) -> Optional[Segment]: |
| 274 | found = _ip_payload(frame, link, 6) |
| 275 | if not found: |
| 276 | return None |
| 277 | src, dst, tcp = found |
| 278 | if len(tcp) < 20: |
| 279 | return None |
| 280 | sport, dport, seq = struct.unpack_from(">HHI", tcp, 0) |
| 281 | offset = (tcp[12] >> 4) * 4 |
| 282 | if offset < 20 or offset > len(tcp): |
| 283 | return None |
| 284 | ack = struct.unpack_from(">I", tcp, 8)[0] |
| 285 | return Segment(src, sport, dst, dport, seq, bool(tcp[13] & 0x02), bytes(tcp[offset:]), fin=bool(tcp[13] & TCP_FIN), ack=ack, has_ack=bool(tcp[13] & 0x10)) |
| 286 | |
| 287 | |
| 288 | def decode_udp(frame: bytes, link: int) -> Optional[Segment]: |
| 289 | """A UDP datagram as a Segment (proto "udp", no sequence number): what the node's resolver sends and receives.""" |
| 290 | found = _ip_payload(frame, link, 17) |
| 291 | if not found: |
| 292 | return None |
| 293 | src, dst, udp = found |
| 294 | if len(udp) < 8: |
| 295 | return None |
| 296 | sport, dport, length = struct.unpack_from(">HHH", udp, 0) |
| 297 | return Segment(src, sport, dst, dport, 0, False, bytes(udp[8:length if 8 <= length <= len(udp) else len(udp)]), proto="udp") |
| 298 | |
| 299 | |
| 300 | # --------------------------------------------------------------------------- reassembly and TLS |
| 301 | |
| 302 | def _relative(seq: int, base: int) -> int: |
| 303 | rel = (seq - base) & 0xFFFFFFFF |
| 304 | return rel - (1 << 32) if rel >= 1 << 31 else rel |
| 305 | |
| 306 | |
| 307 | def reassemble_checked(segments: Sequence[Segment], peer: Sequence[Segment] = ()) -> tuple: |
| 308 | """(chunks, problems) for ONE direction of ONE flow: the payload as contiguous byte strings (a gap, i.e. a lost packet, starts a new one), and what says that |
| 309 | part of it is missing from the capture: `hole` (a gap between two segments), `head_missing` (the handshake was captured and the first bytes are not), |
| 310 | `no_syn` (the capture starts in the middle of the flow), `tail_missing` (the FIN, or what the OTHER side acknowledged, says more bytes were sent than were |
| 311 | captured). `peer` is the opposite direction of the same flow: its acknowledgement numbers say how much of this direction arrived.""" |
| 312 | base, saw_syn = None, False |
| 313 | for seg in segments: |
| 314 | if seg.syn: |
| 315 | base, saw_syn = (seg.seq + 1) & 0xFFFFFFFF, True |
| 316 | break |
| 317 | if base is None and segments: |
| 318 | base = segments[0].seq |
| 319 | placed, fin_end = [], None |
| 320 | for seg in segments: |
| 321 | if seg.fin: |
| 322 | fin_end = max(fin_end or 0, _relative(seg.seq, base) + len(seg.payload)) |
| 323 | if seg.payload: |
| 324 | placed.append((_relative(seg.seq, base), seg.payload)) |
| 325 | placed.sort(key=lambda item: item[0]) |
| 326 | chunks: list = [] |
| 327 | first, end = None, None |
| 328 | for offset, payload in placed: |
| 329 | if end is None or offset > end: |
| 330 | chunks.append(bytearray(payload)) |
| 331 | end = offset + len(payload) |
| 332 | first = offset if first is None else first |
| 333 | elif offset + len(payload) > end: |
| 334 | chunks[-1] += payload[end - offset:] |
| 335 | end = offset + len(payload) |
| 336 | problems: list = [] |
| 337 | if len(chunks) > 1: |
| 338 | problems.append("hole") |
| 339 | if chunks and not saw_syn: |
| 340 | problems.append("no_syn") |
| 341 | elif chunks and first is not None and first > 0: |
| 342 | problems.append("head_missing") |
| 343 | got = end or 0 |
| 344 | if fin_end is not None and fin_end > got: |
| 345 | problems.append("tail_missing") |
| 346 | elif base is not None: |
| 347 | # base is the first payload byte's number: an acknowledgement of `base + n` says n bytes arrived (an ack number of 0 is a hand-built frame's: no information) |
| 348 | acked = max((_relative(p.ack, base) for p in peer if p.has_ack and p.ack), default=0) |
| 349 | if acked > got + (1 if fin_end is not None else 0): |
| 350 | problems.append("tail_missing") |
| 351 | return [bytes(c) for c in chunks], problems |
| 352 | |
| 353 | |
| 354 | def reassemble(segments: Sequence[Segment]) -> list: |
| 355 | """The payload of ONE direction of ONE flow as contiguous byte strings (a gap, i.e. a lost packet, starts a new one).""" |
| 356 | return reassemble_checked(segments)[0] |
| 357 | |
| 358 | |
| 359 | def tls_records(stream: bytes) -> Optional[dict]: |
| 360 | """Counts the TLS records at the start of a stream, or None when the stream does not start like TLS.""" |
| 361 | counts: dict = {} |
| 362 | offset = 0 |
| 363 | while offset + 5 <= len(stream): |
| 364 | kind, major, length = stream[offset], stream[offset + 1], struct.unpack_from(">H", stream, offset + 3)[0] |
| 365 | if kind not in TLS_TYPES or major != 3 or length > 18432: |
| 366 | break |
| 367 | counts[TLS_TYPES[kind]] = counts.get(TLS_TYPES[kind], 0) + 1 |
| 368 | offset += 5 + length |
| 369 | return counts or None |
| 370 | |
| 371 | |
| 372 | def client_hello_sni(stream: bytes) -> Optional[str]: |
| 373 | """The server name of a TLS ClientHello at the start of a stream (what a router that never decrypts can see), or None.""" |
| 374 | try: |
| 375 | if len(stream) < 6 or stream[0] != 22 or stream[5] != 1: |
| 376 | return None |
| 377 | pos = 5 + 4 + 2 + 32 # record header, handshake header, version, random |
| 378 | pos += 1 + stream[pos] # session id |
| 379 | pos += 2 + struct.unpack_from(">H", stream, pos)[0] # cipher suites |
| 380 | pos += 1 + stream[pos] # compression methods |
| 381 | end = pos + 2 + struct.unpack_from(">H", stream, pos)[0] |
| 382 | pos += 2 |
| 383 | while pos + 4 <= min(end, len(stream)): |
| 384 | kind, size = struct.unpack_from(">HH", stream, pos) |
| 385 | pos += 4 |
| 386 | if kind == 0 and size >= 5: |
| 387 | name_len = struct.unpack_from(">H", stream, pos + 3)[0] |
| 388 | return stream[pos + 5:pos + 5 + name_len].decode("ascii", errors="replace") |
| 389 | pos += size |
| 390 | except (IndexError, struct.error): |
| 391 | return None |
| 392 | return None |
| 393 | |
| 394 | |
| 395 | def read_hello(stream: bytes, label: str, report: "PcapReport", hellos: list) -> dict: |
| 396 | """Adds what the start of one direction of a flow says in the clear (a ClientHello, a ServerHello, an alert) to the report, and returns it for the flow's row: |
| 397 | {"client": ClientHello dict, "server": the first ServerHello dict, "alert": name}. A hello that is cut or not TLS is skipped: the TLS record counts above already show it.""" |
| 398 | tls = tls_policy() |
| 399 | seen: dict = {} |
| 400 | try: |
| 401 | if len(stream) > 5 and stream[0] == 22 and stream[5] == 1: |
| 402 | client = tls.parse_client_hello(stream) |
| 403 | if client is not None: |
| 404 | seen["client"] = client.as_dict() |
| 405 | if len(report.client_hellos) < MAX_HELLOS_LISTED: |
| 406 | report.client_hellos.append({"flow": label, **client.as_dict()}) |
| 407 | return seen |
| 408 | observed = tls.observe_server(stream) |
| 409 | except tls.TlsParseError: |
| 410 | return seen |
| 411 | for hello in observed.hellos: |
| 412 | hellos.append(hello) |
| 413 | seen.setdefault("server", hello.as_dict()) |
| 414 | if len(report.server_hellos) < MAX_HELLOS_LISTED: |
| 415 | report.server_hellos.append({"flow": label, **hello.as_dict()}) |
| 416 | if observed.alert is not None: |
| 417 | seen["alert"] = observed.alert.name |
| 418 | if len(report.alerts) < MAX_HELLOS_LISTED: |
| 419 | report.alerts.append({"flow": label, "alert": observed.alert.name}) |
| 420 | return seen |
| 421 | |
| 422 | |
| 423 | def printable(data: bytes, limit: int = 8, hide: Sequence[str] = ()) -> str: |
| 424 | text = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:limit]) |
| 425 | for secret in hide: |
| 426 | if secret: |
| 427 | text = text.replace(secret, "<redacted>") |
| 428 | return text |
| 429 | |
| 430 | |
| 431 | # --------------------------------------------------------------------------- what is searched for, and where |
| 432 | |
| 433 | def needle_forms(value: str) -> list: |
| 434 | """[(form, bytes)] the ways `value` can show up in a capture: as it is, upper and lower case (a DNS resolver folds case), base64 and URL-safe base64 at the three |
| 435 | alignments it can have inside a longer text (only the characters that depend on the marker alone are kept), hex, and UTF-16 either way.""" |
| 436 | raw = value.encode("utf-8") |
| 437 | forms = [("raw", raw)] |
| 438 | for name, other in (("lower", raw.lower()), ("upper", raw.upper())): |
| 439 | if other != raw: |
| 440 | forms.append((name, other)) |
| 441 | for pad in range(3): |
| 442 | encoded = base64.b64encode(b"\0" * pad + raw).rstrip(b"=") |
| 443 | needle = encoded[-(-4 * pad // 3):(4 * (pad + len(raw))) // 3] |
| 444 | if len(needle) >= 8: |
| 445 | forms.append((f"base64+{pad}", needle)) |
| 446 | forms.append((f"base64url+{pad}", needle.replace(b"+", b"-").replace(b"/", b"_"))) |
| 447 | forms.append(("hex", raw.hex().encode("ascii"))) |
| 448 | if raw.hex().lower() != raw.hex().upper(): |
| 449 | forms.append(("HEX", raw.hex().upper().encode("ascii"))) |
| 450 | text = value |
| 451 | forms.append(("utf16le", text.encode("utf-16-le"))) |
| 452 | forms.append(("utf16be", text.encode("utf-16-be"))) |
| 453 | unique, seen = [], set() |
| 454 | for name, needle in forms: |
| 455 | if needle and needle not in seen: |
| 456 | seen.add(needle) |
| 457 | unique.append((name, needle)) |
| 458 | return unique |
| 459 | |
| 460 | |
| 461 | def tls_body_view(stream: bytes) -> Optional[bytes]: |
| 462 | """The record bodies of a stream that starts with TLS records, joined: a marker that a sender cut across two records has its halves next to each other here |
| 463 | (the five-byte headers between them are gone). None when the stream does not start like TLS.""" |
| 464 | out, offset, seen = bytearray(), 0, 0 |
| 465 | while offset + 5 <= len(stream): |
| 466 | kind, major, length = stream[offset], stream[offset + 1], struct.unpack_from(">H", stream, offset + 3)[0] |
| 467 | if kind not in TLS_TYPES or major != 3 or length > 18432: |
| 468 | break |
| 469 | out += stream[offset + 5:offset + 5 + length] |
| 470 | offset += 5 + length |
| 471 | seen += 1 |
| 472 | return bytes(out) if seen else None |
| 473 | |
| 474 | |
| 475 | COMPRESSED_HEADER = re.compile(rb"\x1f\x8b\x08|\x78[\x01\x5e\x9c\xda]") |
| 476 | |
| 477 | |
| 478 | def inflate_views(data: bytes) -> list: |
| 479 | """What the gzip and zlib streams that start anywhere in `data` hold (what could be read before the stream broke counts too: a capture can cut one short).""" |
| 480 | views, tries = [], 0 |
| 481 | for found in COMPRESSED_HEADER.finditer(data): |
| 482 | if tries >= MAX_INFLATE_TRIES: |
| 483 | break |
| 484 | tries += 1 |
| 485 | inflater = zlib.decompressobj(31 if data[found.start()] == 0x1F else 15) |
| 486 | out, pos = bytearray(), found.start() |
| 487 | try: |
| 488 | while pos < len(data) and len(out) < MAX_VIEW_BYTES and not inflater.eof: |
| 489 | out += inflater.decompress(data[pos:pos + 2048], MAX_VIEW_BYTES - len(out)) |
| 490 | pos += 2048 |
| 491 | except zlib.error: |
| 492 | pass |
| 493 | if len(out) >= 8: |
| 494 | views.append(bytes(out)) |
| 495 | return views |
| 496 | |
| 497 | |
| 498 | def base64_views(data: bytes) -> list: |
| 499 | """What the base64 (or URL-safe base64) texts inside `data` decode to.""" |
| 500 | views = [] |
| 501 | for found in BASE64_RUN.finditer(data): |
| 502 | text = found.group(0).rstrip(b"=").replace(b"-", b"+").replace(b"_", b"/") |
| 503 | text = text[:len(text) - len(text) % 4 or None] if len(text) % 4 == 1 else text |
| 504 | try: |
| 505 | views.append(binascii.a2b_base64(text + b"=" * (-len(text) % 4))) |
| 506 | except (binascii.Error, ValueError): |
| 507 | continue |
| 508 | return views |
| 509 | |
| 510 | |
| 511 | def derived_views(data: bytes, depth: int = 2) -> list: |
| 512 | """[(name, bytes)] of what `data` holds once it is unwrapped: the TLS record headers taken out, gzip and zlib inflated, base64 decoded; each of those unwrapped |
| 513 | once more (a base64 text of a gzip body). Bounded: MAX_VIEW_BYTES of output in all.""" |
| 514 | out, budget = [], MAX_VIEW_BYTES |
| 515 | frontier = [("", data[:MAX_VIEW_BYTES])] |
| 516 | for _ in range(depth): |
| 517 | nxt = [] |
| 518 | for prefix, blob in frontier: |
| 519 | candidates = [] |
| 520 | body = tls_body_view(blob) if not prefix else None |
| 521 | if body: |
| 522 | candidates.append(("tls-records", body)) |
| 523 | candidates += [("inflate", view) for view in inflate_views(blob)] |
| 524 | candidates += [("base64", view) for view in base64_views(blob)] |
| 525 | for name, view in candidates: |
| 526 | if budget <= 0: |
| 527 | return out |
| 528 | budget -= len(view) |
| 529 | label = f"{prefix}>{name}" if prefix else name |
| 530 | out.append((label, view)) |
| 531 | nxt.append((label, view)) |
| 532 | frontier = nxt |
| 533 | return out |
| 534 | |
| 535 | |
| 536 | def dns_names(payload: bytes) -> list: |
| 537 | """The names a DNS message carries in its question and record sections (compression pointers followed), or [] when it is not DNS.""" |
| 538 | names: list = [] |
| 539 | |
| 540 | def read(pos: int) -> tuple: |
| 541 | labels, jumped, end, hops = [], False, pos, 0 |
| 542 | while True: |
| 543 | length = payload[pos] |
| 544 | if length == 0: |
| 545 | return ".".join(labels), (end if jumped else pos + 1) |
| 546 | if length & 0xC0 == 0xC0: |
| 547 | target = ((length & 0x3F) << 8) | payload[pos + 1] |
| 548 | end = end if jumped else pos + 2 |
| 549 | jumped, pos, hops = True, target, hops + 1 |
| 550 | if hops > 16: |
| 551 | raise IndexError |
| 552 | continue |
| 553 | labels.append(payload[pos + 1:pos + 1 + length].decode("ascii", errors="replace")) |
| 554 | pos += 1 + length |
| 555 | if not jumped: |
| 556 | end = pos |
| 557 | |
| 558 | try: |
| 559 | questions, answers, authority, extra = struct.unpack_from(">HHHH", payload, 4) |
| 560 | pos = 12 |
| 561 | for _ in range(min(questions, 16)): |
| 562 | name, pos = read(pos) |
| 563 | names.append(name) |
| 564 | pos += 4 |
| 565 | for _ in range(min(answers + authority + extra, 64)): |
| 566 | name, pos = read(pos) |
| 567 | names.append(name) |
| 568 | size = struct.unpack_from(">H", payload, pos + 8)[0] |
| 569 | pos += 10 + size |
| 570 | except (IndexError, struct.error): |
| 571 | pass |
| 572 | return [name for name in names if name] |
| 573 | |
| 574 | |
| 575 | def parse_tcpdump_stats(text: str) -> dict: |
| 576 | """tcpdump's own counters from what it prints when it ends ("12 packets captured", "12 packets received by filter", "0 packets dropped by kernel", "0 packets |
| 577 | dropped by interface"): {"captured", "received", "dropped_kernel", "dropped_interface"}, only the ones the text has.""" |
| 578 | stats = {} |
| 579 | for key, pattern in (("captured", r"(\d+) packets? captured"), ("received", r"(\d+) packets? received by filter"), |
| 580 | ("dropped_kernel", r"(\d+) packets? dropped by kernel"), ("dropped_interface", r"(\d+) packets? dropped by interface")): |
| 581 | found = re.search(pattern, text or "") |
| 582 | if found: |
| 583 | stats[key] = int(found.group(1)) |
| 584 | return stats |
| 585 | |
| 586 | |
| 587 | def stats_problems(stats: dict, packets: int) -> list: |
| 588 | """What tcpdump's counters say about a capture of `packets` packets, as [(where, reason)]; empty when they show nothing lost.""" |
| 589 | problems = [] |
| 590 | for key in ("dropped_kernel", "dropped_interface"): |
| 591 | if stats.get(key): |
| 592 | problems.append(("capture counters", f"tcpdump says {stats[key]} packet(s) {key.replace('dropped_', 'dropped by ')}")) |
| 593 | if "captured" in stats and stats["captured"] != packets: |
| 594 | problems.append(("capture counters", f"tcpdump says it captured {stats['captured']} packet(s) and the file holds {packets}")) |
| 595 | if "received" in stats and "captured" in stats and stats["received"] != stats["captured"] and not stats.get("dropped_kernel"): |
| 596 | problems.append(("capture counters", f"tcpdump received {stats['received']} packet(s) by the filter and captured {stats['captured']}")) |
| 597 | return problems |
| 598 | |
| 599 | |
| 600 | def flow_key(src: str, sport: int, dst: str, dport: int) -> str: |
| 601 | """One name for a connection, whichever direction a packet goes.""" |
| 602 | first, second = sorted([f"{src}:{sport}", f"{dst}:{dport}"]) |
| 603 | return f"{first} <-> {second}" |
| 604 | |
| 605 | |
| 606 | # --------------------------------------------------------------------------- analysis |
| 607 | |
| 608 | def analyse(data: bytes, markers: Sequence[str] = (), *, ports: Optional[Sequence[int]] = None, min_bytes: int = 1, name: str = "", |
| 609 | hide: Sequence[str] = (), secret_markers: Sequence[tuple] = (), forms: bool = False, stats: Optional[dict] = None, |
| 610 | allow_midflow: bool = False, secrets_are_markers: bool = False) -> PcapReport: |
| 611 | """`secret_markers` are (label, value) pairs searched like markers (a leaked secret is a finding) but shown only by their label. `forms` also searches every |
| 612 | marker in its encoded forms and in what each stream holds once unwrapped (needle_forms, derived_views). `stats` is what the capture tool said about itself |
| 613 | (parse_tcpdump_stats): kernel drops or a packet count that differs from the file's make the capture INCOMPLETE. `allow_midflow` accepts flows whose |
| 614 | handshake is not in the capture (a capture that was already running); a hole, a missing tail or a cut packet is never accepted. `secrets_are_markers` lets |
| 615 | secrets alone be what is searched for (their values are still never shown): without it a run that was given no marker is NO-MARKERS.""" |
| 616 | if len(data) > MAX_FILE_BYTES: |
| 617 | raise PcapError(f"the capture is {len(data)} bytes; above {MAX_FILE_BYTES} it is not analysed in memory") |
| 618 | link, frames, truncated, snapped = read_frames_ex(data) |
| 619 | report = PcapReport(file=name, link={LINK_NULL: "bsd-loopback", LINK_ETHERNET: "ethernet", LINK_RAW: "raw-ip", LINK_SLL: "linux-sll", |
| 620 | LINK_SLL2: "linux-sll2"}.get(link, f"unsupported({link})"), |
| 621 | packets=len(frames), truncated_tail=truncated, min_bytes=min_bytes, stats=dict(stats or {}), secrets_are_markers=secrets_are_markers) |
| 622 | if report.link.startswith("unsupported"): |
| 623 | raise PcapError(f"link type {link} is not supported (Ethernet, raw IP, BSD loopback and Linux cooked captures are)") |
| 624 | wanted = set(ports) if ports else None |
| 625 | flows: dict = {} # (src, sport, dst, dport) -> [Segment] |
| 626 | segments, datagrams = [], [] |
| 627 | for frame in frames: |
| 628 | seg = decode_tcp(frame, link) |
| 629 | if seg is None: |
| 630 | seg = decode_udp(frame, link) |
| 631 | if seg is None: |
| 632 | report.skipped_frames += 1 |
| 633 | continue |
| 634 | if wanted is not None and seg.sport not in wanted and seg.dport not in wanted: |
| 635 | continue |
| 636 | if seg.proto == "udp": |
| 637 | report.udp_packets += 1 |
| 638 | report.udp_payload_bytes += len(seg.payload) |
| 639 | datagrams.append(seg) |
| 640 | continue |
| 641 | report.tcp_packets += 1 |
| 642 | segments.append(seg) |
| 643 | flows.setdefault((seg.src, seg.sport, seg.dst, seg.dport), []).append(seg) |
| 644 | results = [MarkerResult(m) for m in markers] + [MarkerResult(f"<secret: {label}>", secret=True) for label, _ in secret_markers] |
| 645 | values = [*markers, *(value for _, value in secret_markers)] |
| 646 | needles: list = [] # (result, form, bytes) |
| 647 | for result, value in zip(results, values): |
| 648 | if not value: |
| 649 | raise PcapError("an empty marker would match everything") |
| 650 | for form, needle in (needle_forms(value) if forms else [("raw", value.encode("utf-8"))]): |
| 651 | needles.append((result, form, needle)) |
| 652 | for result, form, needle in needles: |
| 653 | hits = data.count(needle) |
| 654 | if hits: |
| 655 | result.raw_hits += hits |
| 656 | if form not in result.forms: |
| 657 | result.forms.append(form) |
| 658 | |
| 659 | def search(blob: bytes, view: str, where: str, key: str, kind: str) -> None: |
| 660 | for result, form, needle in needles: |
| 661 | hits = blob.count(needle) |
| 662 | if not hits: |
| 663 | continue |
| 664 | if kind == "packet": |
| 665 | result.packet_hits += hits |
| 666 | else: |
| 667 | result.stream_hits += hits |
| 668 | name = form if view == "raw" else f"{view}:{form}" |
| 669 | if name not in result.forms: |
| 670 | result.forms.append(name) |
| 671 | if key not in result.flows and len(result.flows) < MAX_FLOWS_LISTED: |
| 672 | result.flows.append(key) |
| 673 | if len(result.where) < MAX_EXAMPLES: |
| 674 | result.where.append(f"{where} ({hits}x{'' if view == 'raw' else ', ' + view}{'' if form == 'raw' else ', ' + form})") |
| 675 | |
| 676 | connections = set() |
| 677 | for seg in segments: |
| 678 | report.payload_bytes += len(seg.payload) |
| 679 | if seg.payload: |
| 680 | search(seg.payload, "raw", f"one packet {seg.src}:{seg.sport} -> {seg.dst}:{seg.dport}", flow_key(seg.src, seg.sport, seg.dst, seg.dport), "packet") |
| 681 | low, high = sorted([(seg.src, seg.sport), (seg.dst, seg.dport)]) |
| 682 | connections.add((low, high)) |
| 683 | report.flows = len(connections) |
| 684 | |
| 685 | def service_ports(a: int, b: int) -> set: |
| 686 | # with a --ports filter: the filtered ports of this connection; without: the lower port is the service, the other an ephemeral one |
| 687 | return {p for p in (a, b) if p in wanted} if wanted is not None else {min(a, b)} |
| 688 | |
| 689 | for low, high in connections: |
| 690 | for port in service_ports(low[1], high[1]): |
| 691 | report.ports.setdefault(port, {"flows": 0, "payload_bytes": 0})["flows"] += 1 |
| 692 | server_hellos: list = [] |
| 693 | table: dict = {} # flow_key -> the row of flow_table |
| 694 | for (src, sport, dst, dport), segs in flows.items(): |
| 695 | chunks, problems = reassemble_checked(segs, flows.get((dst, dport, src, sport), ())) |
| 696 | if (dst, dport, src, sport) not in flows and any(seg.has_ack and seg.ack for seg in segs): |
| 697 | problems.append("direction_missing") # these segments acknowledge bytes of the other direction, which is not in the capture at all (an ack number of 0 is a hand-built frame's) |
| 698 | label = f"{src}:{sport} -> {dst}:{dport}" |
| 699 | key = flow_key(src, sport, dst, dport) |
| 700 | for port in service_ports(sport, dport): |
| 701 | report.ports[port]["payload_bytes"] += sum(len(c) for c in chunks) |
| 702 | for problem in problems: |
| 703 | if problem == "no_syn" and allow_midflow: |
| 704 | continue |
| 705 | report.incomplete.append({"where": label, "reason": problem}) |
| 706 | for chunk in chunks: |
| 707 | search(chunk, "raw", f"stream {label}", key, "stream") |
| 708 | if forms: |
| 709 | for view, blob in derived_views(chunk): |
| 710 | search(blob, view, f"stream {label}", key, "stream") |
| 711 | row = table.setdefault(key, {"flow": key, "client": None, "server": None, "sni": None, "tls": None, "alert": None, "offered": None, |
| 712 | "bytes": {}, "tls_records": {}, "server_port": min(sport, dport)}) |
| 713 | row["bytes"][f"{src}:{sport}"] = sum(len(c) for c in chunks) |
| 714 | if chunks: |
| 715 | head = chunks[0] |
| 716 | counts = tls_records(head) |
| 717 | if counts: |
| 718 | report.tls_flows += 1 |
| 719 | for k, v in counts.items(): |
| 720 | report.tls_records[k] = report.tls_records.get(k, 0) + v |
| 721 | row["tls_records"][k] = row["tls_records"].get(k, 0) + v |
| 722 | sni = client_hello_sni(head) |
| 723 | if sni: |
| 724 | report.sni_names[sni] = report.sni_names.get(sni, 0) + 1 |
| 725 | row["sni"], row["client"], row["server"] = sni, f"{src}:{sport}", f"{dst}:{dport}" |
| 726 | seen = read_hello(head, label, report, server_hellos) |
| 727 | row["offered"] = seen.get("client") or row["offered"] |
| 728 | row["tls"] = seen.get("server") or row["tls"] |
| 729 | row["alert"] = seen.get("alert") or row["alert"] |
| 730 | elif len(report.non_tls_flows) < 10: |
| 731 | report.non_tls_flows.append({"flow": label, "first_bytes": printable(head, hide=hide)}) |
| 732 | for dgram in datagrams: |
| 733 | key = flow_key(dgram.src, dgram.sport, dgram.dst, dgram.dport) |
| 734 | where = f"datagram {dgram.src}:{dgram.sport} -> {dgram.dst}:{dgram.dport}" |
| 735 | search(dgram.payload, "raw", where, key, "packet") |
| 736 | if UDP_DNS_PORT in (dgram.sport, dgram.dport): |
| 737 | for dns_name in dns_names(dgram.payload): |
| 738 | report.dns_names[dns_name] = report.dns_names.get(dns_name, 0) + 1 |
| 739 | if forms: |
| 740 | search(dns_name.replace(".", "").encode("ascii", errors="replace"), "dns-labels", where, key, "packet") |
| 741 | search(dns_name.encode("ascii", errors="replace"), "dns-name", where, key, "packet") |
| 742 | report.flow_table = sorted(table.values(), key=lambda row: row["flow"])[:MAX_FLOWS_LISTED] |
| 743 | report.tls_summary = tls_policy().summarize(server_hellos) |
| 744 | if truncated: |
| 745 | report.incomplete.append({"where": "capture file", "reason": "the last record of the file is cut"}) |
| 746 | if snapped: |
| 747 | report.incomplete.append({"where": "capture file", "reason": f"{snapped} packet(s) were cut by the snap length"}) |
| 748 | for where, reason in stats_problems(report.stats, len(frames)): |
| 749 | report.incomplete.append({"where": where, "reason": reason}) |
| 750 | report.markers = results |
| 751 | return report |
| 752 | |
| 753 | |
| 754 | def analyse_file(path: Path, markers: Sequence[str] = (), **kwargs) -> PcapReport: |
| 755 | try: |
| 756 | data = Path(path).read_bytes() |
| 757 | except OSError as exc: |
| 758 | raise PcapError(f"cannot read {path}: {exc.strerror or exc}") from None |
| 759 | return analyse(data, markers, name=str(path), **kwargs) |
| 760 | |
| 761 | |
| 762 | def describe(report: PcapReport) -> list: |
| 763 | """Human lines: the verdict first, then the evidence.""" |
| 764 | lines = [f"CAPTURE {report.verdict}: " + { |
| 765 | "CLEAN": f"{len(report.markers)} marker(s)" + (" (secrets included, never shown)" if any(m.secret for m in report.markers) else "") + f" searched in {report.payload_bytes} payload bytes (raw file, every packet, every " |
| 766 | f"reassembled stream): none found", |
| 767 | "FOUND": "a marker (or a secret) crossed the capture point in the clear", |
| 768 | "NO-TRAFFIC": f"no TCP payload was captured ({report.payload_bytes} bytes < {report.min_bytes}): the absence of a marker proves nothing", |
| 769 | "NO-MARKERS": "no marker was given, so nothing was searched for", |
| 770 | "INCOMPLETE": f"the capture lost part of the traffic ({len(report.incomplete)} finding(s) below): a marker could have been in the part that is missing, " |
| 771 | f"so it proves nothing", |
| 772 | }[report.verdict], " " + report.summary()] |
| 773 | for finding in report.incomplete[:10]: |
| 774 | lines.append(f" incomplete: {finding['where']}: {finding['reason']}") |
| 775 | for result in report.markers: |
| 776 | state = "FOUND" if result.found else "absent" |
| 777 | lines.append(f" marker {result.marker!r}: {state} (raw file {result.raw_hits}, packets {result.packet_hits}, " |
| 778 | f"streams {result.stream_hits})" + (f" as {', '.join(result.forms)}" if result.forms else "") + ("; " + "; ".join(result.where) if result.where else "")) |
| 779 | for hello in report.server_hellos: |
| 780 | lines.append(f" ServerHello {hello['flow']}: {hello['version']} {hello['cipher']} group {hello['group']}" + (" (hello retry request)" if hello["hello_retry_request"] else "")) |
| 781 | for alert in report.alerts: |
| 782 | lines.append(f" alert {alert['flow']}: {alert['alert']}") |
| 783 | for row in report.flow_table: |
| 784 | if row["tls"]: |
| 785 | lines.append(f" flow {row['flow']}: SNI {row['sni'] or '-'}, {row['tls']['version']} {row['tls']['cipher']} group {row['tls']['group']}") |
| 786 | for flow in report.non_tls_flows: |
| 787 | lines.append(f" flow that does not start like TLS: {flow['flow']} first bytes {flow['first_bytes']!r}") |
| 788 | return lines |
| 789 | |
| 790 | |
| 791 | def main(argv: Optional[Sequence[str]] = None) -> int: |
| 792 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 793 | parser.add_argument("file", type=Path) |
| 794 | parser.add_argument("--marker", action="append", default=[], help="plaintext marker to look for (repeatable)") |
| 795 | parser.add_argument("--ports", default="", help="only flows that use one of these TCP ports (default: all TCP)") |
| 796 | parser.add_argument("--min-bytes", type=int, default=1, help="TCP payload bytes the capture must hold to count as traffic") |
| 797 | parser.add_argument("--tcpdump-log", type=Path, help="what tcpdump printed when it ended (its packet counters): kernel drops make the capture INCOMPLETE") |
| 798 | parser.add_argument("--allow-midflow", action="store_true", help="accept flows whose handshake is not in the capture (a capture that was already running)") |
| 799 | parser.add_argument("--exact", action="store_true", help="search the marker as it is only, not in its encoded forms (base64, hex, case, unwrapped records, inflated streams)") |
| 800 | parser.add_argument("--json", action="store_true") |
| 801 | args = parser.parse_args(argv) |
| 802 | try: |
| 803 | ports = [int(p) for p in args.ports.split(",") if p.strip()] |
| 804 | stats = parse_tcpdump_stats(args.tcpdump_log.read_text(errors="replace")) if args.tcpdump_log else None |
| 805 | report = analyse_file(args.file, args.marker, ports=ports or None, min_bytes=args.min_bytes, forms=not args.exact, stats=stats, allow_midflow=args.allow_midflow) |
| 806 | except (PcapError, ValueError, OSError) as exc: |
| 807 | print(f"ERROR: {exc}", file=sys.stderr) |
| 808 | return 2 |
| 809 | print(json.dumps(report.to_dict(), indent=2) if args.json else "\n".join(describe(report))) |
| 810 | return report.exit_code |
| 811 | |
| 812 | |
| 813 | if __name__ == "__main__": |
| 814 | sys.exit(main()) |