Skip to main content

Audit a container's network egress by running it inside an isolated network namespace and capturing every DNS query and TCP connection it makes.

Project description

periscope

CI

Release

Audit a container's network egress. Periscope runs a container inside an isolated Linux network namespace and reports every DNS query, TCP/UDP destination, QUIC endpoint, and TLS SNI it reaches.

$ sudo periscope profile docker.io/curlimages/curl wlp0s20f3 -- -sL https://example.com

=== Capture Summary (12 packets) ===

DNS queries:
   2  example.com

TCP destinations (outbound SYN):
   1  93.184.216.34:443

TLS SNI Entries:
example.com

Why

Running tcpdump on the host conflates the container's traffic with everything else. Periscope gives the container its own namespace, NATs egress through a chosen uplink, and sniffs only the veth — so the report is exactly the container's traffic, no host noise.

Install

Linux, Python 3.14+, uv, podman, iptables, nft.

git clone https://github.com/cbass-d/python-periscope
cd python-periscope
uv sync

All commands need sudo (network namespaces, iptables, sysctl).

Usage

Verify the host can run periscope:

sudo .venv/bin/periscope check <UPLINK_IFACE>

Profile a container:

sudo .venv/bin/periscope profile <IMAGE> <UPLINK_IFACE> [OPTIONS] -- [container args...]

The -- separates periscope's options from the container's command.

Options

Flag Default Description
--duration, -d 60 Capture duration in seconds
--namespace, -n periscope-ns Linux netns name to create
--subnet, -s 10.0.0.0/24 Subnet for the namespace's veth pair
--json off Emit JSON to stdout instead of the human-readable summary
--policy, -p none TOML policy file with expected destinations; appends a diff to the output
--strict off Exit code 2 if any captured destination is not in the policy (requires --policy)

Examples

# Profile an HTTP/3 client — captures QUIC traffic
sudo periscope profile docker.io/ymuski/curl-http3 wlan0 -- \
    curl -4 --http3-only -s -o /dev/null https://cloudflare-quic.com

# Pipe JSON output to jq
sudo periscope profile docker.io/curlimages/curl wlan0 --json -- \
    -sL https://example.com | jq '.dns_queries'

# Custom subnet to avoid host conflicts
sudo periscope profile docker.io/curlimages/curl wlan0 -s 172.20.0.0/24 -- \
    -sI https://example.com

What it captures

  • DNS queries — every name looked up
  • TCP destinations — outbound SYNs, keyed on (IP, port)
  • UDP destinations — non-DNS UDP (NTP, gaming, etc.)
  • QUIC destinations — UDP/443 packets matching the QUIC header bit pattern (RFC 9000 §17)
  • TLS SNI — server name extracted from outbound ClientHello

Inbound responses are filtered out — "destinations" describes where the container reached, not who reached back. (DNS responses are an exception: they're parsed to link hostnames to the IPs the container actually received, which the policy diff uses below.)

Policy: expected destinations

A TOML policy file declares per-channel allowlists. periscope profile --policy egress.toml compares the capture against the policy and appends a diff showing unexpected destinations (reached but not allowed) and expected-unused entries (allowed but never observed). Add --strict to exit nonzero on unexpected destinations — useful as a CI gate.

# egress.toml
[dns]
allowed = ["example.com", "*.cdn.example.com"]

[sni]
allowed = ["example.com"]

[tcp]
allowed = ["8.8.8.8:53", "1.1.1.1:*"]   # port "*" = any port

[udp]
allowed = ["8.8.8.8:53"]

[quic]
allowed = ["1.1.1.1:443"]
  • Hostnames support exact match and *.suffix wildcards (the wildcard does not match the apex).
  • Endpoints are ip:port or ip:*.
  • Any section may be omitted.

Hostname ↔ IP linkage. A hostname listed under [dns] implicitly allows the IPs the container actually received for it via captured DNS responses. So if example.com is in [dns] and the container hits 93.184.216.34:443 after resolving it, that endpoint is treated as expected without needing an explicit [tcp] entry. The link comes from the run's own DNS traffic — no host-side resolution at diff time, so the result is reproducible.

# CI gate: fail the run if anything outside the policy was reached
sudo periscope profile docker.io/curlimages/curl wlan0 \
    --policy egress.toml --strict -- -sL https://example.com

# Get the diff as JSON
sudo periscope profile docker.io/curlimages/curl wlan0 \
    --policy egress.toml --json -- -sL https://example.com | jq '.policy_diff'

Exit codes: 0 clean, 1 argument/config error, 2 strict policy violation.

How it works

A run sets up the host (IP forwarding, NAT via nftables, FORWARD ACCEPT via iptables), creates the namespace + veth pair, writes a working resolv.conf for it, starts a scapy sniffer on the host-side veth, then podman run --network=ns: puts the workload directly in that namespace. When the container exits, the sniffer stops, the summary prints, and everything unwinds — atomically, with rollback on partial failure.

Design notes

  • Podman over Docker--network=ns: joins an existing namespace in one flag; Docker has no equivalent.
  • nft for our own NAT, iptables for the system FORWARD chain — atomic teardown for state we own (nft delete table), delete-by-spec for state we don't (iptables -D).
  • CommandRunner Protocol over subprocess — production calls shell out; tests use FakeRunner to assert exact command sequences without root or network mutation.
  • @contextmanager compositionsession() nests Egress and NetworkSandbox in one with, getting LIFO teardown.

Limitations

  • Linux only (uses Linux network namespaces).
  • IPv4-only NAT/forwarding — IPv6 traffic from the namespace won't reach external IPv6 destinations.
  • TLS SNI is only extracted from TCP-based TLS handshakes; QUIC Initial decryption is not implemented.
  • Image must be pullable by podman. Use the fully qualified docker.io/... form unless you've configured unqualified-search-registries.

Development

uv sync
uv run pytest                    # 45 tests, no root required
uv run mypy src tests
uv run ruff check src tests

Tests use FakeRunner and constructed scapy packets — no real network state is touched. Pre-commit hooks run ruff on commit and mypy + pytest on push.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

periscope_audit-0.1.1.tar.gz (12.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

periscope_audit-0.1.1-py3-none-any.whl (16.9 kB view details)

Uploaded Python 3

File details

Details for the file periscope_audit-0.1.1.tar.gz.

File metadata

  • Download URL: periscope_audit-0.1.1.tar.gz
  • Upload date:
  • Size: 12.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for periscope_audit-0.1.1.tar.gz
Algorithm Hash digest
SHA256 2f4bcb7b269e58e098493d8fe8482e25db31215f456d5ac32855a1278e51aaeb
MD5 66d4309055abdce84cf5e54f1dab4eb8
BLAKE2b-256 db8c37d535860b2681daf8545b337101fbefb42f185aa2174653a6e9f8049ea6

See more details on using hashes here.

Provenance

The following attestation bundles were made for periscope_audit-0.1.1.tar.gz:

Publisher: release.yml on cbass-d/python-periscope

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file periscope_audit-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: periscope_audit-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 16.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for periscope_audit-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e96055ab71201ef152143c7ce5c97a9fc4279971a2381690c1c32d16042378c2
MD5 464ec131e38be8c1903e297d2c04e292
BLAKE2b-256 be8f5550ffaa91961ee7d972d926fe78130005223917df34aaec3b4369698af7

See more details on using hashes here.

Provenance

The following attestation bundles were made for periscope_audit-0.1.1-py3-none-any.whl:

Publisher: release.yml on cbass-d/python-periscope

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page