Skip to main content
pcap-doctor

CI Python tshark License: MIT Telemetry: none

Hand it a capture. It scores it, tells you who talked to whom, what crypto they negotiated, what is weak or leaking, and how to fix it.

pcap-doctor is an offline doctor for .pcap and .pcapng files, built for IoT, OTA and network-device traffic. One command gives a 0-100 health score, findings grouped by category, and six report files you can paste into a ticket. Every claim points at a frame number you can open in Wireshark. It gates CI on new findings only, and hands a finding to Claude Code, Codex or Cursor with a fix prompt when you want help.

No network access during analysis. No telemetry. Secrets it finds are reported, never repeated. (pf still works as a short alias of pcap-doctor.)

Get started

1. Install

You need tshark on PATH (and, optionally, openssl to read certificates exactly):

brew install wireshark                 # macOS
sudo apt-get install -y tshark         # Debian / Ubuntu

Then run pcap-doctor without installing it, or install it:

uvx pcap-doctor analyze traffic.pcap   # Python, via uv
npx pcap-doctor analyze traffic.pcap   # Node launcher: runs the same version through uvx or pipx
pip install pcap-doctor                # or pipx install pcap-doctor

pcap-doctor doctor checks that tshark works and knows the fields the detectors need.

2. Run your first audit

pcap-doctor analyze traffic.pcap
capture traffic.pcap: 5 packets, 1 flows, 2 hosts
Score 68/100 · Needs work

Crypto  4 finding(s) · worst high
  high     TLS_CIPHER_WEAK  TLS_RSA_WITH_AES_128_CBC_SHA negotiated on tcp:10.0.0.10:40000<->10.0.0.20:443
  high     TLS_NO_FORWARD_SECRECY  No forward secrecy on tcp:10.0.0.10:40000<->10.0.0.20:443
  high     TLS_VERSION_DEPRECATED  TLS 1.0 negotiated on tcp:10.0.0.10:40000<->10.0.0.20:443
  +1 more (--verbose shows all)

  wrote traffic.pf-report/01-flows.md
  ...

The full report lands in traffic.pf-report/ (--out DIR to choose); start with index.md. No capture to hand? make captures fetches the Wireshark project's public samples.

3. Understand a finding

pcap-doctor why 4                               # every finding that cites frame 4
pcap-doctor why d1.tls_cipher.TLS_CIPHER_WEAK   # or an id prefix: evidence, fix, references, rule text
pcap-doctor rules explain TLS_CIPHER_WEAK       # what a code means, why it matters, how to fix and verify
pcap-doctor rules list --category Crypto        # the whole catalog, by category

4. Gate CI on new findings

pcap-doctor ci install      # writes .github/workflows/pcap-doctor.yml

On every pull request that touches a capture, the workflow compares each capture with its copy on the base branch, comments one summary on the PR, uploads SARIF to code scanning, and fails only when a new finding reaches --fail-on (default high). See CI.

5. Review it, or fix it with an AI agent

In a terminal, a scan ends on an interactive screen, for security testers and developers alike:

  ┌─────┐  68 / 100 Needs work  ·  device-boot.pcap
  │ o o │  ██████████████████████████████████░░░░░░░░░░░░░░░░
  │  ▭  │  pcap-doctor 0.5.0 · 1,204 packets, 18 flows, 6 hosts
  └─────┘
  Potential score 95 after priority fixes +27

❯ Review 7 finding(s)
› Add to GitHub Actions (Recommended)
› Hand off to an agent

↑/↓ move · enter select · q quit

Review lists the findings by category and shows the selected one's impact, evidence frames, fix, references and rule guide; enter copies it as ticket-ready text, h hands it to an agent. Hand off launches Claude Code, Codex or Cursor with a fix prompt, or copies or shows it. Read the AI mode warning first: launched agents skip their approval prompts unless you pass --safe. Piped output, files, CI, --json, -q and --score never show these screens.

pcap-doctor install         # teach Claude Code, Cursor and Codex how to use pcap-doctor in this repo

6. Configure it

# pcap-doctor.toml (or [tool.pcap-doctor] in pyproject.toml)
profile = "ota"                         # a preset: keep Crypto/Credentials/Cleartext/DNS, fail on high
disable = ["DNS_EXTERNAL_RESOLVER"]

[severity]
TLS_CERT_EXPIRING = "low"

