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
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
*.suffixwildcards (the wildcard does not match the apex). - Endpoints are
ip:portorip:*. - 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. nftfor our own NAT,iptablesfor the system FORWARD chain — atomic teardown for state we own (nft delete table), delete-by-spec for state we don't (iptables -D).CommandRunnerProtocol oversubprocess— production calls shell out; tests useFakeRunnerto assert exact command sequences without root or network mutation.@contextmanagercomposition —session()nestsEgressandNetworkSandboxin onewith, 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 qualifieddocker.io/...form unless you've configuredunqualified-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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2f4bcb7b269e58e098493d8fe8482e25db31215f456d5ac32855a1278e51aaeb
|
|
| MD5 |
66d4309055abdce84cf5e54f1dab4eb8
|
|
| BLAKE2b-256 |
db8c37d535860b2681daf8545b337101fbefb42f185aa2174653a6e9f8049ea6
|
Provenance
The following attestation bundles were made for periscope_audit-0.1.1.tar.gz:
Publisher:
release.yml on cbass-d/python-periscope
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
periscope_audit-0.1.1.tar.gz -
Subject digest:
2f4bcb7b269e58e098493d8fe8482e25db31215f456d5ac32855a1278e51aaeb - Sigstore transparency entry: 1501456402
- Sigstore integration time:
-
Permalink:
cbass-d/python-periscope@22fbc0480fa9933bf44f95ed80f20fe7c3e2eaf9 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/cbass-d
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@22fbc0480fa9933bf44f95ed80f20fe7c3e2eaf9 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e96055ab71201ef152143c7ce5c97a9fc4279971a2381690c1c32d16042378c2
|
|
| MD5 |
464ec131e38be8c1903e297d2c04e292
|
|
| BLAKE2b-256 |
be8f5550ffaa91961ee7d972d926fe78130005223917df34aaec3b4369698af7
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
periscope_audit-0.1.1-py3-none-any.whl -
Subject digest:
e96055ab71201ef152143c7ce5c97a9fc4279971a2381690c1c32d16042378c2 - Sigstore transparency entry: 1501456534
- Sigstore integration time:
-
Permalink:
cbass-d/python-periscope@22fbc0480fa9933bf44f95ed80f20fe7c3e2eaf9 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/cbass-d
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@22fbc0480fa9933bf44f95ed80f20fe7c3e2eaf9 -
Trigger Event:
push
-
Statement type: