drift-evidence 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritish Saini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: drift-evidence
3
+ Version: 0.1.0
4
+ Summary: Does a device's real network traffic match what it's declared to be allowed to contact? Checked against a real packet capture.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/drift
7
+ Project-URL: Source, https://github.com/MaXiMo000/drift
8
+ Project-URL: Issues, https://github.com/MaXiMo000/drift/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/drift/releases
10
+ Keywords: iot,privacy,network,pcap,evidence,provenance
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: scapy>=2.5
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # drift
25
+
26
+ [![ci](https://github.com/MaXiMo000/drift/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/drift/actions/workflows/ci.yml)
27
+
28
+ **Does a device's real network traffic match what it's declared to be
29
+ allowed to contact? Checked against a real packet capture.**
30
+
31
+ [`witness`](https://github.com/MaXiMo000/witness) checks a website's own
32
+ traffic against its declared privacy claims, from inside a browser.
33
+ `drift` is the same idea pushed past that ceiling: a device on your own
34
+ network — anything, not just something with a browser in it — checked
35
+ against a declared allow-list by reading what it actually sent, from a
36
+ packet capture.
37
+
38
+ ```
39
+ $ drift check http.cap claims.yaml
40
+ [XX] 145.254.160.237: 'test workstation' contacted domain(s) outside its declared allow-list: pagead2.googlesyndication.com
41
+
42
+ 0/1 pass, 1 fail
43
+ ```
44
+
45
+ (Real output — see "Tested against real traffic" below for what capture
46
+ this is and why.)
47
+
48
+ ## Read this before anything else: what's actually verified here
49
+
50
+ The idea this comes from (see the portfolio audit) is a **Raspberry Pi
51
+ acting as a live network tap on a real home network**, checking a real
52
+ smart-home device's real traffic in real time. **That deployment is not
53
+ built or tested here** — this session has no access to a Raspberry Pi, a
54
+ real IoT device, or a real home network to verify any of it against, and
55
+ building software nobody can verify would be the one thing this whole
56
+ portfolio has consistently refused to do.
57
+
58
+ What genuinely is built and tested: the actual analysis core — reading a
59
+ `.pcap`/`.pcapng` file, extracting which domains each device contacted
60
+ (via DNS queries and HTTP `Host:` headers), and checking that against a
61
+ declared allow-list. That core doesn't care whether the capture came from
62
+ a live Pi tap, a router's own packet-capture feature, or a file someone
63
+ handed you — a `.pcap` file is a `.pcap` file. **Wiring this to an actual
64
+ live tap on real hardware is real, unstarted future work**, stated
65
+ plainly rather than implied by silence.
66
+
67
+ ## Scope
68
+
69
+ Same discipline as `witness`: this is for checking traffic *you* captured
70
+ on a network *you* own, against claims *you* declared. It has no notion
71
+ of any real device manufacturer's actual claims and ships with no data
72
+ about any real product.
73
+
74
+ ## Install
75
+
76
+ ```
77
+ pip install drift-evidence # the command it installs is `drift`
78
+ ```
79
+
80
+ (`drift` was already taken on PyPI — same story as most siblings in this
81
+ portfolio.)
82
+
83
+ ## Use
84
+
85
+ Declare which domains each device (identified by its IP in the capture)
86
+ is allowed to contact, in `claims.yaml`:
87
+
88
+ ```yaml
89
+ devices:
90
+ - id: "192.168.1.42"
91
+ name: "smart bulb"
92
+ allowed_domains:
93
+ - vendor.example.com
94
+ ```
95
+
96
+ Then check a real capture against it:
97
+
98
+ ```
99
+ drift check capture.pcap claims.yaml
100
+ ```
101
+
102
+ A device with no entry in `claims.yaml` reads `unverified`, never a
103
+ silent pass — same three-status discipline as every claim-checking tool
104
+ in this portfolio. `--json` prints the full report.
105
+
106
+ ## How domains are actually extracted
107
+
108
+ Two signals, both read in the clear regardless of what runs on top of
109
+ them:
110
+
111
+ - **DNS query names** — the device asking "where is X" is visible even
112
+ when the connection to X itself is fully encrypted. This is the signal
113
+ that matters for a real modern device, whose actual traffic is mostly
114
+ HTTPS.
115
+ - **HTTP `Host:` headers** — cleartext HTTP only, a strictly narrower
116
+ signal, kept because it names a domain a request actually *reached*,
117
+ not just one that was looked up.
118
+
119
+ ## What this does NOT do
120
+
121
+ - **Can't read a domain out of an HTTPS/TLS connection.** That needs
122
+ either the TLS ClientHello's SNI field (sent in the clear even over an
123
+ otherwise-encrypted connection, and not read here) or a decryption key.
124
+ Real, addable scope — not attempted in this version.
125
+ - **No live capture of anything.** `drift` reads a `.pcap` file that
126
+ already exists; it has no code that touches a network interface. Making
127
+ one (a Raspberry Pi tap, a router's mirror port, `tcpdump` itself) is
128
+ the caller's job.
129
+ - **Doesn't distinguish which device asked from which device the traffic
130
+ is actually *about*** beyond source IP. On a network with NAT or DHCP
131
+ churn, the same IP can mean a different physical device over time — a
132
+ real limitation of IP-based identification, not solved here.
133
+
134
+ ## Privacy
135
+
136
+ Everything stays on the machine `drift` runs on. It makes no network
137
+ calls of its own -- `rdpcap` reads the `.pcap` file from disk, and the
138
+ report goes to stdout or a `--json` file you name; nothing is uploaded
139
+ or phoned home anywhere.
140
+
141
+ The thing to actually be careful of is upstream of drift, not in it: a
142
+ `.pcap` captured on a shared network (a router's mirror port, a home
143
+ Wi-Fi capture) records *every* device's traffic that happened to be on
144
+ the wire, not just the one you're checking. `extract_domains` (in
145
+ `pcap.py`) buckets by every source IP it sees, and `check_pcap` reports
146
+ on every one of them -- a device with no entry in `claims.yaml` still
147
+ comes back `unverified`, **with its full list of observed domains
148
+ included in the report**, exactly the same as a declared device's would
149
+ be. Point drift at a capture that includes a housemate's phone or a
150
+ guest's laptop, and their browsing domains end up in your output, not
151
+ just your smart bulb's.
152
+
153
+ That's not a bug to fix -- filtering out "devices you didn't mean to
154
+ capture" isn't something drift can know how to do, since it has no way
155
+ to tell an incidental bystander's IP from a device you meant to declare
156
+ later. It's a fact about what a shared-network capture *is*, and worth
157
+ knowing before you paste a report somewhere or hand it to someone else:
158
+ capture only what you mean to, and treat the report as covering
159
+ everyone who was on the network at the time, not just the device named
160
+ in your claims file.
161
+
162
+ ## Tested against real traffic, not synthetic fixtures
163
+
164
+ `tests/fixtures/http.cap` is a real capture downloaded from
165
+ [Wireshark's own official sample-captures page](https://wiki.wireshark.org/SampleCaptures)
166
+ (see `tests/fixtures/PROVENANCE.md`) — 2004-era traffic of a client
167
+ requesting a page from `www.ethereal.com` (Wireshark's former name)
168
+ whose response pulled in a Google AdSense ad from
169
+ `pagead2.googlesyndication.com`. That's a real DNS query *and* a real
170
+ HTTP request to an undeclared third party, sitting in a 20-year-old
171
+ capture nobody built for this purpose — exactly the shape `drift` exists
172
+ to catch, found in genuine traffic rather than constructed to prove a
173
+ point.
174
+
175
+ ## Tests
176
+
177
+ ```
178
+ pip install -e .
179
+ python tests/test_pcap.py # domain extraction, against the real fixture above
180
+ python tests/test_claims.py # claims.yaml validation
181
+ python tests/test_check.py # pass/fail/unverified classification
182
+ python tests/test_cli.py # the real CLI entry point, against the real fixture
183
+ ```
184
+
185
+ 29 tests.
186
+
187
+ MIT licensed.
@@ -0,0 +1,164 @@
1
+ # drift
2
+
3
+ [![ci](https://github.com/MaXiMo000/drift/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/drift/actions/workflows/ci.yml)
4
+
5
+ **Does a device's real network traffic match what it's declared to be
6
+ allowed to contact? Checked against a real packet capture.**
7
+
8
+ [`witness`](https://github.com/MaXiMo000/witness) checks a website's own
9
+ traffic against its declared privacy claims, from inside a browser.
10
+ `drift` is the same idea pushed past that ceiling: a device on your own
11
+ network — anything, not just something with a browser in it — checked
12
+ against a declared allow-list by reading what it actually sent, from a
13
+ packet capture.
14
+
15
+ ```
16
+ $ drift check http.cap claims.yaml
17
+ [XX] 145.254.160.237: 'test workstation' contacted domain(s) outside its declared allow-list: pagead2.googlesyndication.com
18
+
19
+ 0/1 pass, 1 fail
20
+ ```
21
+
22
+ (Real output — see "Tested against real traffic" below for what capture
23
+ this is and why.)
24
+
25
+ ## Read this before anything else: what's actually verified here
26
+
27
+ The idea this comes from (see the portfolio audit) is a **Raspberry Pi
28
+ acting as a live network tap on a real home network**, checking a real
29
+ smart-home device's real traffic in real time. **That deployment is not
30
+ built or tested here** — this session has no access to a Raspberry Pi, a
31
+ real IoT device, or a real home network to verify any of it against, and
32
+ building software nobody can verify would be the one thing this whole
33
+ portfolio has consistently refused to do.
34
+
35
+ What genuinely is built and tested: the actual analysis core — reading a
36
+ `.pcap`/`.pcapng` file, extracting which domains each device contacted
37
+ (via DNS queries and HTTP `Host:` headers), and checking that against a
38
+ declared allow-list. That core doesn't care whether the capture came from
39
+ a live Pi tap, a router's own packet-capture feature, or a file someone
40
+ handed you — a `.pcap` file is a `.pcap` file. **Wiring this to an actual
41
+ live tap on real hardware is real, unstarted future work**, stated
42
+ plainly rather than implied by silence.
43
+
44
+ ## Scope
45
+
46
+ Same discipline as `witness`: this is for checking traffic *you* captured
47
+ on a network *you* own, against claims *you* declared. It has no notion
48
+ of any real device manufacturer's actual claims and ships with no data
49
+ about any real product.
50
+
51
+ ## Install
52
+
53
+ ```
54
+ pip install drift-evidence # the command it installs is `drift`
55
+ ```
56
+
57
+ (`drift` was already taken on PyPI — same story as most siblings in this
58
+ portfolio.)
59
+
60
+ ## Use
61
+
62
+ Declare which domains each device (identified by its IP in the capture)
63
+ is allowed to contact, in `claims.yaml`:
64
+
65
+ ```yaml
66
+ devices:
67
+ - id: "192.168.1.42"
68
+ name: "smart bulb"
69
+ allowed_domains:
70
+ - vendor.example.com
71
+ ```
72
+
73
+ Then check a real capture against it:
74
+
75
+ ```
76
+ drift check capture.pcap claims.yaml
77
+ ```
78
+
79
+ A device with no entry in `claims.yaml` reads `unverified`, never a
80
+ silent pass — same three-status discipline as every claim-checking tool
81
+ in this portfolio. `--json` prints the full report.
82
+
83
+ ## How domains are actually extracted
84
+
85
+ Two signals, both read in the clear regardless of what runs on top of
86
+ them:
87
+
88
+ - **DNS query names** — the device asking "where is X" is visible even
89
+ when the connection to X itself is fully encrypted. This is the signal
90
+ that matters for a real modern device, whose actual traffic is mostly
91
+ HTTPS.
92
+ - **HTTP `Host:` headers** — cleartext HTTP only, a strictly narrower
93
+ signal, kept because it names a domain a request actually *reached*,
94
+ not just one that was looked up.
95
+
96
+ ## What this does NOT do
97
+
98
+ - **Can't read a domain out of an HTTPS/TLS connection.** That needs
99
+ either the TLS ClientHello's SNI field (sent in the clear even over an
100
+ otherwise-encrypted connection, and not read here) or a decryption key.
101
+ Real, addable scope — not attempted in this version.
102
+ - **No live capture of anything.** `drift` reads a `.pcap` file that
103
+ already exists; it has no code that touches a network interface. Making
104
+ one (a Raspberry Pi tap, a router's mirror port, `tcpdump` itself) is
105
+ the caller's job.
106
+ - **Doesn't distinguish which device asked from which device the traffic
107
+ is actually *about*** beyond source IP. On a network with NAT or DHCP
108
+ churn, the same IP can mean a different physical device over time — a
109
+ real limitation of IP-based identification, not solved here.
110
+
111
+ ## Privacy
112
+
113
+ Everything stays on the machine `drift` runs on. It makes no network
114
+ calls of its own -- `rdpcap` reads the `.pcap` file from disk, and the
115
+ report goes to stdout or a `--json` file you name; nothing is uploaded
116
+ or phoned home anywhere.
117
+
118
+ The thing to actually be careful of is upstream of drift, not in it: a
119
+ `.pcap` captured on a shared network (a router's mirror port, a home
120
+ Wi-Fi capture) records *every* device's traffic that happened to be on
121
+ the wire, not just the one you're checking. `extract_domains` (in
122
+ `pcap.py`) buckets by every source IP it sees, and `check_pcap` reports
123
+ on every one of them -- a device with no entry in `claims.yaml` still
124
+ comes back `unverified`, **with its full list of observed domains
125
+ included in the report**, exactly the same as a declared device's would
126
+ be. Point drift at a capture that includes a housemate's phone or a
127
+ guest's laptop, and their browsing domains end up in your output, not
128
+ just your smart bulb's.
129
+
130
+ That's not a bug to fix -- filtering out "devices you didn't mean to
131
+ capture" isn't something drift can know how to do, since it has no way
132
+ to tell an incidental bystander's IP from a device you meant to declare
133
+ later. It's a fact about what a shared-network capture *is*, and worth
134
+ knowing before you paste a report somewhere or hand it to someone else:
135
+ capture only what you mean to, and treat the report as covering
136
+ everyone who was on the network at the time, not just the device named
137
+ in your claims file.
138
+
139
+ ## Tested against real traffic, not synthetic fixtures
140
+
141
+ `tests/fixtures/http.cap` is a real capture downloaded from
142
+ [Wireshark's own official sample-captures page](https://wiki.wireshark.org/SampleCaptures)
143
+ (see `tests/fixtures/PROVENANCE.md`) — 2004-era traffic of a client
144
+ requesting a page from `www.ethereal.com` (Wireshark's former name)
145
+ whose response pulled in a Google AdSense ad from
146
+ `pagead2.googlesyndication.com`. That's a real DNS query *and* a real
147
+ HTTP request to an undeclared third party, sitting in a 20-year-old
148
+ capture nobody built for this purpose — exactly the shape `drift` exists
149
+ to catch, found in genuine traffic rather than constructed to prove a
150
+ point.
151
+
152
+ ## Tests
153
+
154
+ ```
155
+ pip install -e .
156
+ python tests/test_pcap.py # domain extraction, against the real fixture above
157
+ python tests/test_claims.py # claims.yaml validation
158
+ python tests/test_check.py # pass/fail/unverified classification
159
+ python tests/test_cli.py # the real CLI entry point, against the real fixture
160
+ ```
161
+
162
+ 29 tests.
163
+
164
+ MIT licensed.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,49 @@
1
+ """Compare what a device actually contacted (extracted from a real packet
2
+ capture) against what it's declared to be allowed to contact. Same
3
+ three-status discipline as witness's own diff.js: no claim recorded for a
4
+ device is `unverified`, never a silent pass; a domain outside the
5
+ declared allow-list is `fail`, named explicitly, not folded into a vague
6
+ "suspicious" bucket.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ PASS, FAIL, UNVERIFIED = "pass", "fail", "unverified"
11
+
12
+
13
+ def _norm(domain: str) -> str:
14
+ return domain.strip().lower().rstrip(".")
15
+
16
+
17
+ def check_device(device_id: str, observed: dict, claims: dict | None) -> dict:
18
+ all_observed = sorted(set(observed.get("dns", [])) | set(observed.get("http", [])))
19
+
20
+ if claims is None:
21
+ return {
22
+ "device_id": device_id, "status": UNVERIFIED,
23
+ "detail": (f"no claims recorded for device '{device_id}' -- observed "
24
+ f"{len(all_observed)} domain(s), nothing to check them against"),
25
+ "observed_domains": all_observed, "unexpected_domains": [],
26
+ }
27
+
28
+ allowed = {_norm(d) for d in claims["allowed_domains"]}
29
+ unexpected = [d for d in all_observed if _norm(d) not in allowed]
30
+
31
+ if unexpected:
32
+ return {
33
+ "device_id": device_id, "status": FAIL,
34
+ "detail": (f"'{claims['name']}' contacted domain(s) outside its declared "
35
+ f"allow-list: {', '.join(unexpected)}"),
36
+ "observed_domains": all_observed, "unexpected_domains": unexpected,
37
+ }
38
+ return {
39
+ "device_id": device_id, "status": PASS,
40
+ "detail": f"'{claims['name']}' only contacted declared domains",
41
+ "observed_domains": all_observed, "unexpected_domains": [],
42
+ }
43
+
44
+
45
+ def check_pcap(observed_by_device: dict[str, dict], claims: dict[str, dict]) -> list[dict]:
46
+ return [
47
+ check_device(dev_id, obs, claims.get(dev_id))
48
+ for dev_id, obs in sorted(observed_by_device.items())
49
+ ]
@@ -0,0 +1,52 @@
1
+ """Load a drift claims file: which domains each device is declared to be
2
+ allowed to contact. Same discipline as witness's own claims schema (this
3
+ portfolio's browser-side equivalent): the allow-list is the actual gate,
4
+ and a device with no entry here reads as unverified, never a silent pass.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import yaml
9
+
10
+
11
+ class ClaimsError(ValueError):
12
+ pass
13
+
14
+
15
+ def _norm(domain: str) -> str:
16
+ return domain.strip().lower().rstrip(".")
17
+
18
+
19
+ def load_claims(path: str) -> dict[str, dict]:
20
+ try:
21
+ with open(path, encoding="utf-8") as f:
22
+ raw = yaml.safe_load(f)
23
+ except yaml.YAMLError as exc:
24
+ raise ClaimsError(f"{path}: not valid YAML ({exc})") from exc
25
+ except OSError as exc:
26
+ raise ClaimsError(f"{path}: {exc}") from exc
27
+
28
+ if not isinstance(raw, dict) or "devices" not in raw:
29
+ raise ClaimsError(f"{path}: must be a mapping with a top-level 'devices' list")
30
+ devices = raw["devices"]
31
+ if not isinstance(devices, list) or not devices:
32
+ raise ClaimsError(f"{path}: 'devices' must be a non-empty list")
33
+
34
+ result: dict[str, dict] = {}
35
+ for i, dev in enumerate(devices):
36
+ if not isinstance(dev, dict):
37
+ raise ClaimsError(f"{path}: devices[{i}] must be a mapping")
38
+ dev_id = dev.get("id")
39
+ if not dev_id or not isinstance(dev_id, str):
40
+ raise ClaimsError(f"{path}: devices[{i}] is missing a string 'id'")
41
+ if dev_id in result:
42
+ raise ClaimsError(f"{path}: duplicate device id '{dev_id}'")
43
+
44
+ allowed = dev.get("allowed_domains", [])
45
+ if not isinstance(allowed, list):
46
+ raise ClaimsError(f"{path}: device '{dev_id}': 'allowed_domains' must be a list")
47
+
48
+ result[dev_id] = {
49
+ "name": dev.get("name", dev_id),
50
+ "allowed_domains": [_norm(d) for d in allowed],
51
+ }
52
+ return result
@@ -0,0 +1,60 @@
1
+ """drift check <pcap> <claims.yaml> [--json]"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from .check import FAIL, PASS, UNVERIFIED, check_pcap
9
+ from .claims import ClaimsError, load_claims
10
+ from .pcap import extract_domains
11
+
12
+ _TAG = {PASS: "OK", FAIL: "XX", UNVERIFIED: "??"}
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(prog="drift")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+
19
+ check_p = sub.add_parser(
20
+ "check", help="check devices in a packet capture against their declared allowed domains")
21
+ check_p.add_argument("pcap", help="a packet capture file (.pcap/.pcapng)")
22
+ check_p.add_argument("claims", help="drift-claims.yaml")
23
+ check_p.add_argument("--json", action="store_true", help="print the full report as JSON")
24
+
25
+ args = parser.parse_args(argv)
26
+
27
+ try:
28
+ claims = load_claims(args.claims)
29
+ except ClaimsError as exc:
30
+ sys.exit(f"drift: {exc}")
31
+
32
+ try:
33
+ observed = extract_domains(args.pcap)
34
+ except OSError as exc:
35
+ sys.exit(f"drift: could not read {args.pcap}: {exc}")
36
+ except Exception as exc: # noqa: BLE001 -- scapy's own parse errors vary by malformation
37
+ sys.exit(f"drift: could not parse {args.pcap} as a packet capture ({exc})")
38
+
39
+ results = check_pcap(observed, claims)
40
+
41
+ if args.json:
42
+ print(json.dumps(results, indent=2))
43
+ else:
44
+ for r in results:
45
+ print(f"[{_TAG[r['status']]}] {r['device_id']}: {r['detail']}")
46
+ n_fail = sum(1 for r in results if r["status"] == FAIL)
47
+ n_unverified = sum(1 for r in results if r["status"] == UNVERIFIED)
48
+ n_pass = len(results) - n_fail - n_unverified
49
+ summary = f"{n_pass}/{len(results)} pass"
50
+ if n_fail:
51
+ summary += f", {n_fail} fail"
52
+ if n_unverified:
53
+ summary += f", {n_unverified} unverified"
54
+ print(f"\n{summary}")
55
+
56
+ return 1 if any(r["status"] == FAIL for r in results) else 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ """Extract which domains each device (identified by its source IP)
2
+ actually contacted in a packet capture.
3
+
4
+ Two signals, both cleartext regardless of what runs on top of them:
5
+
6
+ - DNS query names -- the device asking "where is X" is visible even when
7
+ the connection to X itself is fully encrypted, which is why this is the
8
+ signal that actually matters for a modern device whose traffic is
9
+ mostly HTTPS.
10
+ - HTTP `Host:` headers -- a strictly narrower signal (cleartext HTTP
11
+ only), kept because it's free once a capture has any plain HTTP in it
12
+ at all, and it names the domain a request actually reached, not just
13
+ one it resolved.
14
+
15
+ What this does NOT do: read a domain out of an HTTPS/TLS connection.
16
+ That needs either the TLS ClientHello's SNI field (sent in the clear even
17
+ over an otherwise-encrypted connection, and not read here -- real,
18
+ addable scope) or a decryption key. Stated as a real gap, not silently
19
+ worked around.
20
+ """
21
+ from __future__ import annotations
22
+
23
+
24
+ def _parse_host_header(data: bytes) -> str | None:
25
+ for line in data.split(b"\r\n"):
26
+ if line.lower().startswith(b"host:"):
27
+ return line.split(b":", 1)[1].strip().decode("ascii", errors="replace")
28
+ return None
29
+
30
+
31
+ def extract_domains(pcap_path: str) -> dict[str, dict]:
32
+ """Returns {source_ip: {"dns": [names...], "http": [hosts...]}},
33
+ both lists sorted for deterministic output."""
34
+ from scapy.all import rdpcap # imported here, not at module load, so
35
+ from scapy.layers.dns import DNS # importing drift.claims/drift.check
36
+ from scapy.layers.inet import IP, TCP # never requires scapy at all
37
+ from scapy.packet import Raw
38
+
39
+ packets = rdpcap(pcap_path)
40
+ result: dict[str, dict] = {}
41
+
42
+ def bucket(ip: str) -> dict:
43
+ return result.setdefault(ip, {"dns": set(), "http": set()})
44
+
45
+ for pkt in packets:
46
+ if IP not in pkt:
47
+ continue
48
+ src = pkt[IP].src
49
+
50
+ # qr == 0 is a query, not a response -- the querier is the device
51
+ # actually asking, which is who this attributes the lookup to.
52
+ # qd is a PacketListField (scapy's newer versions warn on treating
53
+ # it as a single record) -- indexed explicitly, not accessed as if
54
+ # it were one object, so this doesn't silently break when a future
55
+ # scapy release removes the deprecated single-object shim.
56
+ if DNS in pkt and pkt[DNS].qd and pkt[DNS].qr == 0:
57
+ qname = pkt[DNS].qd[0].qname
58
+ if isinstance(qname, bytes):
59
+ qname = qname.decode("ascii", errors="replace")
60
+ bucket(src)["dns"].add(qname.rstrip("."))
61
+
62
+ if TCP in pkt and Raw in pkt:
63
+ data = bytes(pkt[Raw])
64
+ if data.startswith((b"GET ", b"POST ", b"HEAD ", b"PUT ", b"DELETE ")):
65
+ host = _parse_host_header(data)
66
+ if host:
67
+ bucket(src)["http"].add(host)
68
+
69
+ return {ip: {"dns": sorted(v["dns"]), "http": sorted(v["http"])} for ip, v in result.items()}
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: drift-evidence
3
+ Version: 0.1.0
4
+ Summary: Does a device's real network traffic match what it's declared to be allowed to contact? Checked against a real packet capture.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/drift
7
+ Project-URL: Source, https://github.com/MaXiMo000/drift
8
+ Project-URL: Issues, https://github.com/MaXiMo000/drift/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/drift/releases
10
+ Keywords: iot,privacy,network,pcap,evidence,provenance
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: scapy>=2.5
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # drift
25
+
26
+ [![ci](https://github.com/MaXiMo000/drift/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/drift/actions/workflows/ci.yml)
27
+
28
+ **Does a device's real network traffic match what it's declared to be
29
+ allowed to contact? Checked against a real packet capture.**
30
+
31
+ [`witness`](https://github.com/MaXiMo000/witness) checks a website's own
32
+ traffic against its declared privacy claims, from inside a browser.
33
+ `drift` is the same idea pushed past that ceiling: a device on your own
34
+ network — anything, not just something with a browser in it — checked
35
+ against a declared allow-list by reading what it actually sent, from a
36
+ packet capture.
37
+
38
+ ```
39
+ $ drift check http.cap claims.yaml
40
+ [XX] 145.254.160.237: 'test workstation' contacted domain(s) outside its declared allow-list: pagead2.googlesyndication.com
41
+
42
+ 0/1 pass, 1 fail
43
+ ```
44
+
45
+ (Real output — see "Tested against real traffic" below for what capture
46
+ this is and why.)
47
+
48
+ ## Read this before anything else: what's actually verified here
49
+
50
+ The idea this comes from (see the portfolio audit) is a **Raspberry Pi
51
+ acting as a live network tap on a real home network**, checking a real
52
+ smart-home device's real traffic in real time. **That deployment is not
53
+ built or tested here** — this session has no access to a Raspberry Pi, a
54
+ real IoT device, or a real home network to verify any of it against, and
55
+ building software nobody can verify would be the one thing this whole
56
+ portfolio has consistently refused to do.
57
+
58
+ What genuinely is built and tested: the actual analysis core — reading a
59
+ `.pcap`/`.pcapng` file, extracting which domains each device contacted
60
+ (via DNS queries and HTTP `Host:` headers), and checking that against a
61
+ declared allow-list. That core doesn't care whether the capture came from
62
+ a live Pi tap, a router's own packet-capture feature, or a file someone
63
+ handed you — a `.pcap` file is a `.pcap` file. **Wiring this to an actual
64
+ live tap on real hardware is real, unstarted future work**, stated
65
+ plainly rather than implied by silence.
66
+
67
+ ## Scope
68
+
69
+ Same discipline as `witness`: this is for checking traffic *you* captured
70
+ on a network *you* own, against claims *you* declared. It has no notion
71
+ of any real device manufacturer's actual claims and ships with no data
72
+ about any real product.
73
+
74
+ ## Install
75
+
76
+ ```
77
+ pip install drift-evidence # the command it installs is `drift`
78
+ ```
79
+
80
+ (`drift` was already taken on PyPI — same story as most siblings in this
81
+ portfolio.)
82
+
83
+ ## Use
84
+
85
+ Declare which domains each device (identified by its IP in the capture)
86
+ is allowed to contact, in `claims.yaml`:
87
+
88
+ ```yaml
89
+ devices:
90
+ - id: "192.168.1.42"
91
+ name: "smart bulb"
92
+ allowed_domains:
93
+ - vendor.example.com
94
+ ```
95
+
96
+ Then check a real capture against it:
97
+
98
+ ```
99
+ drift check capture.pcap claims.yaml
100
+ ```
101
+
102
+ A device with no entry in `claims.yaml` reads `unverified`, never a
103
+ silent pass — same three-status discipline as every claim-checking tool
104
+ in this portfolio. `--json` prints the full report.
105
+
106
+ ## How domains are actually extracted
107
+
108
+ Two signals, both read in the clear regardless of what runs on top of
109
+ them:
110
+
111
+ - **DNS query names** — the device asking "where is X" is visible even
112
+ when the connection to X itself is fully encrypted. This is the signal
113
+ that matters for a real modern device, whose actual traffic is mostly
114
+ HTTPS.
115
+ - **HTTP `Host:` headers** — cleartext HTTP only, a strictly narrower
116
+ signal, kept because it names a domain a request actually *reached*,
117
+ not just one that was looked up.
118
+
119
+ ## What this does NOT do
120
+
121
+ - **Can't read a domain out of an HTTPS/TLS connection.** That needs
122
+ either the TLS ClientHello's SNI field (sent in the clear even over an
123
+ otherwise-encrypted connection, and not read here) or a decryption key.
124
+ Real, addable scope — not attempted in this version.
125
+ - **No live capture of anything.** `drift` reads a `.pcap` file that
126
+ already exists; it has no code that touches a network interface. Making
127
+ one (a Raspberry Pi tap, a router's mirror port, `tcpdump` itself) is
128
+ the caller's job.
129
+ - **Doesn't distinguish which device asked from which device the traffic
130
+ is actually *about*** beyond source IP. On a network with NAT or DHCP
131
+ churn, the same IP can mean a different physical device over time — a
132
+ real limitation of IP-based identification, not solved here.
133
+
134
+ ## Privacy
135
+
136
+ Everything stays on the machine `drift` runs on. It makes no network
137
+ calls of its own -- `rdpcap` reads the `.pcap` file from disk, and the
138
+ report goes to stdout or a `--json` file you name; nothing is uploaded
139
+ or phoned home anywhere.
140
+
141
+ The thing to actually be careful of is upstream of drift, not in it: a
142
+ `.pcap` captured on a shared network (a router's mirror port, a home
143
+ Wi-Fi capture) records *every* device's traffic that happened to be on
144
+ the wire, not just the one you're checking. `extract_domains` (in
145
+ `pcap.py`) buckets by every source IP it sees, and `check_pcap` reports
146
+ on every one of them -- a device with no entry in `claims.yaml` still
147
+ comes back `unverified`, **with its full list of observed domains
148
+ included in the report**, exactly the same as a declared device's would
149
+ be. Point drift at a capture that includes a housemate's phone or a
150
+ guest's laptop, and their browsing domains end up in your output, not
151
+ just your smart bulb's.
152
+
153
+ That's not a bug to fix -- filtering out "devices you didn't mean to
154
+ capture" isn't something drift can know how to do, since it has no way
155
+ to tell an incidental bystander's IP from a device you meant to declare
156
+ later. It's a fact about what a shared-network capture *is*, and worth
157
+ knowing before you paste a report somewhere or hand it to someone else:
158
+ capture only what you mean to, and treat the report as covering
159
+ everyone who was on the network at the time, not just the device named
160
+ in your claims file.
161
+
162
+ ## Tested against real traffic, not synthetic fixtures
163
+
164
+ `tests/fixtures/http.cap` is a real capture downloaded from
165
+ [Wireshark's own official sample-captures page](https://wiki.wireshark.org/SampleCaptures)
166
+ (see `tests/fixtures/PROVENANCE.md`) — 2004-era traffic of a client
167
+ requesting a page from `www.ethereal.com` (Wireshark's former name)
168
+ whose response pulled in a Google AdSense ad from
169
+ `pagead2.googlesyndication.com`. That's a real DNS query *and* a real
170
+ HTTP request to an undeclared third party, sitting in a 20-year-old
171
+ capture nobody built for this purpose — exactly the shape `drift` exists
172
+ to catch, found in genuine traffic rather than constructed to prove a
173
+ point.
174
+
175
+ ## Tests
176
+
177
+ ```
178
+ pip install -e .
179
+ python tests/test_pcap.py # domain extraction, against the real fixture above
180
+ python tests/test_claims.py # claims.yaml validation
181
+ python tests/test_check.py # pass/fail/unverified classification
182
+ python tests/test_cli.py # the real CLI entry point, against the real fixture
183
+ ```
184
+
185
+ 29 tests.
186
+
187
+ MIT licensed.
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ drift/__init__.py
5
+ drift/check.py
6
+ drift/claims.py
7
+ drift/cli.py
8
+ drift/pcap.py
9
+ drift_evidence.egg-info/PKG-INFO
10
+ drift_evidence.egg-info/SOURCES.txt
11
+ drift_evidence.egg-info/dependency_links.txt
12
+ drift_evidence.egg-info/entry_points.txt
13
+ drift_evidence.egg-info/requires.txt
14
+ drift_evidence.egg-info/top_level.txt
15
+ tests/test_check.py
16
+ tests/test_claims.py
17
+ tests/test_cli.py
18
+ tests/test_pcap.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ drift = drift.cli:main
@@ -0,0 +1,2 @@
1
+ scapy>=2.5
2
+ PyYAML>=6.0
@@ -0,0 +1,40 @@
1
+ [project]
2
+ # "drift" was already taken on PyPI, same story as most siblings in this
3
+ # portfolio. The installed command stays the short name -- python-dateutil
4
+ # installs `dateutil`, this installs `drift`.
5
+ name = "drift-evidence"
6
+ version = "0.1.0"
7
+ description = "Does a device's real network traffic match what it's declared to be allowed to contact? Checked against a real packet capture."
8
+ requires-python = ">=3.10"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["iot", "privacy", "network", "pcap", "evidence", "provenance"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Security",
20
+ ]
21
+ # scapy for reading the pcap format itself -- there's no lighter way to
22
+ # parse real packet captures correctly. PyYAML for claims.yaml, same real
23
+ # dependency invariant/escrow/portable already use for their own config
24
+ # files, not a new convention introduced here.
25
+ dependencies = ["scapy>=2.5", "PyYAML>=6.0"]
26
+
27
+ urls.Homepage = "https://github.com/MaXiMo000/drift"
28
+ urls.Source = "https://github.com/MaXiMo000/drift"
29
+ urls.Issues = "https://github.com/MaXiMo000/drift/issues"
30
+ urls.Changelog = "https://github.com/MaXiMo000/drift/releases"
31
+
32
+ [project.scripts]
33
+ drift = "drift.cli:main"
34
+
35
+ [build-system]
36
+ requires = ["setuptools>=77"]
37
+ build-backend = "setuptools.build_meta"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["drift*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,69 @@
1
+ """Run: python tests/test_check.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
9
+
10
+ from drift.check import FAIL, PASS, UNVERIFIED, check_device, check_pcap
11
+
12
+
13
+ class TestCheckDevice(unittest.TestCase):
14
+ def test_no_claims_is_unverified_never_a_silent_pass(self):
15
+ r = check_device("10.0.0.5", {"dns": ["vendor.com"], "http": []}, claims=None)
16
+ self.assertEqual(r["status"], UNVERIFIED)
17
+
18
+ def test_an_undeclared_devices_observed_domains_still_appear_in_the_report(self):
19
+ """Privacy-relevant, documented in README: a device with no claims
20
+ entry (a housemate's phone caught in a shared-network capture, not
21
+ just an undeclared IoT device) still gets its full domain list
22
+ included in the output -- 'unverified' doesn't mean 'omitted'."""
23
+ r = check_device("10.0.0.9", {"dns": ["some-random-site.example"], "http": []}, claims=None)
24
+ self.assertEqual(r["observed_domains"], ["some-random-site.example"])
25
+
26
+ def test_only_declared_domains_is_pass(self):
27
+ claims = {"name": "bulb", "allowed_domains": ["vendor.com"]}
28
+ r = check_device("10.0.0.5", {"dns": ["vendor.com"], "http": []}, claims)
29
+ self.assertEqual(r["status"], PASS)
30
+
31
+ def test_an_undeclared_domain_is_fail_and_named(self):
32
+ claims = {"name": "bulb", "allowed_domains": ["vendor.com"]}
33
+ r = check_device("10.0.0.5", {"dns": ["vendor.com", "ads.example.com"], "http": []}, claims)
34
+ self.assertEqual(r["status"], FAIL)
35
+ self.assertIn("ads.example.com", r["unexpected_domains"])
36
+ self.assertIn("ads.example.com", r["detail"])
37
+
38
+ def test_dns_and_http_observations_are_merged_and_deduplicated(self):
39
+ claims = {"name": "bulb", "allowed_domains": ["vendor.com"]}
40
+ r = check_device("10.0.0.5", {"dns": ["vendor.com"], "http": ["vendor.com"]}, claims)
41
+ self.assertEqual(r["observed_domains"], ["vendor.com"])
42
+
43
+ def test_case_and_trailing_dot_do_not_cause_a_false_mismatch(self):
44
+ claims = {"name": "bulb", "allowed_domains": ["vendor.com"]}
45
+ r = check_device("10.0.0.5", {"dns": ["Vendor.com."], "http": []}, claims)
46
+ self.assertEqual(r["status"], PASS)
47
+
48
+ def test_no_observations_at_all_with_claims_is_a_clean_pass(self):
49
+ claims = {"name": "bulb", "allowed_domains": ["vendor.com"]}
50
+ r = check_device("10.0.0.5", {"dns": [], "http": []}, claims)
51
+ self.assertEqual(r["status"], PASS)
52
+ self.assertEqual(r["observed_domains"], [])
53
+
54
+
55
+ class TestCheckPcap(unittest.TestCase):
56
+ def test_multiple_devices_evaluated_independently_and_sorted(self):
57
+ observed = {
58
+ "10.0.0.2": {"dns": ["ads.example.com"], "http": []},
59
+ "10.0.0.1": {"dns": ["vendor.com"], "http": []},
60
+ }
61
+ claims = {"10.0.0.1": {"name": "a", "allowed_domains": ["vendor.com"]}}
62
+ results = check_pcap(observed, claims)
63
+ self.assertEqual([r["device_id"] for r in results], ["10.0.0.1", "10.0.0.2"])
64
+ self.assertEqual(results[0]["status"], PASS)
65
+ self.assertEqual(results[1]["status"], UNVERIFIED) # 10.0.0.2 has no claims entry
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()
@@ -0,0 +1,76 @@
1
+ """Run: python tests/test_claims.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
10
+
11
+ from drift.claims import ClaimsError, load_claims
12
+
13
+
14
+ class TestLoadClaims(unittest.TestCase):
15
+ def setUp(self):
16
+ self.tmp = tempfile.TemporaryDirectory()
17
+ self.path = pathlib.Path(self.tmp.name) / "claims.yaml"
18
+
19
+ def tearDown(self):
20
+ self.tmp.cleanup()
21
+
22
+ def _write(self, text: str) -> str:
23
+ self.path.write_text(text, encoding="utf-8")
24
+ return str(self.path)
25
+
26
+ def test_valid_claims(self):
27
+ p = self._write(
28
+ "devices:\n - id: '10.0.0.5'\n name: bulb\n allowed_domains: [Vendor.com]\n"
29
+ )
30
+ claims = load_claims(p)
31
+ self.assertEqual(claims, {"10.0.0.5": {"name": "bulb", "allowed_domains": ["vendor.com"]}})
32
+
33
+ def test_domain_normalization_lowercases_and_strips_trailing_dot(self):
34
+ p = self._write("devices:\n - id: x\n allowed_domains: ['Example.COM.']\n")
35
+ claims = load_claims(p)
36
+ self.assertEqual(claims["x"]["allowed_domains"], ["example.com"])
37
+
38
+ def test_name_defaults_to_id_when_absent(self):
39
+ p = self._write("devices:\n - id: x\n allowed_domains: []\n")
40
+ claims = load_claims(p)
41
+ self.assertEqual(claims["x"]["name"], "x")
42
+
43
+ def test_missing_file_is_a_claims_error_not_a_crash(self):
44
+ with self.assertRaises(ClaimsError):
45
+ load_claims(str(self.path))
46
+
47
+ def test_not_yaml_is_a_claims_error(self):
48
+ p = self._write("not: valid: yaml: at: all:::")
49
+ with self.assertRaises(ClaimsError):
50
+ load_claims(p)
51
+
52
+ def test_missing_devices_key_is_rejected(self):
53
+ p = self._write("something_else: true\n")
54
+ with self.assertRaises(ClaimsError) as ctx:
55
+ load_claims(p)
56
+ self.assertIn("devices", str(ctx.exception))
57
+
58
+ def test_device_missing_id_is_rejected(self):
59
+ p = self._write("devices:\n - name: x\n")
60
+ with self.assertRaises(ClaimsError) as ctx:
61
+ load_claims(p)
62
+ self.assertIn("id", str(ctx.exception))
63
+
64
+ def test_duplicate_device_ids_are_rejected(self):
65
+ p = self._write("devices:\n - id: x\n - id: x\n")
66
+ with self.assertRaises(ClaimsError):
67
+ load_claims(p)
68
+
69
+ def test_allowed_domains_defaults_to_empty_list(self):
70
+ p = self._write("devices:\n - id: x\n")
71
+ claims = load_claims(p)
72
+ self.assertEqual(claims["x"]["allowed_domains"], [])
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
@@ -0,0 +1,98 @@
1
+ """Run: python tests/test_cli.py
2
+
3
+ Exercises the real CLI entry point against the real pcap fixture (see
4
+ test_pcap.py's docstring for its provenance) -- not a mocked
5
+ extract_domains().
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import io
11
+ import json
12
+ import pathlib
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+
17
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
18
+
19
+ from drift.cli import main
20
+
21
+ FIXTURE = str(pathlib.Path(__file__).parent / "fixtures" / "http.cap")
22
+
23
+
24
+ class TestCli(unittest.TestCase):
25
+ def setUp(self):
26
+ self.tmp = tempfile.TemporaryDirectory()
27
+ self.claims_path = pathlib.Path(self.tmp.name) / "claims.yaml"
28
+
29
+ def tearDown(self):
30
+ self.tmp.cleanup()
31
+
32
+ def test_real_pcap_with_an_undeclared_domain_fails(self):
33
+ self.claims_path.write_text(
34
+ "devices:\n"
35
+ " - id: '145.254.160.237'\n"
36
+ " name: test workstation\n"
37
+ " allowed_domains: [www.ethereal.com]\n"
38
+ )
39
+ buf = io.StringIO()
40
+ with contextlib.redirect_stdout(buf):
41
+ code = main(["check", FIXTURE, str(self.claims_path)])
42
+ self.assertEqual(code, 1)
43
+ self.assertIn("pagead2.googlesyndication.com", buf.getvalue())
44
+ self.assertIn("[XX]", buf.getvalue())
45
+
46
+ def test_real_pcap_with_both_domains_declared_passes(self):
47
+ self.claims_path.write_text(
48
+ "devices:\n"
49
+ " - id: '145.254.160.237'\n"
50
+ " allowed_domains: [www.ethereal.com, pagead2.googlesyndication.com]\n"
51
+ )
52
+ buf = io.StringIO()
53
+ with contextlib.redirect_stdout(buf):
54
+ code = main(["check", FIXTURE, str(self.claims_path)])
55
+ self.assertEqual(code, 0)
56
+ self.assertIn("1/1 pass", buf.getvalue())
57
+
58
+ def test_unknown_device_in_the_capture_is_unverified_not_a_fail(self):
59
+ self.claims_path.write_text("devices:\n - id: '10.0.0.99'\n allowed_domains: []\n")
60
+ buf = io.StringIO()
61
+ with contextlib.redirect_stdout(buf):
62
+ code = main(["check", FIXTURE, str(self.claims_path)])
63
+ self.assertEqual(code, 0)
64
+ self.assertIn("[??]", buf.getvalue())
65
+
66
+ def test_missing_pcap_is_a_clean_error_not_a_traceback(self):
67
+ self.claims_path.write_text("devices:\n - id: x\n allowed_domains: []\n")
68
+ with self.assertRaises(SystemExit) as ctx:
69
+ main(["check", "/no/such/file.pcap", str(self.claims_path)])
70
+ self.assertIn("drift:", str(ctx.exception))
71
+
72
+ def test_bad_claims_file_is_a_clean_error_not_a_traceback(self):
73
+ self.claims_path.write_text("not: a valid claims file\n")
74
+ with self.assertRaises(SystemExit) as ctx:
75
+ main(["check", FIXTURE, str(self.claims_path)])
76
+ self.assertIn("drift:", str(ctx.exception))
77
+
78
+ def test_not_actually_a_pcap_file_is_a_clean_error(self):
79
+ bad_pcap = pathlib.Path(self.tmp.name) / "not_a_pcap.pcap"
80
+ bad_pcap.write_text("this is definitely not packet capture data")
81
+ self.claims_path.write_text("devices:\n - id: x\n allowed_domains: []\n")
82
+ with self.assertRaises(SystemExit) as ctx:
83
+ main(["check", str(bad_pcap), str(self.claims_path)])
84
+ self.assertIn("drift:", str(ctx.exception))
85
+
86
+ def test_json_flag_prints_the_full_report(self):
87
+ self.claims_path.write_text(
88
+ "devices:\n - id: '145.254.160.237'\n allowed_domains: [www.ethereal.com]\n"
89
+ )
90
+ buf = io.StringIO()
91
+ with contextlib.redirect_stdout(buf):
92
+ main(["check", FIXTURE, str(self.claims_path), "--json"])
93
+ report = json.loads(buf.getvalue())
94
+ self.assertEqual(report[0]["status"], "fail")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ """Run: python tests/test_pcap.py
2
+
3
+ Against a real packet capture (tests/fixtures/http.cap, downloaded from
4
+ Wireshark's own official SampleCaptures wiki -- see
5
+ tests/fixtures/PROVENANCE.md), not a synthetic one. Real traffic from
6
+ 2004: a client hitting www.ethereal.com that also pulled in a Google
7
+ AdSense ad from pagead2.googlesyndication.com, both via a real DNS query
8
+ and a real HTTP request -- exactly the "declared one thing, actually did
9
+ another" shape this whole tool exists to catch, found in genuine traffic,
10
+ not constructed to make a point.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import pathlib
15
+ import sys
16
+ import unittest
17
+
18
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
19
+
20
+ from drift.pcap import extract_domains
21
+
22
+ FIXTURE = str(pathlib.Path(__file__).parent / "fixtures" / "http.cap")
23
+
24
+
25
+ class TestExtractDomains(unittest.TestCase):
26
+ def test_the_one_real_device_in_the_capture_is_found(self):
27
+ result = extract_domains(FIXTURE)
28
+ self.assertIn("145.254.160.237", result)
29
+
30
+ def test_the_real_dns_query_is_extracted(self):
31
+ result = extract_domains(FIXTURE)
32
+ self.assertIn("pagead2.googlesyndication.com", result["145.254.160.237"]["dns"])
33
+
34
+ def test_the_real_http_host_headers_are_extracted(self):
35
+ result = extract_domains(FIXTURE)
36
+ http_hosts = result["145.254.160.237"]["http"]
37
+ self.assertIn("www.ethereal.com", http_hosts)
38
+ self.assertIn("pagead2.googlesyndication.com", http_hosts)
39
+
40
+ def test_results_are_sorted_and_deduplicated(self):
41
+ result = extract_domains(FIXTURE)
42
+ http_hosts = result["145.254.160.237"]["http"]
43
+ self.assertEqual(http_hosts, sorted(set(http_hosts)))
44
+
45
+ def test_a_nonexistent_pcap_path_raises_an_os_error(self):
46
+ with self.assertRaises(OSError):
47
+ extract_domains("/no/such/file.pcap")
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()