[[allow]]
code = "TLS_CIPHER_WEAK"
subject = "10.0.0.20*"
reason = "legacy appliance, replacement tracked in OPS-12"

See Configuration.


Table of contents


What it catches

Findings are grouped into eight categories, the same ones --category, the summary and the config use.

Category What it covers
Crypto weak and deprecated TLS/DTLS: prohibited and legacy suites, deprecated versions, missing forward secrecy, weak suites a client still offers, expired or weak certificates, self-signed chains, alerts and truncated handshakes, JA3 fleet clustering
Credentials HTTP Basic/Digest, SNMP communities, FTP PASS, LDAP simple binds, MySQL queries and Telnet logins (always redacted to <redacted N chars>), cookies without Secure
Cleartext plain HTTP (high when the request path looks like a firmware or package download), cleartext FTP, Telnet, TFTP, NTP, SNMP, LDAP, SMTP, POP and MySQL
DNS plaintext DNS leakage, external resolvers over IPv4 and IPv6, DNS tunnelling shapes
SSH & QUIC weak SSH key exchange, cipher, MAC and host-key offers; Terrapin exposure (CVE-2023-48795); QUIC version inventory and payload opacity
Voice cleartext SIP signalling, SDP offered without a=crypto, RTP media in the clear, media volume anomalies (the SIP call graph is drawn in 04-diagrams.md)
Network unanswered-SYN scans, beaconing measured by regular gaps between bursts, services on non-standard ports
Other anything a detector reports that the catalog does not know yet (a test keeps this empty)

The distinction that matters is negotiated versus offered. A TLS 1.3 session is fine even if the same ClientHello still offers 3DES for TLS 1.2; that is reported as downgrade surface, not as a weakness of the session.


The score

The score is a local, deterministic function of the findings: 100 - Σ penalty, clamped to 0-100.

  • Each distinct finding code counts once, at its worst severity, so one noisy flow cannot sink the score on its own.
  • Penalty by severity: critical 20, high 10, medium 5, low 2, info 0.
  • Multiplied by confidence: high 1.0, medium 0.75, low 0.5.
  • Labels: Great ≥ 90, Good ≥ 75, Needs work ≥ 50, Critical below.

--score prints only the number, for scripts. With --baseline, --score and the console summary cover the new findings only; the JSON envelope keeps the full report's score and lists new_findings.


The report

Every run writes six files, meant to be read in order and pasted into a ticket.

Question Where the answer lands
What communication is happening? 01-flows.md: every conversation with bytes, duration, protocol and an encrypted/cleartext verdict
Which cipher is used between this sender and this recipient? 02-ciphers.md: per-flow matrix of version, chosen suite, offered count, forward secrecy, SNI, certificate
Is that cipher weak? 03-findings.md: every weak, prohibited or deprecated suite, with the frame it was seen in
What else is wrong? 03-findings.md: credentials, cookies, SIP/RTP, DNS, SSH, scans, beaconing, certificates
What do I do about it? every finding carries a remediation and, where one exists, an RFC, CWE or CVE reference
graph LR
  A["index.md<br/>navigation + summary"] --> B["01-flows.md<br/>who talks to whom"]
  A --> C["02-ciphers.md<br/>negotiated crypto"]
  A --> D["03-findings.md<br/>issues + evidence"]
  A --> E["04-diagrams.md<br/>Mermaid views"]
  A --> F["report.json<br/>machine readable"]
  B --> D
  C --> D
  C --> E
  style A fill:#1f2933,color:#fff
  style D fill:#7f1d1d,color:#fff
  style F fill:#14532d,color:#fff
File Read it for
index.md capture identity (path, SHA-256, size, window), packet/flow/host counts, severity histogram, notes, cipher-policy provenance
01-flows.md host table, every conversation with orientation, bytes, duration, crypto verdict and the evidence for that verdict, plus a cleartext-only section
02-ciphers.md the matrix (sender → recipient), then per-session detail: version, record versions, chosen suite, forward secrecy, offered suites, SNI, ALPN, JA3/JA3S, certificate chain, alerts; also QUIC inventory and SSH algorithm offers
03-findings.md triage table (worst first), then per-finding detail: severity, confidence, code, evidence table, remediation, references, tags
04-diagrams.md topology graph, crypto matrix graph, severity pie, per-detector bar chart, RTP timeline, SIP sequence diagrams
report.json the same data with a stable schema_version, for diffing between runs

