Troubleshooting · python · 86 copies
PCAP Flow Summary
Turns a packet capture into the short list of flows worth opening in Wireshark. Ranks conversations by severity and then by volume, and separates the findings an expert separates: a receiver advertising zero window (the application is not reading, the network is innocent) from retransmissions on the path, and a SYN that was never answered (look at the far end) from a SYN-ACK the client never completed (look at the client). Declares what the capture itself cannot prove - snaplen truncation, one-directional flows, flows that began before the capture did - instead of giving a verdict it has not earned. A field that could not be read prints as a dash, never as zero. Reads pcap and pcapng in either byte order, offline, stdlib only: the capture never leaves the machine.
- Linux
- macOS
- Windows
# No extra packages — uses Python stdlib only#!/usr/bin/env python3
"""
PCAP Flow Summary
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/pcap-flow-summary
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
# No extra packages — uses Python stdlib only
"""
import argparse
import ipaddress
import json
import os
import struct
import sys
from datetime import datetime, timezone
DASH = "-"
# ---------------------------------------------------------------------------
# Capture file readers. A pcap is UNTRUSTED input: every length field in it
# is attacker- or bug-controlled, so nothing here trusts one without checking
# it against the bytes actually present.
# ---------------------------------------------------------------------------
LINKTYPE_NULL = 0
LINKTYPE_ETHERNET = 1
LINKTYPE_RAW = 101
LINKTYPE_LINUX_SLL = 113
LINKTYPE_IPV4 = 228
LINKTYPE_IPV6 = 229
LINKTYPE_LINUX_SLL2 = 276
class CaptureError(Exception):
"""The file is not a capture we can read at all."""
class Packet:
__slots__ = ("ts", "caplen", "origlen", "data", "linktype")
def __init__(self, ts, caplen, origlen, data, linktype):
self.ts = ts
self.caplen = caplen
self.origlen = origlen
self.data = data
self.linktype = linktype
def _read_exact(fh, n):
buf = fh.read(n)
if len(buf) != n:
return None
return buf
def read_pcap(fh, head):
"""Classic libpcap. Yields Packet, then a dict of file-level facts."""
if head in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"):
endian = ">"
elif head in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"):
endian = "<"
else:
raise CaptureError("not a pcap file")
nanos = head in (b"\xa1\xb2\x3c\x4d", b"\x4d\x3c\xb2\xa1")
rest = _read_exact(fh, 20)
if rest is None:
raise CaptureError("pcap header truncated at byte 24")
_vmaj, _vmin, _tz, _sig, snaplen, linktype = struct.unpack(endian + "HHiIII", rest)
meta = {"format": "pcap", "snaplen": snaplen, "linktypes": {linktype}, "truncated_file": False}
divisor = 1_000_000_000.0 if nanos else 1_000_000.0
while True:
rh = fh.read(16)
if not rh:
break
if len(rh) < 16:
meta["truncated_file"] = True
break
secs, frac, caplen, origlen = struct.unpack(endian + "IIII", rh)
if caplen > 0x400000: # 4 MB: a length field that cannot be believed
meta["truncated_file"] = True
break
data = fh.read(caplen)
if len(data) < caplen:
meta["truncated_file"] = True
break
yield Packet(secs + frac / divisor, caplen, origlen, data, linktype)
yield meta
def read_pcapng(fh):
"""pcapng. Only the blocks that carry packets matter here."""
fh.seek(0)
meta = {"format": "pcapng", "snaplen": 0, "linktypes": set(), "truncated_file": False}
endian = "<"
ifaces = [] # (linktype, tsresol_divisor)
while True:
bh = fh.read(8)
if not bh:
break
if len(bh) < 8:
meta["truncated_file"] = True
break
btype = struct.unpack(endian + "I", bh[:4])[0]
if btype == 0x0A0D0D0A: # Section Header: byte order magic decides endianness
bom = fh.read(4)
if len(bom) < 4:
meta["truncated_file"] = True
break
# 0x1A2B3C4D stored least-significant byte first reads as
# 4d 3c 2b 1a on the wire. Getting this pair the wrong way round
# makes every block length absurd.
if bom == b"\x4d\x3c\x2b\x1a":
endian = "<"
elif bom == b"\x1a\x2b\x3c\x4d":
endian = ">"
else:
raise CaptureError("pcapng section header has no valid byte-order magic")
blen = struct.unpack(endian + "I", bh[4:])[0]
if blen < 16 or blen > 0x1000000:
raise CaptureError("pcapng block length is not believable")
# 8 header bytes and the 4-byte magic are already consumed.
body = fh.read(blen - 12)
if len(body) < blen - 12:
meta["truncated_file"] = True
break
ifaces = []
continue
blen = struct.unpack(endian + "I", bh[4:])[0]
if blen < 12 or blen > 0x1000000:
meta["truncated_file"] = True
break
body = fh.read(blen - 8)
if len(body) < blen - 8:
meta["truncated_file"] = True
break
body = body[:-4] # drop the trailing copy of the block length
if btype == 0x00000001 and len(body) >= 8: # Interface Description
linktype, _res, snaplen = struct.unpack(endian + "HHI", body[:8])
divisor = 1_000_000.0
opts = body[8:]
off = 0
while off + 4 <= len(opts):
ocode, olen = struct.unpack(endian + "HH", opts[off:off + 4])
oval = opts[off + 4:off + 4 + olen]
if ocode == 0:
break
if ocode == 9 and len(oval) == 1: # if_tsresol
raw = oval[0]
if raw & 0x80:
divisor = float(2 ** (raw & 0x7F))
else:
divisor = float(10 ** raw)
off += 4 + olen + ((4 - olen % 4) % 4)
ifaces.append((linktype, divisor))
meta["linktypes"].add(linktype)
if snaplen and (meta["snaplen"] == 0 or snaplen < meta["snaplen"]):
meta["snaplen"] = snaplen
elif btype == 0x00000006 and len(body) >= 20: # Enhanced Packet
iid, tsh, tsl, caplen, origlen = struct.unpack(endian + "IIIII", body[:20])
if iid >= len(ifaces):
continue
linktype, divisor = ifaces[iid]
if caplen > len(body) - 20:
meta["truncated_file"] = True
continue
ts = ((tsh << 32) | tsl) / divisor
yield Packet(ts, caplen, origlen, body[20:20 + caplen], linktype)
elif btype == 0x00000003 and len(body) >= 4: # Simple Packet: no timestamp
origlen = struct.unpack(endian + "I", body[:4])[0]
linktype, _d = ifaces[0] if ifaces else (LINKTYPE_ETHERNET, 1e6)
data = body[4:4 + min(origlen, len(body) - 4)]
yield Packet(None, len(data), origlen, data, linktype)
yield meta
def read_capture(path):
"""Returns (list_of_packets, meta). Raises CaptureError on an unreadable file."""
try:
fh = open(path, "rb")
except OSError as exc:
raise CaptureError(f"cannot open {path}: {exc}") from exc
with fh:
head = fh.read(4)
if len(head) < 4:
raise CaptureError("file is shorter than a capture file header")
if head == b"\x0a\x0d\x0d\x0a":
gen = read_pcapng(fh)
elif head[:2] == b"\x1f\x8b":
raise CaptureError(
"this is a gzip file. Decompress it first: gunzip capture.pcap.gz"
)
else:
gen = read_pcap(fh, head)
packets = []
meta = {}
for item in gen:
if isinstance(item, dict):
meta = item
else:
packets.append(item)
return packets, meta
# ---------------------------------------------------------------------------
# Decoding. Every step returns None rather than raising on a short buffer:
# a truncated packet is normal, not an error.
# ---------------------------------------------------------------------------
ETH_IPV4, ETH_IPV6, ETH_VLAN, ETH_QINQ, ETH_ARP = 0x0800, 0x86DD, 0x8100, 0x88A8, 0x0806
ETH_MPLS = 0x8847
PROTO_NAMES = {1: "ICMP", 6: "TCP", 17: "UDP", 47: "GRE", 50: "ESP", 51: "AH", 58: "ICMPv6", 89: "OSPF", 112: "VRRP", 132: "SCTP"}
WELL_KNOWN = {
20: "ftp-data", 21: "ftp", 22: "ssh", 23: "telnet", 25: "smtp", 53: "dns",
67: "dhcp", 68: "dhcp", 69: "tftp", 80: "http", 88: "kerberos", 110: "pop3",
123: "ntp", 135: "msrpc", 137: "netbios", 139: "netbios", 143: "imap",
161: "snmp", 179: "bgp", 389: "ldap", 443: "https", 445: "smb", 514: "syslog",
520: "rip", 587: "smtp-sub", 636: "ldaps", 993: "imaps", 995: "pop3s",
1433: "mssql", 1521: "oracle", 1701: "l2tp", 1812: "radius", 3306: "mysql",
3389: "rdp", 4500: "ipsec-nat-t", 5060: "sip", 5432: "postgres",
5671: "amqps", 6379: "redis", 8080: "http-alt", 8443: "https-alt", 9200: "elastic",
}
class Decoded:
__slots__ = ("src", "dst", "proto", "sport", "dport", "ip_len", "payload_len",
"tcp", "l4_truncated", "vlan")
def __init__(self):
self.src = self.dst = None
self.proto = None
self.sport = self.dport = None
self.ip_len = None
self.payload_len = None
self.tcp = None
self.l4_truncated = False
self.vlan = None
class TcpBits:
__slots__ = ("seq", "ack", "flags", "window", "wscale_opt", "plen", "sack_blocks")
def __init__(self, seq, ack, flags, window, plen, wscale_opt, sack_blocks):
self.seq = seq
self.ack = ack
self.flags = flags
self.window = window
self.plen = plen
self.wscale_opt = wscale_opt
self.sack_blocks = sack_blocks
def strip_link(data, linktype):
"""Returns (ethertype_or_None, payload) or (None, None) if undecodable."""
if linktype == LINKTYPE_ETHERNET:
if len(data) < 14:
return None, None, None
etype = struct.unpack("!H", data[12:14])[0]
off = 14
vlan = None
hops = 0
while etype in (ETH_VLAN, ETH_QINQ) and hops < 4:
if len(data) < off + 4:
return None, None, None
tci = struct.unpack("!H", data[off:off + 2])[0]
if vlan is None:
vlan = tci & 0x0FFF
etype = struct.unpack("!H", data[off + 2:off + 4])[0]
off += 4
hops += 1
return (etype, data[off:], vlan)
if linktype in (LINKTYPE_RAW, LINKTYPE_IPV4, LINKTYPE_IPV6):
if not data:
return None, None, None
ver = data[0] >> 4
return (ETH_IPV4 if ver == 4 else ETH_IPV6 if ver == 6 else None, data, None)
if linktype == LINKTYPE_NULL:
if len(data) < 4:
return None, None, None
fam = struct.unpack("<I", data[:4])[0]
if fam not in (2, 24, 28, 30):
fam = struct.unpack(">I", data[:4])[0]
return (ETH_IPV4 if fam == 2 else ETH_IPV6, data[4:], None)
if linktype == LINKTYPE_LINUX_SLL:
if len(data) < 16:
return None, None, None
return (struct.unpack("!H", data[14:16])[0], data[16:], None)
if linktype == LINKTYPE_LINUX_SLL2:
if len(data) < 20:
return None, None, None
return (struct.unpack("!H", data[:2])[0], data[20:], None)
return None, None, None
def parse_tcp(buf, payload_available):
if len(buf) < 20:
return None, True
sport, dport, seq, ack, offres, flags, window = struct.unpack("!HHIIBBH", buf[:16])
doff = (offres >> 4) * 4
if doff < 20:
return None, True
wscale = None
sack_blocks = []
if len(buf) >= doff:
opts = buf[20:doff]
i = 0
while i < len(opts):
kind = opts[i]
if kind == 0:
break
if kind == 1:
i += 1
continue
if i + 1 >= len(opts):
break
olen = opts[i + 1]
if olen < 2 or i + olen > len(opts):
break
if kind == 3 and olen == 3:
wscale = opts[i + 2]
elif kind == 5 and olen >= 10:
body = opts[i + 2:i + olen]
for j in range(0, len(body) - 7, 8):
sack_blocks.append(struct.unpack("!II", body[j:j + 8]))
i += olen
truncated = len(buf) < doff
plen = None
if payload_available is not None and not truncated:
plen = max(payload_available - doff, 0)
return (sport, dport,
TcpBits(seq, ack, flags, window, plen, wscale, sack_blocks)), truncated
def decode(pkt):
etype, buf, vlan = strip_link(pkt.data, pkt.linktype)
if etype is None or buf is None:
return None
d = Decoded()
d.vlan = vlan
if etype == ETH_IPV4:
if len(buf) < 20:
return None
ihl = (buf[0] & 0x0F) * 4
if ihl < 20:
return None
total_len = struct.unpack("!H", buf[2:4])[0]
proto = buf[9]
frag = struct.unpack("!H", buf[6:8])[0]
d.src = str(ipaddress.IPv4Address(buf[12:16]))
d.dst = str(ipaddress.IPv4Address(buf[16:20]))
d.proto = proto
d.ip_len = total_len if total_len else None
# The bytes of L4 that WOULD be there if the capture were complete.
l4_claimed = (total_len - ihl) if total_len >= ihl else None
l4_present = len(buf) - ihl
if l4_present < 0:
d.l4_truncated = True
return d
l4 = buf[ihl:]
first_frag = (frag & 0x1FFF) == 0
elif etype == ETH_IPV6:
if len(buf) < 40:
return None
plen = struct.unpack("!H", buf[4:6])[0]
nxt = buf[6]
d.src = str(ipaddress.IPv6Address(buf[8:24]))
d.dst = str(ipaddress.IPv6Address(buf[24:40]))
off = 40
hops = 0
while nxt in (0, 43, 60) and hops < 4:
if len(buf) < off + 8:
d.l4_truncated = True
d.proto = nxt
return d
ext_len = (buf[off + 1] + 1) * 8
nxt = buf[off]
off += ext_len
hops += 1
if nxt == 44: # fragment header
d.proto = nxt
d.ip_len = 40 + plen
return d
d.proto = nxt
d.ip_len = 40 + plen if plen else None
l4_claimed = plen if plen else None
l4 = buf[off:]
first_frag = True
else:
return None
if d.proto == 6 and first_frag:
parsed, trunc = parse_tcp(l4, l4_claimed)
d.l4_truncated = trunc
if parsed:
d.sport, d.dport, d.tcp = parsed
elif d.proto == 17 and first_frag:
if len(l4) >= 8:
d.sport, d.dport = struct.unpack("!HH", l4[:4])
ulen = struct.unpack("!H", l4[4:6])[0]
d.payload_len = max(ulen - 8, 0) if ulen >= 8 else None
else:
d.l4_truncated = True
elif not first_frag:
d.l4_truncated = True
return d
# ---------------------------------------------------------------------------
# Flow accumulation
# ---------------------------------------------------------------------------
FIN, SYN, RST, PSH, ACK = 0x01, 0x02, 0x04, 0x08, 0x10
class Dir:
__slots__ = ("packets", "bytes", "payload_bytes", "retrans", "retrans_known",
"zero_window", "dup_acks", "max_seq", "seen_ranges", "syn", "synack",
"fin", "rst", "last_ack", "ack_run", "min_window", "wscale",
"first_ts", "last_ts", "keepalives", "syn_retries", "l4_seen")
def __init__(self):
self.packets = 0
self.bytes = 0
self.payload_bytes = 0
self.retrans = 0
self.retrans_known = True
self.zero_window = 0
self.dup_acks = 0
self.max_seq = None
self.seen_ranges = set()
self.syn = self.synack = self.fin = self.rst = False
self.last_ack = None
self.ack_run = 0
self.min_window = None
self.wscale = None
self.first_ts = None
self.last_ts = None
self.keepalives = 0
self.syn_retries = 0
self.l4_seen = 0
class Flow:
__slots__ = ("key", "proto", "a", "b", "fwd", "rev", "first_ts", "last_ts",
"truncated_l4", "icmp_types", "vlan")
def __init__(self, key, proto, a, b):
self.key = key
self.proto = proto
self.a = a # (ip, port) -- the side that sent the first packet we saw
self.b = b
self.fwd = Dir()
self.rev = Dir()
self.first_ts = None
self.last_ts = None
self.truncated_l4 = 0
self.icmp_types = set()
self.vlan = None
@property
def packets(self):
return self.fwd.packets + self.rev.packets
@property
def bytes(self):
return self.fwd.bytes + self.rev.bytes
@property
def retrans(self):
# Unknown the moment ANY direction that carried packets had its TCP
# header cut off. "0 retransmissions" and "I could not look" are
# opposite findings; this returns None and the report prints a dash.
for d in (self.fwd, self.rev):
if d.packets and not d.retrans_known:
return None
if self.proto != 6:
return None
return self.fwd.retrans + self.rev.retrans
@property
def zwin(self):
for d in (self.fwd, self.rev):
if d.packets and not d.retrans_known:
return None
if self.proto != 6:
return None
return self.fwd.zero_window + self.rev.zero_window
@property
def headers_readable(self):
return all(d.l4_seen for d in (self.fwd, self.rev) if d.packets)
@property
def syn_retries(self):
return self.fwd.syn_retries + self.rev.syn_retries
@property
def duration(self):
if self.first_ts is None or self.last_ts is None:
return None
return self.last_ts - self.first_ts
def flow_key(d):
pa = d.sport if d.sport is not None else -1
pb = d.dport if d.dport is not None else -1
one = (d.src, pa)
two = (d.dst, pb)
if one <= two:
return (d.proto, one, two), True
return (d.proto, two, one), False
def accumulate(packets, meta, want):
flows = {}
stats = {
"total": len(packets), "decoded": 0, "undecodable": 0, "non_ip": 0,
"truncated_caplen": 0, "at_snaplen": 0, "no_timestamp": 0,
"first_ts": None, "last_ts": None, "l4_truncated": 0,
"proto_bytes": {}, "proto_packets": {}, "fragments": 0,
}
snaplen = meta.get("snaplen") or 0
for pkt in packets:
if pkt.ts is None:
stats["no_timestamp"] += 1
else:
if stats["first_ts"] is None or pkt.ts < stats["first_ts"]:
stats["first_ts"] = pkt.ts
if stats["last_ts"] is None or pkt.ts > stats["last_ts"]:
stats["last_ts"] = pkt.ts
if pkt.origlen and pkt.caplen < pkt.origlen:
stats["truncated_caplen"] += 1
if snaplen and pkt.caplen >= snaplen:
stats["at_snaplen"] += 1
d = decode(pkt)
if d is None or d.src is None:
stats["non_ip"] += 1
continue
stats["decoded"] += 1
name = PROTO_NAMES.get(d.proto, f"IP-{d.proto}")
wire = pkt.origlen or pkt.caplen
stats["proto_bytes"][name] = stats["proto_bytes"].get(name, 0) + wire
stats["proto_packets"][name] = stats["proto_packets"].get(name, 0) + 1
if d.l4_truncated:
stats["l4_truncated"] += 1
if want and not want(d):
continue
key, forward = flow_key(d)
fl = flows.get(key)
if fl is None:
fl = Flow(key, d.proto, key[1], key[2])
fl.vlan = d.vlan
flows[key] = fl
side = fl.fwd if forward else fl.rev
other = fl.rev if forward else fl.fwd
side.packets += 1
side.bytes += wire
if pkt.ts is not None:
if fl.first_ts is None or pkt.ts < fl.first_ts:
fl.first_ts = pkt.ts
if fl.last_ts is None or pkt.ts > fl.last_ts:
fl.last_ts = pkt.ts
if side.first_ts is None:
side.first_ts = pkt.ts
side.last_ts = pkt.ts
if d.l4_truncated:
fl.truncated_l4 += 1
side.retrans_known = False
continue
if d.proto == 6 and d.tcp:
side.l4_seen += 1
tcp_update(side, other, d.tcp)
elif d.proto == 17 and d.payload_len is not None:
side.l4_seen += 1
side.payload_bytes += d.payload_len
elif d.proto in (1, 58):
if len(pkt.data) >= 1:
fl.icmp_types.add(d.proto)
return flows, stats
def tcp_update(side, other, t):
f = t.flags
if f & SYN and not (f & ACK):
side.syn = True
if (f & SYN) and (f & ACK):
side.synack = True
if f & FIN:
side.fin = True
if f & RST:
side.rst = True
if t.wscale_opt is not None:
side.wscale = t.wscale_opt
plen = t.plen or 0
side.payload_bytes += plen
# Zero window: the RECEIVER telling the sender to stop. It is a statement
# by this host's own stack, which is why it is the strongest evidence in
# the whole summary.
if not (f & RST):
scaled = t.window
if side.wscale is not None and not (f & SYN):
scaled = t.window << side.wscale
if side.min_window is None or scaled < side.min_window:
side.min_window = scaled
if t.window == 0 and not (f & (SYN | FIN)):
side.zero_window += 1
# Retransmission: a segment that occupies sequence space we have already
# seen from this direction. Pure ACKs carry no sequence space and are
# never retransmissions.
if f & SYN:
# A repeated SYN or SYN-ACK is a handshake retry, not a data
# retransmission. Counting it as one sends the reader hunting for
# packet loss when the real finding is "nobody answered".
side.syn_retries += 1
if side.syn_retries > 1:
return
consumes = plen > 0 or (f & FIN)
if consumes:
span = plen + (1 if f & SYN else 0) + (1 if f & FIN else 0)
end = (t.seq + span) & 0xFFFFFFFF
if side.max_seq is not None and _seq_le(end, side.max_seq) and (t.seq, end) in side.seen_ranges:
side.retrans += 1
elif side.max_seq is not None and _seq_lt(t.seq, side.max_seq):
side.retrans += 1
side.seen_ranges.add((t.seq, end))
if side.max_seq is None or _seq_lt(side.max_seq, end):
side.max_seq = end
elif f & ACK and plen == 0 and not (f & (SYN | FIN | RST)):
# TCP keepalive: one byte below the expected sequence number, or a
# zero-length segment at seq-1. Counting it as a retransmission is a
# classic false positive.
if side.max_seq is not None and t.seq == ((side.max_seq - 1) & 0xFFFFFFFF):
side.keepalives += 1
if side.last_ack is not None and t.ack == side.last_ack:
side.ack_run += 1
if side.ack_run >= 2:
side.dup_acks += 1
else:
side.ack_run = 0
side.last_ack = t.ack
if f & ACK and plen == 0 and side.last_ack is None:
side.last_ack = t.ack
def _seq_lt(a, b):
return ((a - b) & 0xFFFFFFFF) > 0x80000000
def _seq_le(a, b):
return a == b or _seq_lt(a, b)
# ---------------------------------------------------------------------------
# Judgement. This is the part that is not "parse a pcap".
# ---------------------------------------------------------------------------
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
def classify(fl, capture_is_partial, capture_end=None):
"""Returns (severity, label, explanation) or None when nothing stands out.
Every branch answers the same question: WHAT DOES THIS CAPTURE ACTUALLY
PROVE? A check that could not run returns an INFO saying so, never a
finding.
"""
if fl.proto != 6:
return None
if not fl.headers_readable:
return ("INFO", "TCP headers not captured",
"the snaplen cut this flow's TCP headers, so handshake, "
"retransmissions and window could not be read. Nothing about "
"this flow is being claimed - recapture with -s 0")
a, b = fl.fwd, fl.rev
unidirectional = a.packets == 0 or b.packets == 0
initiator = a if a.syn else (b if b.syn else None)
saw_syn = a.syn or b.syn
saw_synack = a.synack or b.synack
# Did the capture simply stop? You cannot conclude "no reply" from a flow
# whose last packet is in the last second of the file.
ends_at_edge = (capture_end is not None and fl.last_ts is not None
and (capture_end - fl.last_ts) < 1.0)
if saw_syn and not saw_synack:
if ends_at_edge and fl.syn_retries <= 1:
return ("INFO", "SYN at the very end of the capture",
"no reply, but the capture stops here - there was no time "
"for one. Recapture with a longer window before concluding")
extra = " after %d attempt(s)" % fl.syn_retries if fl.syn_retries > 1 else ""
if unidirectional:
return ("HIGH", "SYN, no reply seen" + extra,
"nothing came back - but only one direction is in this "
"capture, so this may be the capture point and not the host")
return ("HIGH", "SYN unanswered" + extra,
"the destination never sent SYN-ACK: filtered in the path, or "
"nothing listening on that port. Look at the FAR END")
if saw_syn and saw_synack:
completed = initiator is not None and initiator.packets > 1
if not completed and not ends_at_edge:
return ("HIGH", "half-open: SYN-ACK never acknowledged",
"the server answered and the client never completed. Look "
"at the CLIENT or at the return path, not at the server")
zw = fl.zwin or 0
rt = fl.retrans
if zw > 0:
who = "both ends" if (a.zero_window and b.zero_window) else (
fmt_ep(fl.a if a.zero_window else fl.b))
tail = ""
if rt:
tail = (" The %d resend(s) here are window probes and stalled "
"data, not evidence of loss." % rt)
return ("HIGH", "zero window advertised by %s (%dx)" % (who, zw),
"the receiver's own stack said its buffer is full. The "
"application is not reading fast enough - this is the strongest "
"evidence in the summary that the NETWORK is innocent." + tail)
if rt is None:
return ("INFO", "retransmissions unknown",
"TCP headers were cut in this flow, so retransmissions could "
"not be counted. Shown as a dash, never as zero")
data_pkts = max(a.packets + b.packets, 1)
if rt > 0:
pct = 100.0 * rt / data_pkts
if pct >= 2.0:
return ("MEDIUM", "%d retransmission(s), %.1f%% of packets" % (rt, pct),
"segments were resent. One capture point cannot say WHERE "
"they were lost, and the heaviest talker is as often the "
"VICTIM of congestion as its cause. Capture at both ends "
"before blaming this host")
return ("LOW", "%d retransmission(s), %.1f%% of packets" % (rt, pct),
"below the level worth chasing on a WAN path on its own")
if a.rst or b.rst:
side = fmt_ep(fl.a if a.rst else fl.b)
return ("MEDIUM", "reset by " + side,
"torn down with RST rather than FIN. A RST whose TTL differs "
"from the rest of the flow was forged by a middlebox - this "
"script does not compare TTLs, tshark does")
if unidirectional and fl.packets > 2:
return ("MEDIUM", "one direction only",
"no packets came back. This almost never means one-way traffic; "
"it means the capture point or the capture filter missed the "
"return path")
if not (a.fin or b.fin or a.rst or b.rst) and not capture_is_partial:
return ("LOW", "no close seen",
"neither FIN nor RST. The flow was still open when the capture "
"stopped, or the close is outside the capture window")
return None
def fmt_ep(ep):
ip, port = ep
if ":" in ip and port >= 0:
return f"[{ip}]:{port}"
if port < 0:
return ip
return f"{ip}:{port}"
def service_of(fl):
ports = [p for p in (fl.a[1], fl.b[1]) if p is not None and p >= 0]
for p in sorted(ports):
if p in WELL_KNOWN:
return WELL_KNOWN[p]
return None
# ---------------------------------------------------------------------------
# Presentation
# ---------------------------------------------------------------------------
def human_bytes(n):
if n is None:
return DASH
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024 or unit == "TB":
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024.0
return DASH
def human_dur(s):
if s is None:
return DASH
if s < 1:
return f"{s * 1000:.0f}ms"
if s < 60:
return f"{s:.2f}s"
m, sec = divmod(s, 60)
if m < 60:
return f"{int(m)}m{sec:04.1f}s"
h, m = divmod(int(m), 60)
return f"{h}h{m:02d}m"
def iso(ts):
if ts is None:
return DASH
return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + "Z"
def capture_quality(stats, meta, flows):
"""Facts about the CAPTURE, not about the network. Printed before any
verdict, because they decide how much the verdicts are worth."""
notes = []
snaplen = meta.get("snaplen") or 0
if stats["truncated_caplen"]:
notes.append(
f"{stats['truncated_caplen']} packet(s) were cut short by the capture "
f"snaplen ({snaplen if snaplen else DASH} bytes). Payload byte counts "
"below come from the IP header length field, not from what was stored.")
if stats["l4_truncated"]:
notes.append(
f"{stats['l4_truncated']} packet(s) had their TCP/UDP header cut off. "
"Retransmissions and window sizes cannot be counted for those flows "
"and are shown as '-', never as zero.")
uni = [f for f in flows.values() if f.fwd.packets == 0 or f.rev.packets == 0]
if uni:
notes.append(
f"{len(uni)} of {len(flows)} flow(s) have traffic in ONE direction only. "
"That is almost never one-way traffic - it means the capture point or "
"the capture filter did not see the return path.")
tcp_flows = [f for f in flows.values() if f.proto == 6]
readable = [f for f in tcp_flows if f.headers_readable]
midstream = [f for f in readable if not (f.fwd.syn or f.rev.syn)]
if midstream:
notes.append(
f"{len(midstream)} of {len(readable)} readable TCP flow(s) have no SYN: "
"capture started after they did. Their byte totals are partial and "
"their handshake cannot be judged.")
noports = [f for f in flows.values() if f.a[1] < 0 or f.b[1] < 0]
if noports:
notes.append(
f"{len(noports)} flow(s) have no port numbers, because the L4 header "
"was not captured. Everything between the same pair of addresses is "
"merged into one row there - that row is an address pair, not a "
"conversation.")
if meta.get("truncated_file"):
notes.append(
"The capture FILE itself ends mid-record - it was cut off while being "
"written, or copied while still open. Everything after that point is missing.")
if stats["no_timestamp"]:
notes.append(
f"{stats['no_timestamp']} packet(s) carry no timestamp (pcapng Simple "
"Packet Blocks). They are counted but cannot be placed in time.")
if stats["non_ip"]:
notes.append(
f"{stats['non_ip']} packet(s) were not IPv4/IPv6 or could not be "
"decoded (ARP, STP, LLDP, unsupported link type). They are excluded "
"from flows but counted in the total.")
return notes
def print_report(flows, stats, meta, args, path):
out = []
w = out.append
rule = "=" * 78
w(rule)
w(f"CAPTURE {os.path.basename(path)}")
w(rule)
span = None
if stats["first_ts"] is not None and stats["last_ts"] is not None:
span = stats["last_ts"] - stats["first_ts"]
w(f" format {meta.get('format', DASH)} "
f"snaplen {meta.get('snaplen') or DASH} "
f"linktype {','.join(str(x) for x in sorted(meta.get('linktypes') or [])) or DASH}")
w(f" packets {stats['total']} ({stats['decoded']} IP, {stats['non_ip']} other)")
w(f" first packet {iso(stats['first_ts'])}")
w(f" last packet {iso(stats['last_ts'])}")
w(f" span {human_dur(span)}")
w(f" flows {len(flows)}")
w("")
w(" All times are UTC and come from the CAPTURE POINT's clock, not from")
w(" the sender. Two captures taken on two boxes do not share a clock.")
w("")
notes = capture_quality(stats, meta, flows)
w(rule)
w("CAPTURE QUALITY")
w(rule)
if notes:
for n in notes:
w(" [!] " + wrap(n, 72, " "))
else:
w(" No truncation, no one-sided flows, every TCP flow has its handshake.")
w(" This capture supports the conclusions below.")
w("")
if not flows:
w(" No flows matched. Nothing to summarise.")
print("\n".join(out))
return
ranked = []
for fl in flows.values():
verdict = classify(fl, bool(notes), stats["last_ts"])
ranked.append((fl, verdict))
anomalies = [(f, v) for f, v in ranked if v and v[0] in ("HIGH", "MEDIUM", "LOW")]
anomalies.sort(key=lambda t: (SEV_ORDER[t[1][0]], -t[0].bytes))
w(rule)
w("FLOWS WORTH LOOKING AT")
w(rule)
unjudged = [f for f, v in ranked if v and v[0] == "INFO"]
if not anomalies:
if len(unjudged) == len([f for f in flows.values() if f.proto == 6]) and unjudged:
w(f" Nothing is being claimed: {len(unjudged)} TCP flow(s) could not be")
w(" judged at all - see CAPTURE QUALITY. This is NOT a clean bill of")
w(" health, it is an unreadable capture.")
else:
w(" Nothing stood out in the flows that could be judged. Handshakes")
w(" completed, nothing retransmitted, no zero window advertised.")
if unjudged:
w(f" {len(unjudged)} flow(s) could not be checked - see CAPTURE QUALITY.")
else:
for fl, (sev, label, why) in anomalies[:args.top]:
svc = service_of(fl)
w(f" [{sev:<6}] {fmt_ep(fl.a)} <-> {fmt_ep(fl.b)}"
f" {PROTO_NAMES.get(fl.proto, fl.proto)}{'/' + svc if svc else ''}")
w(f" {label}")
w(f" {wrap(why, 64, ' ')}")
w(f" {fl.packets} pkt {human_bytes(fl.bytes)} "
f"{human_dur(fl.duration)} retrans "
f"{fl.retrans if fl.retrans is not None else DASH} zwin "
f"{fl.zwin if fl.zwin is not None else DASH}")
w("")
if len(anomalies) > args.top:
w(f" ... and {len(anomalies) - args.top} more. Raise --top to see them.")
w("")
if unjudged:
w(f" {len(unjudged)} further flow(s) could not be judged at all - see")
w(" CAPTURE QUALITY. They are not clean, they are unreadable.")
w("")
w("")
w(rule)
w("TOP FLOWS BY VOLUME")
w(rule)
w(table(sorted(flows.values(), key=lambda f: -f.bytes)[:args.top]))
w("")
timed = [f for f in flows.values() if f.duration is not None]
w(rule)
w("LONGEST FLOWS")
w(rule)
if timed:
w(table(sorted(timed, key=lambda f: -f.duration)[:args.top]))
else:
w(" No flow carries timestamps on both ends.")
w("")
w(rule)
w("PROTOCOL AND ENDPOINT BREAKDOWN")
w(rule)
if build_filter(args) is not None:
w(" The protocol table counts the WHOLE capture. The endpoint list and")
w(" everything above it count only the flows your filter kept.")
w("")
total_b = sum(stats["proto_bytes"].values()) or 1
for name, b in sorted(stats["proto_bytes"].items(), key=lambda kv: -kv[1]):
w(f" {name:<8} {stats['proto_packets'][name]:>7} pkt "
f"{human_bytes(b):>9} {100.0 * b / total_b:5.1f}%")
w("")
ep = {}
for fl in flows.values():
for side, d in ((fl.a, fl.fwd), (fl.b, fl.rev)):
ep[side[0]] = ep.get(side[0], 0) + d.bytes
w(" Busiest endpoints:")
for ip, b in sorted(ep.items(), key=lambda kv: -kv[1])[:args.top]:
w(f" {ip:<40} {human_bytes(b):>9}")
w("")
sev_count = {}
for _f, v in anomalies:
sev_count[v[0]] = sev_count.get(v[0], 0) + 1
w(rule)
w(" " + ", ".join(f"{sev_count.get(s, 0)} {s}" for s in ("HIGH", "MEDIUM", "LOW"))
+ f" across {len(flows)} flow(s)."
+ (" Read CAPTURE QUALITY above before acting on any of it."
if notes else ""))
w(rule)
print("\n".join(out))
def table(flows):
lines = [f" {'FLOW':<47} {'PKT':>6} {'BYTES':>9} {'DURATION':>9} {'RETR':>5} {'ZWIN':>5}"]
for fl in flows:
name = f"{fmt_ep(fl.a)} <-> {fmt_ep(fl.b)}"
if len(name) > 47:
name = name[:44] + "..."
lines.append(
f" {name:<47} {fl.packets:>6} {human_bytes(fl.bytes):>9} "
f"{human_dur(fl.duration):>9} "
f"{(str(fl.retrans) if fl.retrans is not None else DASH):>5} "
f"{(str(fl.zwin) if fl.zwin is not None else DASH):>5}")
return "\n".join(lines)
def wrap(text, width, indent):
words = text.split()
lines, cur = [], ""
for word in words:
if cur and len(cur) + 1 + len(word) > width:
lines.append(cur)
cur = word
else:
cur = f"{cur} {word}".strip()
if cur:
lines.append(cur)
return ("\n" + indent).join(lines)
def to_json(flows, stats, meta, notes):
return {
"generated_utc": datetime.now(timezone.utc).isoformat(),
"capture": {
"format": meta.get("format"),
"snaplen": meta.get("snaplen") or None,
"packets": stats["total"],
"ip_packets": stats["decoded"],
"first_packet_utc": iso(stats["first_ts"]) if stats["first_ts"] else None,
"last_packet_utc": iso(stats["last_ts"]) if stats["last_ts"] else None,
"file_truncated": bool(meta.get("truncated_file")),
},
"capture_quality": notes,
"flows": [
{
"proto": PROTO_NAMES.get(f.proto, f.proto),
"a": fmt_ep(f.a), "b": fmt_ep(f.b),
"packets": f.packets, "bytes": f.bytes,
"duration_s": round(f.duration, 6) if f.duration is not None else None,
"retransmissions": f.retrans,
"zero_window": f.zwin,
"verdict": (lambda v: {"severity": v[0], "label": v[1], "detail": v[2]}
if v else None)(classify(f, bool(notes), stats["last_ts"])),
}
for f in sorted(flows.values(), key=lambda x: -x.bytes)
],
}
def build_filter(args):
hosts = set(args.host or [])
ports = set(args.port or [])
proto = args.proto.lower() if args.proto else None
if not hosts and not ports and not proto:
return None
pmap = {"tcp": 6, "udp": 17, "icmp": 1, "icmpv6": 58}
def want(d):
if hosts and d.src not in hosts and d.dst not in hosts:
return False
if ports and d.sport not in ports and d.dport not in ports:
return False
if proto and d.proto != pmap.get(proto, -1):
return False
return True
return want
def main():
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
p = argparse.ArgumentParser(
description="Summarise a packet capture into the flows worth looking at.")
p.add_argument("capture", help="path to a .pcap or .pcapng file")
p.add_argument("--top", type=int, default=10, help="rows per table (default 10)")
p.add_argument("--host", action="append", help="only flows involving this IP (repeatable)")
p.add_argument("--port", action="append", type=int, help="only flows on this port (repeatable)")
p.add_argument("--proto", choices=["tcp", "udp", "icmp", "icmpv6"], help="only this protocol")
p.add_argument("--min-bytes", type=int, default=0, help="ignore flows below this many bytes")
p.add_argument("--json-out", help="write the full summary to a JSON file")
p.add_argument("--fail-on", choices=["high", "medium", "low", "never"], default="high",
help="lowest severity that makes the script exit 1 (default high)")
args = p.parse_args()
if args.top < 1:
print("--top must be at least 1", file=sys.stderr)
return 2
try:
packets, meta = read_capture(args.capture)
except CaptureError as exc:
print(f"[!] {exc}", file=sys.stderr)
return 2
except MemoryError:
print("[!] the capture does not fit in memory. Split it first:\n"
" editcap -c 200000 big.pcap part.pcap", file=sys.stderr)
return 2
if not packets:
print("[!] the capture contains no packets.", file=sys.stderr)
return 2
flows, stats = accumulate(packets, meta, build_filter(args))
if args.min_bytes:
flows = {k: v for k, v in flows.items() if v.bytes >= args.min_bytes}
print_report(flows, stats, meta, args, args.capture)
notes = capture_quality(stats, meta, flows)
if args.json_out:
try:
with open(args.json_out, "w", encoding="utf-8") as fh:
json.dump(to_json(flows, stats, meta, notes), fh, indent=2)
print(f"\n[+] JSON written to {args.json_out}")
except OSError as exc:
print(f"[!] could not write {args.json_out}: {exc}", file=sys.stderr)
return 2
if args.fail_on == "never":
return 0
threshold = SEV_ORDER[args.fail_on.upper()]
for fl in flows.values():
v = classify(fl, bool(notes), stats["last_ts"])
if v and SEV_ORDER[v[0]] <= threshold:
return 1
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n[!] interrupted", file=sys.stderr)
sys.exit(130)
capturetextrequiredThe capture to summarise. pcap or pcapng, either byte order, microsecond or nanosecond timestamps. Read-only and never uploaded anywhere.
--topnumberHow many findings and how many rows per table to print. Default 10, minimum 1.
--hosttextOnly flows involving this IPv4 or IPv6 address. Repeatable, so passing it twice pins both ends of a conversation.
--portnumberOnly flows using this port on either side. Repeatable.
--prototextOnly this protocol. One of tcp, udp, icmp, icmpv6.
--min-bytesnumberIgnore flows below this many bytes. The fast way to clear single-packet noise off a busy segment.
--json-outtextWrite the whole summary - capture quality notes and every verdict - as JSON. This is what you attach to a ticket.
--fail-ontextLowest severity that makes the script exit 1. One of high, medium, low, never. Default high.
What This Solves
You have a capture. It is 400 MB, it has 1.2 million packets in it, and somewhere inside is the reason a user says "the application is slow". You open Wireshark and you are looking at packet 1 of 1,200,000. This script does not try to replace Wireshark. It answers the one question Wireshark cannot answer for you in the first five minutes: WHERE SHOULD I LOOK? It collapses the capture into conversations, ranks them, and puts the ones that are behaving badly at the top with a sentence saying what kind of bad they are. The ranking is the product. Three judgements in it are the difference between a summary and a verdict: A flow with many retransmissions may be the victim, not the problem The heaviest talker is as often the thing being starved as the thing doing the starving. So retransmissions are reported as a count and a percentage, never as "this host is causing packet loss" - because one capture point cannot possibly know that. Zero window is the strongest evidence in the whole report A receiver advertising window 0 is that machine's own TCP stack saying "my buffer is full, stop sending". That is not network loss. It is an application that is not reading its socket fast enough, and it proves the network is innocent for that flow. When both appear together, the zero window is reported and the resends are explained as its consequence. A SYN with no SYN-ACK and a SYN-ACK with no final ACK are not the same The first says the far end never answered: filtered in the path, or nothing listening. Look at the destination. The second says the far end answered perfectly and the CLIENT never completed, which points at the client or at the return path. Same "connection failed" to a user, opposite directions to investigate. Use it as the first pass on any capture, before you have a theory. It is also what you run on the capture somebody sent you, to find out whether it is even worth analysing.
Is It Safe to Run
Yes, in two senses. It is read-only on the file. It opens the capture, reads it, and never writes to it. The only file it ever creates is the one you ask for with --json-out. It is also offline. It does not connect to anything, does not resolve DNS names, does not look anything up on the internet, and does not transmit a single byte anywhere. Your capture does not leave the machine. That is deliberate, and it is what makes it usable on a production capture that contains customer traffic, session cookies and credentials. It touches no network device at all, so there is no inventory file and no credentials. The only argument it needs is a path. It is written to survive hostile input. A capture file is UNTRUSTED data: packets are malformed in the wild, length fields lie, and files get copied while still being written. Nothing here believes a length field without checking it against the bytes actually present, so a corrupt file produces a message and exit code 2, not a stack trace.
Before You Start
Two things. 1. Python 3.9 or newer. Check what you have: python --version 2. Nothing else. This script imports only the Python standard library - there is no pip install step, no Npcap, no Wireshark, no tshark. That is on purpose: the machine holding a sensitive capture is often the one you are least able to install packages on. You need a capture file. If you do not have one yet, take a small one: Linux or macOS sudo tcpdump -i eth0 -s 0 -w /tmp/capture.pcap -c 20000 Windows (Wireshark) Capture, then File, Save As, pcapng A Cisco or Arista switch, then copy the file off monitor capture CAP interface Gi1/0/1 both monitor capture CAP start The -s 0 matters. It tells tcpdump to store whole packets. The default on some systems clips them, and a clipped packet loses the TCP header this script needs. See When Something Fails.
Your First Run
Start with a small capture, not the 400 MB one. Three steps. Step 1 - put the script and the capture in the same folder. Nothing else is needed: pcap_flow_summary.py capture.pcap Step 2 - run it: python pcap_flow_summary.py capture.pcap Step 3 - read the CAPTURE QUALITY block FIRST, before any finding. It tells you what this particular capture is able to prove. If it says half your flows are one-directional, nothing below it about those flows means what it appears to mean. That is the whole first run. No flags, no configuration, no network access. When the list is too long, narrow it: python pcap_flow_summary.py capture.pcap --host 10.0.0.13 python pcap_flow_summary.py capture.pcap --port 1521 --top 5
What the Output Means
A run against a small capture with several deliberate problems in it: $ python pcap_flow_summary.py capture.pcap --top 3 ============================================================== CAPTURE capture.pcap ============================================================== format pcap snaplen 262144 linktype 1 packets 67 (66 IP, 1 other) first packet 2026-10-14 17:46:40.000Z last packet 2026-10-14 17:46:49.799Z span 9.80s flows 10 All times are UTC and come from the CAPTURE POINT's clock, not from the sender. Two captures taken on two boxes do not share a clock. ============================================================== CAPTURE QUALITY ============================================================== [!] 4 of 10 flow(s) have traffic in ONE direction only. That is almost never one-way traffic - it means the capture point or the capture filter did not see the return path. [!] 3 of 9 readable TCP flow(s) have no SYN: capture started after they did. Their byte totals are partial and their handshake cannot be judged. [!] 1 packet(s) were not IPv4/IPv6 or could not be decoded (ARP, STP, LLDP, unsupported link type). ============================================================== FLOWS WORTH LOOKING AT ============================================================== [HIGH ] 10.0.0.13:53000 <-> 172.16.9.9:1521 TCP/oracle zero window advertised by 172.16.9.9:1521 (3x) the receiver's own stack said its buffer is full. The application is not reading fast enough - this is the strongest evidence in the summary that the NETWORK is innocent. The 2 resend(s) here are window probes and stalled data, not evidence of loss. 14 pkt 11.7KB 600ms retrans 2 zwin 3 [HIGH ] 10.0.0.10:51001 <-> 10.9.9.9:3389 TCP/rdp SYN, no reply seen after 3 attempt(s) nothing came back - but only one direction is in this capture, so this may be the capture point and not the host 3 pkt 162B 3.00s retrans 0 zwin 0 ... and 5 more. Raise --top to see them. ============================================================== TOP FLOWS BY VOLUME ============================================================== FLOW PKT BYTES DURATION RETR ZWIN 10.0.0.12:52000 <-> 172.16.5.5:445 22 21.7KB 630ms 3 0 10.0.0.13:53000 <-> 172.16.9.9:1521 14 11.7KB 600ms 2 3 10.0.0.10:51000 <-> 93.184.216.34:443 9 2.3KB 211ms 0 0 ============================================================== 3 HIGH, 4 MEDIUM, 0 LOW across 10 flow(s). Read CAPTURE QUALITY above before acting on any of it. ============================================================== Reading it block by block: CAPTURE QUALITY comes before the findings on purpose It is a statement about the capture, not about the network, and it decides how much everything below is worth. A capture is truncated or filtered far more often than anyone admits. If the TCP headers were cut off by the snaplen, this script says so and then refuses to judge those flows at all - it does not quietly report them as clean. A dash means NOT MEASURED. It never means zero In the RETR and ZWIN columns, 0 means "I counted, and the answer was none". A dash means "the header I needed was not in the file". Those are opposite findings, and treating the second as the first is how a broken link gets reported as healthy. FLOWS WORTH LOOKING AT is sorted by severity, then by volume Not by packet count. The top line is the flow a human should open in Wireshark first. Severities HIGH zero window, SYN never answered, or a handshake that the server completed and the client did not MEDIUM retransmissions at 2% of packets or more, a reset instead of a clean close, or a flow that is one-directional LOW a trickle of retransmissions, or no close seen INFO the check could not run - printed separately and never counted as a finding One-directional flows are a statement about your capture point A flow with traffic in one direction almost never means the traffic was one-way. It means you captured on the wrong port, on the wrong side of a NAT, or with a capture filter that matched one direction. Move the capture point before you conclude anything.
What It Cannot Tell You
Stated plainly, because a summary that hides its limits is worse than no summary. It cannot tell you WHERE a packet was lost. Loss is a claim about two points, and one capture has one point. A retransmission seen at the sender means the sender resent; whether the original ever reached the receiver is unknowable from this file. Capture at both ends. It cannot tell you that something did not happen. Absence in a capture is absence FROM THE CAPTURE. A SPAN port that is oversubscribed silently drops what it cannot copy, so the loss you are looking at may exist only in your capture and not on the wire. The timestamps are the capture point's clock, not the sender's. Two captures taken on two machines do not share a clock, and comparing them to the millisecond is meaningless unless both are disciplined to the same source. It does not decrypt anything, does not reassemble application streams, and does not read HTTP, DNS or TLS contents. Port numbers are labelled from the well-known list, which is a guess about the service, not proof of it. It does not compare TTLs, so it cannot tell you that a RST was forged by a middlebox. If a flow is reset and you suspect injection, check in tshark whether the RST's TTL matches the rest of that peer's packets. It does not detect checksum errors, and that is deliberate. A capture taken on the sending host itself routinely shows bad checksums on its own outgoing packets, because the NIC had not computed them yet. It is the most common false alarm in packet analysis and this script will not manufacture it.
Exit Codes for Cron and CI
The script returns a code, so a pipeline or a cron job can act on it without a human reading the output: 0 nothing at or above the --fail-on severity was found 1 at least one finding at or above --fail-on (default: HIGH) 2 the capture could not be read, or the arguments were wrong 130 you pressed Ctrl-C Note what 0 does NOT mean. A capture whose TCP headers were truncated produces no findings and exits 0, because nothing was verified as bad. It is not a clean bill of health, and the report says so in words. If you are gating on this in CI, read the CAPTURE QUALITY block from --json-out as well as the exit code. Checking a batch of captures overnight, keeping the evidence: for f in /captures/*.pcap; do python pcap_flow_summary.py "$f" --json-out "$f.json" || \ echo "FINDINGS in $f" done
Capture Files It Reads
Both of the formats tcpdump and Wireshark write, in either byte order and with microsecond or nanosecond timestamps: .pcap classic libpcap, big and little endian, usec and nsec .pcapng the Wireshark default, Enhanced and Simple Packet Blocks Compressed files are not read. Decompress first: gunzip capture.pcap.gz Link layers it decodes: Ethernet including 802.1Q and QinQ VLAN tags Linux cooked SLL and SLL2, what you get capturing on "any" Raw IP IPv4 and IPv6 with no link header BSD loopback the null/loopback encapsulation Above that: IPv4 and IPv6 (including extension headers), TCP, UDP, and a packet and byte count for every other IP protocol by number. ARP, STP, LLDP and anything else non-IP are counted and then excluded from the flow table, and the report says how many. A capture from a device that is not on this list still parses - the packets simply land in the "other" count rather than in a flow.
Every Flag
capture REQUIRED, positional. Path to the .pcap or .pcapng file. Nothing else is required. --top N How many rows per table, and how many findings to print. Default 10. Must be at least 1. --host IP Only flows involving this address. Repeatable: pass it twice to pin both ends of a conversation. --port N Only flows using this port, on either side. Repeatable. --proto NAME One of tcp, udp, icmp, icmpv6. --min-bytes N Drop flows smaller than this. The fast way to clear out the single-packet noise on a busy segment. --json-out PATH Write the whole summary, including the capture quality notes and every verdict, as JSON. This is what you attach to a ticket. --fail-on LEVEL Lowest severity that makes the exit code 1: high, medium, low, or never. Default high. Use never when you want the report but always want exit 0. Filters apply to the flow tables and the findings. The protocol breakdown always counts the whole capture, and says so when a filter is active.
When Something Fails
"not a pcap file" The file is not a capture, or it is a format this does not read. Check it: the first four bytes of a pcap are d4c3b2a1 or a1b2c3d4, and of a pcapng are 0a0d0d0a. A ".pcap" that is actually a text export of Wireshark will land here. "this is a gzip file" Exactly what it says. Run gunzip on it and pass the result. "the capture contains no packets" The file has a valid header and nothing after it. Usually a capture that was started and stopped before anything matched the capture filter. "The capture FILE itself ends mid-record" The file was copied while tcpdump was still writing it, or the disk filled. Everything up to that point is still analysed; everything after is gone. Recapture, or copy again after stopping the capture. Every flow shows a dash for RETR and ZWIN The snaplen cut the TCP headers off. Look at the snaplen in the capture header line: if it is 96 or 128 or 68, that is the cause. Recapture with -s 0. Nothing can recover headers that were never stored. Everything is one-directional You are capturing in the wrong place. On a switch, a SPAN session with only one direction configured does this; so does capturing on an access port for traffic that is routed elsewhere; so does a capture filter written with "src host" instead of "host". "the capture does not fit in memory" The whole file is read before it is summarised. Split it: editcap -c 200000 big.pcap part.pcap then run the script on each part. A per-part summary is usually enough to find the flow you then extract from the original. The report is clean but users still complain Read CAPTURE QUALITY again. A clean report on a capture that is truncated, filtered or taken at the wrong point is a statement about the capture, not about the network. The commonest outcome of a first pass is not a diagnosis - it is knowing where to capture next.