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.
- drift_evidence-0.1.0/LICENSE +21 -0
- drift_evidence-0.1.0/PKG-INFO +187 -0
- drift_evidence-0.1.0/README.md +164 -0
- drift_evidence-0.1.0/drift/__init__.py +1 -0
- drift_evidence-0.1.0/drift/check.py +49 -0
- drift_evidence-0.1.0/drift/claims.py +52 -0
- drift_evidence-0.1.0/drift/cli.py +60 -0
- drift_evidence-0.1.0/drift/pcap.py +69 -0
- drift_evidence-0.1.0/drift_evidence.egg-info/PKG-INFO +187 -0
- drift_evidence-0.1.0/drift_evidence.egg-info/SOURCES.txt +18 -0
- drift_evidence-0.1.0/drift_evidence.egg-info/dependency_links.txt +1 -0
- drift_evidence-0.1.0/drift_evidence.egg-info/entry_points.txt +2 -0
- drift_evidence-0.1.0/drift_evidence.egg-info/requires.txt +2 -0
- drift_evidence-0.1.0/drift_evidence.egg-info/top_level.txt +1 -0
- drift_evidence-0.1.0/pyproject.toml +40 -0
- drift_evidence-0.1.0/setup.cfg +4 -0
- drift_evidence-0.1.0/tests/test_check.py +69 -0
- drift_evidence-0.1.0/tests/test_claims.py +76 -0
- drift_evidence-0.1.0/tests/test_cli.py +98 -0
- drift_evidence-0.1.0/tests/test_pcap.py +51 -0
|
@@ -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
|
+
[](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
|
+
[](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
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
drift
|
|
@@ -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,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()
|