Machine-readable output, on top of the six files:

Flag Output
--json only the JSON envelope on stdout: {tool, version, score, label, categories, report} (plus new_findings with --baseline)
--json-out FILE the same envelope written to a file, with the normal console summary
--sarif FILE SARIF 2.1.0 for code scanning: ruleId is the finding code, the fingerprint is the stable finding id
--baseline OLD.json show and gate only findings whose id is not in an earlier report.json or envelope; the files on disk stay complete
Sample finding
### 3. TLS_RSA_WITH_AES_128_CBC_SHA negotiated on tcp:10.0.0.1:41832<->10.0.0.243:443

- **Severity**: 🟠 high
- **Confidence**: high
- **Code**: `d1.tls_cipher.TLS_CIPHER_WEAK`

10.0.0.243:443 completed a handshake at TLS 1.2 with a static RSA key exchange
(RSA); no forward secrecy: a later compromise of the server private key decrypts
all recorded past traffic on this session.

| Frame | Field                | Value                             |
|-------|----------------------|-----------------------------------|
| 20    | `tls.handshake.ciphersuite` | `TLS_RSA_WITH_AES_128_CBC_SHA` |
| 19    | `tls.handshake.ciphersuite[offered]` | `1 suite(s) offered by the client` |

Configuration

pcap-doctor uses the nearest pcap-doctor.toml, or [tool.pcap-doctor] table in pyproject.toml, searching from the current directory up to the repository root, so it also applies when you run it from a subfolder. --config FILE points at another file, and the report notes say which file was used. Every key is optional.

Key Meaning
profile start from a preset; ota (firmware and OTA-update traffic) keeps Crypto, Credentials, Cleartext and DNS and sets fail_on = "high"
categories keep only these categories (same as --category)
fail_on default gate severity (same as --fail-on)
disable codes that are never reported
[severity] re-rate a code, e.g. TLS_CERT_EXPIRING = "low"; the finding id does not change
[[allow]] accept one known finding: code, subject and/or flow_key (fnmatch patterns, all must match) and a required reason
  • Precedence: command-line flags win over the config, and the config wins over its profile (--profile NAME replaces the config's own profile).
  • Nothing disappears silently: every re-rated, disabled or allowed finding is counted in the report notes with the file and the reason.
  • No silent typos: an unknown key, code, category, profile or severity exits 2 before tshark runs, and lists the valid values.

CI

pcap-doctor ci install writes .github/workflows/pcap-doctor.yml:

on:
  pull_request:
    paths: ["**/*.pcap", "**/*.pcapng", ".github/workflows/pcap-doctor.yml"]
permissions:
  contents: read
  pull-requests: write # one summary comment, updated in place
  security-events: write # SARIF upload to code scanning
jobs:
  pcap-doctor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Vaibhav91one/pcap-forensics@v0.5.0
        with:
          captures: "**/*.pcap **/*.pcapng"
          fail-on: high

Options: --captures (bash globstar patterns), --fail-on, --ref (the action ref to pin), --force (overwrite a workflow you edited; without it an edited file is kept and the exit code is 1).

The action (action.yml in this repository) installs tshark and pcap-doctor from its own ref, then, for each matching capture:

  1. analyzes the base branch's copy of the capture, when there is one;
  2. analyzes the pull request's copy with --baseline, so only new findings are shown and gated (a capture added by the PR counts all its findings as new);
  3. writes a summary table to the job summary and to one PR comment that it updates on each push;
  4. uploads one merged SARIF run to code scanning (upload-sarif: false to skip; private repositories need GitHub Advanced Security);
  5. fails the job when a new finding reaches fail-on.

Anywhere else, the building blocks are plain flags:

pcap-doctor analyze main.pcap -o base -q
pcap-doctor analyze head.pcap -o head --baseline base/report.json --sarif head.sarif --fail-on high

AI mode

The interactive screens. They appear only after an interactive analyze: a terminal on both ends, not in CI, not inside an agent, and not with --json, -q, --score or --no-handoff. Everything else keeps its exact output.

  • Review (↑/↓, enter, h, esc, q): findings grouped by category with ×N per code and unread markers; the selected finding's impact, why, evidence frames, fix, references and rule guide. Enter copies it as ticket-ready text (pbcopy, wl-copy, xclip or clip).
  • Hand off to an agent, for the whole report or, from Review, one finding: pick an agent found on PATH, or copy or show the prompt. The prompt carries tool and capture identity, the rule text, the fenced evidence and the task (find the configuration or code that produces this traffic, fix it at the source, verify with --baseline). It is always shown before a launch, with the skip-approvals warning.
  • Add to GitHub Actions writes the same workflow as pcap-doctor ci install, at the repository root, after asking.
  • Findings report: Choose how to continue also copies or shows the findings as a security-weakness report in Markdown (summary table; per finding: severity, affected flow and hosts, impact, description, evidence frames, remediation, references, id), for one finding or all of them; s saves it. Review's enter copies the same report for the selected finding.

Copying works on any machine. pcap-doctor uses the clipboard tool that fits the session (pbcopy on macOS, wl-copy under Wayland, xclip or xsel under X11, PowerShell's Set-Clipboard on Windows and WSL with clip.exe as a fallback, termux-clipboard-set on Android) and says "copied" only when that tool succeeded. Otherwise (a stock Ubuntu without wl-clipboard/xclip, SSH, a headless server) it sends the text with OSC 52, the terminal's own clipboard, which most modern terminals honour even over SSH, and saves it next to the report (findings-report.md, fix-prompt.md), so you always get it.

Inside an agent. When pcap-doctor runs inside Claude Code (CLAUDECODE), Codex (CODEX_THREAD_ID, CODEX_SANDBOX), Cursor's agent (CURSOR_SANDBOX), or with PCAP_DOCTOR_AGENT=1, it prints an Agent guidance block with pcap-doctor why <id> --prompt commands instead of a menu, and never launches anything.

Agent guides. pcap-doctor install writes .claude/skills/pcap-doctor/SKILL.md, .cursor/rules/pcap-doctor.mdc and a managed block in AGENTS.md (Codex), so agents in your repo know how to run pcap-doctor and read its output safely. Re-running it is idempotent; --agent picks one, --force overwrites a file you edited.


White-box firmware testing

When you have a device's firmware and a capture of its own OTA / management traffic — your own devices, or an authorized engagement — pcap-doctor can tie the two together: find the key material the image ships, decrypt the channel it protects, and report the consequence.

# 1. Inventory the key material an attacker holding the image would have.
pcap-doctor keys scan ./firmware/extracted/fs
#    flags weak keys (<=1024-bit RSA), self-signed and expired certs, and — the dangerous one —
#    a private key whose public half matches a certificate shipped in the same tree.

# 2. Decrypt your own capture with a key you supply (RSA key exchange, no forward secrecy).
pcap-doctor analyze ota.pcap --tls-key ./firmware/extracted/fs/etc/server.key
#    or load every PEM private key under a tree:
pcap-doctor analyze ota.pcap --keys-from ./firmware/extracted/fs
#    decrypted inner HTTP then flows through every detector, so cleartext creds inside the
#    management channel surface with the usual redaction. Forward-secret sessions need a key log:
pcap-doctor analyze ota.pcap --keylog sslkeys.log

# 3. Correlate the capture against the image and report the finding.
pcap-doctor analyze ota.pcap --firmware ./firmware/extracted/fs

Step 3 fingerprints every private key in the tree and matches it against each session's server certificate (by SPKI, sha256(DER public key)[:16] — the same fingerprint keys scan uses). On a match it reports TLS_KEY_IN_FIRMWARE (critical): the private key that authenticates and protects this session ships in the image, so anyone with the firmware can passively decrypt — and, as a man-in-the-middle, tamper with — the channel on every device that ships it. Forward secrecy does not help; the match is on the server's identity key, independent of cipher.

pcap-doctor consumes an already-extracted filesystem tree (like binwalk's output); it never unpacks an image or runs a device binary. Key material goes straight to tshark and is never written to any report — only the non-secret SPKI fingerprint and the firmware-relative path appear as evidence.


Live capture

pcap-doctor watch -i en0                    # see interfaces with `dumpcap -D`
pcap-doctor watch -i eth0 --seconds 30 --files 20 --dir ./ring

watch runs dumpcap (or tshark) as a ring buffer, analyzes each file as soon as it is closed, and prints every finding the first time its id appears, with the pcap-doctor why command to explain it. Ctrl-C stops the capture, analyzes the last file and keeps the ring files and their reports. Capturing needs the rights to open the interface (the access_bpf group from Wireshark's ChmodBPF on macOS, the wireshark group on Linux, or root); without them watch exits 2 with dumpcap's own error.


Detectors

One file, one detector, discovered automatically: pcapforensics/registry.py loads every non-underscore module in detectors/, so adding one needs no registration.

Id Module Owns
d1.tls_cipher detectors/tls_cipher.py negotiated version, chosen suite, offered-but-unused weak suites, forward secrecy, certificate expiry/key/signature/self-signed, alerts, truncated handshakes, JA3 fleet clustering
d2.transport_exposure detectors/transport_exposure.py plain HTTP (firmware-like downloads rated high; query strings never reported), HTTP Basic/Digest over cleartext, Basic inside TLS, cookies without Secure, cleartext FTP/Telnet/NTP/SNMP/LDAP/SMTP/POP/MySQL/TFTP, leaked credentials (SNMP community, FTP PASS, LDAP simple bind, MySQL queries, Telnet logins; always redacted), services on odd ports (lower-port side, low confidence), unanswered-SYN scan shapes, beaconing (regular gaps between bursts)
d3.sip_rtp detectors/sip_rtp.py SIP call graph, cleartext REGISTER/INVITE with auth headers, SDP offered without a=crypto (no SRTP possible), RTP media in the clear, media volume anomalies
d4.dns_quic_ssh detectors/dns_quic_ssh.py plaintext DNS leakage, DNS tunnelling heuristics (label depth + entropy + TXT/NULL volume), external resolvers (IPv4 and IPv6), QUIC version inventory and payload opacity, weak SSH kex/cipher/MAC/host-key offers, Terrapin exposure (CVE-2023-48795)

Every code a detector can emit is listed in the rule catalog (rules.py, pcap-doctor rules list) with its category and title, and explained in rule_docs/<CODE>.md.

graph TB
  subgraph index["CaptureIndex (read-only, frozen after Phase 0)"]
    flows[Flows]
    tls[TLS/DTLS sessions]
    http[HTTP exchanges]
    dns[DNS queries]
    sip[SIP messages + SDP]
    rtp[RTP streams]
    ssh[SSH sessions]
    quic[QUIC sessions]
    svc[Services + Telnet logins]
  end
  d1["D1 tls_cipher<br/>version, suite, PFS, certs, JA3"]
  d2["D2 transport_exposure<br/>credentials, cookies, ports, scans, beacons"]
  d3["D3 sip_rtp<br/>call graph, SIP auth, SRTP verdict"]
  d4["D4 dns_quic_ssh<br/>DNS leak/tunnel, QUIC, SSH, Terrapin"]
  flows & tls & http & dns & sip & rtp & ssh & quic --> d1
  flows & tls & http & dns & svc --> d2
  flows & sip & rtp --> d3
  flows & dns & quic & ssh --> d4
  d1 & d2 & d3 & d4 --> rep["Report + artifacts"]
  style d1 fill:#1e3a5f,color:#fff
  style d2 fill:#1e3a5f,color:#fff
  style d3 fill:#1e3a5f,color:#fff
  style d4 fill:#1e3a5f,color:#fff
  style rep fill:#14532d,color:#fff

Architecture

flowchart TD
  cap["capture.pcap"] --> runner["TsharkRunner<br/>subprocess tshark -T fields<br/>one pass per protocol family<br/>self-heals on field drift<br/>caches by sha256 + version"]
  runner --> idx["IndexBuilder<br/>CaptureIndex"]
  idx --> core["models.py (frozen)<br/>index.py (frozen)<br/>data_ciphers.py (frozen)"]
  core --> dets["detectors/*"]
  dets --> pol["config.py + policy.py<br/>re-rate, disable, allow,<br/>category and severity filters"]
  pol --> rep["Report"]
  reg["data/cipher_suites.json<br/>424 suites, names from tshark,<br/>weakness tags derived"] --> dets
  cert["certificates.py<br/>openssl x509 enrichment"] --> core
  rep --> md["render/markdown.py"]
  rep --> mm["render/mermaid.py"]
  md & mm --> files["6 artifacts"]
  rep --> out["scoring.py, baseline.py, output.py<br/>score, new findings, JSON envelope, SARIF"]
  out --> cli["cli/*: summary, why, handoff, CI"]
  style core fill:#422006,color:#fff
  style reg fill:#422006,color:#fff
  style files fill:#14532d,color:#fff

Score, categories, the JSON envelope, SARIF and baselines are all computed around the report from Finding.code and the stable Finding.id, so report.json keeps its own schema (schema_version 1.5.0). The CLI is a package of self-registering command modules (cli/<command>.py with a register(app)), so a new command is a new file.

Why tshark and not pyshark

pyshark was on the table. It is not used, for three reasons that cost real bugs to learn:

  1. Field names stay visible. Every field the analyzer uses is pinned against tshark -G fields, and drift is recorded in report.notes. When tshark renames something, the run degrades into a note instead of into a wrong answer.
  2. Determinism. One process per pass, -T fields, tab-separated, a documented aggregator character. Output is snapshot-testable.
  3. Python 3.13+ support is not a coin flip. pyshark's last release predates it.

If you want pyshark anyway, it belongs behind the same source interface as a notebook adapter. It must never be on the critical path.

Self-healing tshark layer

Field names move between tshark releases, and one release may not know a protocol at all (dtls.desegment_dtls_records does not exist in 4.6.6). The runner therefore:

  • validates every requested field against tshark -G fields and drops unknown ones,
  • validates every bare protocol token in a display filter against tshark -G protocols,
  • only passes preferences the build actually has (tshark -G defaultprefs),
  • retries with a fix-up if tshark still rejects a -o flag, and records every drop.

tests/test_tshark_layer.py pins the fields the project cannot work without, including every service field the index reads, so a tshark upgrade that breaks us fails CI instead of silently returning fewer findings.

Certificates

tshark flattens X.509 into a bag of RDN strings with no subject/issuer split and no key size. certificates.py takes the real route: tls.handshake.certificate (raw DER, one occurrence per certificate) → PEM → openssl x509 -noout -text. Subject, issuer, validity, key size, signature algorithm, SANs and self-signedness are exact. If openssl is missing, the tool falls back to the flattened tshark values, marks name_source, and adds a note.


How a finding is born

sequenceDiagram
  participant U as analyst
  participant P as pipeline
  participant I as CaptureIndex
  participant D as detector
  participant R as Report
  U->>P: pcap-doctor analyze capture.pcap
  P->>I: build (one tshark pass per protocol family, cached)
  I-->>D: frozen read-only view
  D->>D: decide, attach frame-numbered evidence
  D-->>P: list[Finding]
  Note over P: one detector raising is caught,<br/>the other detectors still run
  P->>P: config (re-rate, disable, allow), then category and severity filters, all noted
  P->>R: sort by severity, then confidence
  R-->>U: score, grouped summary, 6 artifacts, exit code

A finding is only valid if it carries a stable id, a real frame number and a remediation. Tests enforce the mechanical parts: every finding on every fixture and corpus capture must cite a frame above 0, and every reference must be something a reader can look up.

Finding.id is detector.code.sha256(scope)[:16], so re-running the same capture produces the same ids, two reports can be diffed, and --baseline knows what is new.


Cipher policy

The registry holds 424 suites. Names and ids come from tshark's own value table (tshark -G values), vendored in data/cipher_names.tsv; everything else is derived from the IANA name, which encodes key exchange, cipher, mode and MAC structurally.

This is deliberate. The first version hand-typed the common suites and 22 of the ids were wrong (0x0008 was labelled with the suite that lives at 0x0009, and so on). A registry that lies about which id is which is worse than no registry, so scripts/gen_cipher_suites.py derives everything and tests/test_cipher_registry.py proves the registry agrees with tshark for all 424 entries.

Tier Meaning Typical severity
prohibited NULL, export-grade, RC4, RC2, DES, 3DES, IDEA, SEED, MD5, anonymous, CRC32, GOST critical / high
deprecated no forward secrecy (static RSA, bare PSK) high
legacy CBC mode or SHA-1 MAC, AEAD otherwise medium
acceptable AEAD with forward secrecy, TLS 1.2 info
recommended TLS 1.3 suites info
signalling SCSV and other non-cipher values never reported

The reasoning lives in docs/cipher-policy.md. Regenerate with make regenerate (add --refresh to re-extract the name table from tshark).


Trust and honesty rules

These are the project's rules for itself, and tests enforce the mechanical ones:

  1. No silent passes. An unknown cipher id raises TLS_CIPHER_UNKNOWN rather than being ignored. An unparseable certificate, or a field tshark does not have, produces a note. An unknown option, config key or code exits 2 with the valid values.
  2. No invented facts. If subject/issuer cannot be separated, the field is None and name_source says where the data came from. If forward secrecy cannot be decided, the answer is None plus a reason, not False.
  3. No secrets in output. Credentials are redacted before they reach a report or a prompt. Every fixture that carries a secret has a test that greps every artifact for it.
  4. Confidence is separate from severity. A high-severity, low-confidence item is a lead to investigate, and the report says so.
  5. Absence of findings is not safety. 03-findings.md ends with that reminder in every run.
  6. One broken detector cannot sink a run. Detector exceptions are caught per detector and surfaced in report.notes.
  7. Suppression is visible. Every finding hidden by a config, a profile or a filter is counted in report.notes.

CLI reference

pcap-doctor analyze CAPTURE [--out DIR] [--only ID]... [--category NAME]... [--min-severity LEVEL]
                            [--fail-on LEVEL] [--profile NAME] [--config FILE] [--baseline OLD.json]
                            [--json] [--json-out FILE] [--sarif FILE] [--score] [-v] [-q]
                            [--no-handoff] [--safe] [--no-cache]
pcap-doctor why QUERY [--report report.json] [--prompt]    # QUERY: a frame number or an id prefix
pcap-doctor rules list [--category NAME]
pcap-doctor rules explain CODE
pcap-doctor install [--agent claude|cursor|codex]... [--force] [--dir DIR]
pcap-doctor ci install [--captures GLOBS] [--fail-on LEVEL] [--ref REF] [--force] [--dir DIR]
pcap-doctor watch -i IFACE [--seconds N] [--files K] [--dir DIR]
pcap-doctor keys scan DIR [--json] [--out KEYS_DIR]   # inventory key material in an extracted firmware tree
pcap-doctor flows CAPTURE [--top N]
pcap-doctor ciphers CAPTURE
pcap-doctor detectors | suites | doctor | schema
pcap-doctor --version
Exit code Meaning
0 finished; nothing tripped the --fail-on gate
1 a shown finding (new, with --baseline) is at or above the --fail-on severity; install / ci install kept a file you edited
2 bad input or environment: an unknown option value, config key or code, an unreadable baseline, tshark missing, or watch could not capture

--fail-on {none,critical,high,medium,low,info} defaults to the config's fail_on, else none: report, do not fail. The tshark pass cache lives in PCAP_DOCTOR_CACHE (default ~/.cache/pcap-doctor; the older PCAP_FORENSICS_CACHE still works) and is keyed by capture SHA-256, tshark version and pass arguments, so editing a detector never re-runs tshark.


Privacy and telemetry

pcap-doctor collects nothing and makes no network calls during analysis. uvx and npx download the package once, like any installer; make captures fetches public sample captures on demand; the GitHub Action talks to GitHub only to comment and upload SARIF.

Your captures stay on your machine, but treat the reports as sensitive: they contain IP addresses, hostnames, SNI names, usernames and certificate subjects. Passwords, community strings, tokens and Authorization headers are replaced with <redacted N chars> and never written to any artifact or prompt. When you hand a finding to an AI agent, that agent receives the prompt you previewed, and from there your agent's own privacy terms apply.


Testing

make test      # the full pytest suite
make verify    # ruff + mypy --strict + pytest: exactly what CI runs
File What it protects
tests/test_cipher_registry.py all 424 registry entries agree with tshark; tier and forward-secrecy expectations; unknown ids fail loudly
tests/test_tshark_layer.py every field and protocol the project needs exists (including every service field the index reads); TLS/DTLS pass field sets stay in sync; cache behaviour; self-healing paths
tests/test_detectors.py each synthetic fixture produces exactly the codes it is meant to, with frame-numbered evidence; secrets never leak; every finding on every capture cites a real frame and a well-formed reference
tests/test_corpus.py real captures: assertions written against tshark -V output for the same file, e.g. that tls12-aes128ccm.pcap negotiates TLS 1.2 with 0xC0A4 and must not be reported as TLS 1.0
tests/test_rules_catalog.py, tests/test_prompts.py every code a detector can emit has a catalog entry and a rule doc; hostile evidence cannot break out of the prompt fence
tests/test_baseline.py, tests/test_sarif.py, tests/test_config.py baselines by stable id, SARIF levels and fingerprints, config validation and visible suppression
tests/test_handoff.py, tests/test_ci_install.py, tests/test_watch.py exact agent launch commands and safe mode, the CI action end to end in a temporary git repo, the ring-buffer watcher
tests/test_docs.py every link and anchor in the docs resolves, and every documented command exists

Fixtures are generated, not captured by hand. scripts/make_fixtures.py builds byte-exact packets for the cleartext protocols and performs genuine OpenSSL handshakes through a logging proxy for TLS (weak_tls.pcap is a real TLS 1.0 session with a real certificate). Hand-rolled handshake bytes were tried first, and a single wrong length field made tshark report [Client Hello Fragment] and the whole session vanish: exactly the kind of bug this suite exists to catch.

The regression corpus in captures/ is the Wireshark project's own test captures (scripts/fetch_captures.sh). CI runs everything on every pull request, and runs this repository's own GitHub Action against the fixtures whenever the action or the package changes.


Contributing

Issues and pull requests are welcome. Work is organised as one issue, one branch, one pull request, and every change ships with a fixture and a test that fails without it.

From a clone: make setup (uv venv and an editable install with dev extras), then make verify. The core (models.py, index.py, data_ciphers.py, tshark.py) is frozen: a detector that needs a new field opens an issue instead of editing the model. A new finding code needs a RULES entry and a rule_docs/<CODE>.md.


Known limits

Stated plainly, because a triage tool that hides its limits is worse than useless:

  • Encrypted payloads are opaque. QUIC and TLS 1.3 application data cannot be read without a keylog. The tool says so rather than guessing.
  • Certificate subject/issuer need openssl. Without it, only the flattened CN and validity dates are available, and the report says which source was used.
  • Some heuristics are heuristics. DNS tunnelling and beaconing are scored on label depth, entropy and inter-arrival regularity, and reported with confidence: low unless the shape is strong. They are leads, not verdicts.
  • No ground truth for "who is the server". Orientation uses port heuristics; a service on an odd port is reported as such instead of being silently trusted.
  • Capture point matters. The tool sees the traffic it is given, and cannot know what the capture point did not record.
  • Baselines match by finding id. If a detector changes a finding's title or scope, its old findings look new; pcap-doctor warns when a detector's version differs from the baseline's.
  • The CI action splits captures on spaces. Paths with spaces are not supported there.
  • Telnet logins are found by prompt matching. Typed keystrokes are rebuilt from the client stream and a password is recognised after a Password: prompt; logins without such a prompt are missed. The password itself is never stored, only its length and frame.
  • Redis is classified by port only. tshark has no RESP dissector, so Redis commands are not parsed and cannot be checked for secrets.
  • JA3 clustering is v1. It groups by fingerprint, not by a maintained JA3 database.

License

MIT. The captures under captures/ belong to the Wireshark project (BSD-2-Clause) and are fetched, not authored.

Metadata

Release files for pcap-doctor 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pcap-doctor 0.5.0
File Size Uploaded
pcap_doctor-0.5.0.tar.gz 479.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pcap-doctor 0.5.0
File Interpreter ABI Platform
pcap_doctor-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 647.5 kB

Release files / pcap_doctor-0.5.0.tar.gz

Download URL pcap_doctor-0.5.0.tar.gz
Size 479.8 kB
Tags Source
SHA-256 checksum
How to use checksums
933fdc6087a8325c6728bc817761aea85040cc69d5cbc3a59096fad103718834
BLAKE2b-256 checksum
How to use checksums
c92448d89b30181b7266f6ab1de2a3c9de452d7f464a1e1bf232efdd25316079
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / pcap_doctor-0.5.0-py3-none-any.whl

Download URL pcap_doctor-0.5.0-py3-none-any.whl
Size 167.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b800130209d3ecf9d3cb6a2c8092ca6ddd6402140698fb4a80f38f82af53ac0f
BLAKE2b-256 checksum
How to use checksums
5cbfdac643ddcded8794d31ed6042ce7c557ac5ae616495efd897fba701ca898
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

0.7.0

2 release files

0.6.0

2 release files

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page