dns-shield 0.1.1__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.
- dns_shield-0.1.1/.gitignore +84 -0
- dns_shield-0.1.1/CHANGELOG.md +119 -0
- dns_shield-0.1.1/CONTRIBUTING.md +77 -0
- dns_shield-0.1.1/LICENSE +21 -0
- dns_shield-0.1.1/PKG-INFO +563 -0
- dns_shield-0.1.1/README.md +524 -0
- dns_shield-0.1.1/dns_shield/__init__.py +94 -0
- dns_shield-0.1.1/dns_shield/cli.py +365 -0
- dns_shield-0.1.1/dns_shield/config.py +80 -0
- dns_shield-0.1.1/dns_shield/detect.py +916 -0
- dns_shield-0.1.1/dns_shield/patch.py +232 -0
- dns_shield-0.1.1/dns_shield/py.typed +0 -0
- dns_shield-0.1.1/dns_shield/resolve.py +372 -0
- dns_shield-0.1.1/dns_shield/transport.py +624 -0
- dns_shield-0.1.1/pyproject.toml +132 -0
- dns_shield-0.1.1/tests/conftest.py +82 -0
- dns_shield-0.1.1/tests/test_adapter_and_live.py +161 -0
- dns_shield-0.1.1/tests/test_cli.py +349 -0
- dns_shield-0.1.1/tests/test_detect.py +656 -0
- dns_shield-0.1.1/tests/test_import_safety.py +268 -0
- dns_shield-0.1.1/tests/test_patch.py +177 -0
- dns_shield-0.1.1/tests/test_probe.py +548 -0
- dns_shield-0.1.1/tests/test_resolve.py +398 -0
- dns_shield-0.1.1/tests/test_transport.py +803 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Secrets and local configuration -- never commit these.
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.env
|
|
6
|
+
secrets
|
|
7
|
+
secrets.*
|
|
8
|
+
secrets/
|
|
9
|
+
secret
|
|
10
|
+
*.pem
|
|
11
|
+
*.key
|
|
12
|
+
*.p12
|
|
13
|
+
*.pfx
|
|
14
|
+
credentials.json
|
|
15
|
+
credentials*.yml
|
|
16
|
+
service-account*.json
|
|
17
|
+
.netrc
|
|
18
|
+
|
|
19
|
+
# Python
|
|
20
|
+
__pycache__/
|
|
21
|
+
*.py[cod]
|
|
22
|
+
*$py.class
|
|
23
|
+
*.so
|
|
24
|
+
.Python
|
|
25
|
+
build/
|
|
26
|
+
develop-eggs/
|
|
27
|
+
dist/
|
|
28
|
+
downloads/
|
|
29
|
+
eggs/
|
|
30
|
+
.eggs/
|
|
31
|
+
lib64/
|
|
32
|
+
parts/
|
|
33
|
+
sdist/
|
|
34
|
+
var/
|
|
35
|
+
wheels/
|
|
36
|
+
share/python-wheels/
|
|
37
|
+
*.egg-info/
|
|
38
|
+
.installed.cfg
|
|
39
|
+
*.egg
|
|
40
|
+
MANIFEST
|
|
41
|
+
|
|
42
|
+
# Virtual environments
|
|
43
|
+
.venv/
|
|
44
|
+
venv/
|
|
45
|
+
ENV/
|
|
46
|
+
env/
|
|
47
|
+
.python-version
|
|
48
|
+
|
|
49
|
+
# Testing and coverage
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
.tox/
|
|
52
|
+
.nox/
|
|
53
|
+
.coverage
|
|
54
|
+
.coverage.*
|
|
55
|
+
coverage.xml
|
|
56
|
+
htmlcov/
|
|
57
|
+
.hypothesis/
|
|
58
|
+
.mypy_cache/
|
|
59
|
+
.dmypy.json
|
|
60
|
+
ruff_cache/
|
|
61
|
+
.benchmarks/
|
|
62
|
+
|
|
63
|
+
# Build tooling
|
|
64
|
+
*.manifest
|
|
65
|
+
*.spec
|
|
66
|
+
pip-log.txt
|
|
67
|
+
pip-delete-this-directory.txt
|
|
68
|
+
site/
|
|
69
|
+
|
|
70
|
+
# Editors and OS
|
|
71
|
+
.vscode/
|
|
72
|
+
.idea/
|
|
73
|
+
*.swp
|
|
74
|
+
*.swo
|
|
75
|
+
*~
|
|
76
|
+
.DS_Store
|
|
77
|
+
Thumbs.db
|
|
78
|
+
|
|
79
|
+
# Jupyter
|
|
80
|
+
.ipynb_checkpoints/
|
|
81
|
+
|
|
82
|
+
# Local scratch -- diagnostic output may contain personal network details.
|
|
83
|
+
*.local
|
|
84
|
+
scratch/
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.1] - 2026-10-04
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Packaging and release metadata only; no code or behaviour changes from the
|
|
15
|
+
entries below. Added complete PyPI metadata (PEP 639 `license` expression,
|
|
16
|
+
author, project URLs and classifiers) and a Trusted Publishing (OIDC) release
|
|
17
|
+
workflow that publishes on `v*` tags.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **A false `POISONED` verdict when both resolvers returned the same address.**
|
|
22
|
+
The poisoning rule's guard was `answers_disagree or not system_alive`, and by
|
|
23
|
+
the time that rule was reached `not system_alive` was always true — so the
|
|
24
|
+
disagreement condition was never actually consulted. A host whose system probe
|
|
25
|
+
transiently failed while the DoH probe to the *same* address succeeded was
|
|
26
|
+
reported as poisoning, with a summary claiming the address "does not serve the
|
|
27
|
+
host" about an address the tool had just fetched HTTP 200 from. Two probes to
|
|
28
|
+
one literal IP, seconds apart, is a transient failure, not a resolver lie. The
|
|
29
|
+
rule now requires genuine disagreement, with an explicit branch for the
|
|
30
|
+
agreed-address case. Guarded by `TestSameAddressIsNeverPoisoning`, including an
|
|
31
|
+
exhaustive test over every probe-outcome combination asserting that no
|
|
32
|
+
non-disagreeing input can ever yield `POISONED`.
|
|
33
|
+
- `ConnectionResetError` was classified as `other-error` rather than `refused`.
|
|
34
|
+
It is not a subclass of `ConnectionRefusedError`, so it fell through to the
|
|
35
|
+
generic handler — an active refusal was reported as an unknown error. Both are
|
|
36
|
+
now classified by one shared `classify_connection_error`, so the mapping cannot
|
|
37
|
+
drift between probe paths.
|
|
38
|
+
- `read_head` accepted a header block truncated by a peer close, returning a
|
|
39
|
+
status with no headers and no body — indistinguishable from a legitimate empty
|
|
40
|
+
`200`, with the failure surfacing later as a confusing JSON decode error. It
|
|
41
|
+
now raises `connection closed before headers were complete`.
|
|
42
|
+
- `dechunk` silently dropped data on a malformed chunk terminator. It now
|
|
43
|
+
validates the CRLF and raises rather than presenting a short body as complete.
|
|
44
|
+
- Status-line parsing used `split(" ", 2)`, rejecting legal responses such as
|
|
45
|
+
`HTTP/1.1 204` (no reason phrase) and doubled spaces. Now parsed tolerantly.
|
|
46
|
+
- `backoff_delay` used pure full jitter with no floor, admitting a zero-length
|
|
47
|
+
sleep and a near-instant retry loop against a fast-refusing endpoint. A
|
|
48
|
+
`base_s` floor is now applied, including to honoured `retryAfter` hints.
|
|
49
|
+
- Resolution failures were labelled `kind="connect"`, misdirecting diagnosis of
|
|
50
|
+
a DoH outage as a connection problem. Now `kind="resolution"`.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- `ProbeResult` gains `slow_refusal` and `refusal_mode`, so an active refusal is
|
|
55
|
+
distinguished from a blackhole timeout in the evidence output.
|
|
56
|
+
- README: the poisoning fingerprint now documents **both** observed failure
|
|
57
|
+
modes (active refusal and blackhole timeout) with the ten-trial measurement
|
|
58
|
+
that shows the mode varies run to run, rather than asserting only the ~10 ms
|
|
59
|
+
RST from the original report. Exit-code and `--sibling` descriptions corrected
|
|
60
|
+
to match what the code actually does.
|
|
61
|
+
|
|
62
|
+
## [0.1.0] - 2026-09-28
|
|
63
|
+
|
|
64
|
+
Initial release.
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **`dns_shield.detect`** — diagnoses ISP DNS poisoning by comparing the system
|
|
69
|
+
resolver against DNS-over-HTTPS, then probing both answers.
|
|
70
|
+
- `Verdict` enum: `HEALTHY`, `POISONED`, `SUSPICIOUS`, `UNREACHABLE`, `UNKNOWN`.
|
|
71
|
+
- `FailureKind` classification: `SUCCESS`, `REFUSED`, `TIMEOUT`, `DNS_FAILURE`,
|
|
72
|
+
`TLS_FAILURE`, `HTTP_ERROR`, `OTHER_ERROR`.
|
|
73
|
+
- `ProbeResult` records connect latency, HTTP status, and the peer TLS
|
|
74
|
+
certificate subject/issuer, so a verdict can be audited.
|
|
75
|
+
- Deliberately distinguishes DNS poisoning from a genuine outage, a real
|
|
76
|
+
HTTP 451/403 geo-restriction, and TLS interception. The classifier reports
|
|
77
|
+
`UNREACHABLE` rather than accusing the resolver when the true address fails
|
|
78
|
+
as well.
|
|
79
|
+
- **`dns_shield.resolve`** — DNS-over-HTTPS resolver.
|
|
80
|
+
- Pluggable providers: Cloudflare, Google, Quad9, any `application/dns-json`
|
|
81
|
+
endpoint, or a chain of them with per-provider failover.
|
|
82
|
+
- TTL-aware cache; answers kept in the resolver's order and consumed
|
|
83
|
+
round-robin across the address pool.
|
|
84
|
+
- Returns the CNAME chain alongside A/AAAA records.
|
|
85
|
+
- **`dns_shield.transport`** — SNI-preserving HTTP/1.1 client.
|
|
86
|
+
- Resolves over DoH, then dials the resulting address with SNI and `Host` set
|
|
87
|
+
to the hostname. Never calls `socket.getaddrinfo` on the request path.
|
|
88
|
+
- Rotates across the resolved address set before failing; re-resolves only
|
|
89
|
+
after every known address has failed.
|
|
90
|
+
- Retries only genuinely retryable statuses (408, 425, 429, 500, 502, 503,
|
|
91
|
+
504) with exponential backoff and jitter, honouring a `retryAfter` hint.
|
|
92
|
+
- Optional `SuccessContract` for APIs that signal failure inside an
|
|
93
|
+
HTTP 200 body, with a Binance BAPI preset.
|
|
94
|
+
- **`dns_shield.patch`** — targeted override helpers.
|
|
95
|
+
- `ShieldSession` and `resolve_and_call()` for one-line use.
|
|
96
|
+
- `RequestsShieldAdapter` for opt-in, per-prefix `requests` integration.
|
|
97
|
+
- Explicitly scoped: nothing here patches global state or touches `/etc/hosts`.
|
|
98
|
+
- **`dns_shield.cli`** — `check`, `fetch` and `hosts` subcommands.
|
|
99
|
+
- Meaningful exit codes: 0 healthy, 1 poisoned/suspicious, 2
|
|
100
|
+
unreachable/unknown, 3 usage error.
|
|
101
|
+
- `--json` output on every subcommand.
|
|
102
|
+
- Typed throughout (`py.typed`), Python 3.10+, zero runtime dependencies.
|
|
103
|
+
- Offline test suite: 246 tests, with an autouse fixture that blocks real
|
|
104
|
+
sockets so no test can silently acquire a network dependency. Opt-in `live`
|
|
105
|
+
marker for integration tests.
|
|
106
|
+
|
|
107
|
+
### Notes
|
|
108
|
+
|
|
109
|
+
- Measured against the motivating case: an Indonesian ISP (Biznet) returning a
|
|
110
|
+
single bogus address for every `*.binance.com` hostname, while the real hosts
|
|
111
|
+
behind CloudFront answered HTTP 200.
|
|
112
|
+
- The bogus address was observed to fail non-deterministically — sometimes an
|
|
113
|
+
instant RST, sometimes a 2-second timeout. The classifier treats both as
|
|
114
|
+
consistent with poisoning and records the latency as evidence rather than
|
|
115
|
+
making it the verdict.
|
|
116
|
+
|
|
117
|
+
[Unreleased]: https://github.com/beduldul/dns-shield/compare/v0.1.1...HEAD
|
|
118
|
+
[0.1.1]: https://github.com/beduldul/dns-shield/compare/v0.1.0...v0.1.1
|
|
119
|
+
[0.1.0]: https://github.com/beduldul/dns-shield/releases/tag/v0.1.0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Contributing to dns-shield
|
|
2
|
+
|
|
3
|
+
Thanks for looking. Short version: keep it honest and keep the tests offline.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git clone https://github.com/beduldul/dns-shield
|
|
9
|
+
cd dns-shield
|
|
10
|
+
python -m venv .venv && . .venv/bin/activate
|
|
11
|
+
pip install -e ".[dev]"
|
|
12
|
+
python -m pytest -q
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## The two rules that matter
|
|
16
|
+
|
|
17
|
+
**1. Tests must not touch the network.** An autouse fixture in
|
|
18
|
+
`tests/conftest.py` blocks real sockets, so this is enforced rather than
|
|
19
|
+
requested. If you genuinely need the real network, mark the test:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
@pytest.mark.live
|
|
23
|
+
def test_something_live() -> None:
|
|
24
|
+
...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Live tests are deselected by default (`-m 'not live'` in `addopts`). Run them
|
|
28
|
+
with `python -m pytest -m live`. Keep them few and stable.
|
|
29
|
+
|
|
30
|
+
**2. Do not call anything "poisoned" without evidence.** The classifier in
|
|
31
|
+
`detect.py` is the part of this project most likely to do harm by being
|
|
32
|
+
confidently wrong. Before adding a rule, ask: *could this fire on a genuinely
|
|
33
|
+
down host, a genuine HTTP 451, or a corporate TLS proxy?* If yes, it needs
|
|
34
|
+
another condition. Every verdict branch needs a test proving it, including the
|
|
35
|
+
negative cases. `tests/test_detect.py::TestNotPoisoned` is the file to extend.
|
|
36
|
+
|
|
37
|
+
There is also a hard rule enforced in `tests/test_patch.py`: **never disable TLS
|
|
38
|
+
verification.** No `verify=False`, no `CERT_NONE`, no
|
|
39
|
+
`_create_unverified_context`. The technique works *because* certificates are
|
|
40
|
+
valid; accepting a bad one would be a silent security downgrade.
|
|
41
|
+
|
|
42
|
+
## Style
|
|
43
|
+
|
|
44
|
+
- Python 3.10+, full type hints (the package ships `py.typed`). Avoid `Any`
|
|
45
|
+
where a real type exists; the exceptions are the `urllib`/`requests` interop
|
|
46
|
+
boundaries.
|
|
47
|
+
- Immutability: return new values, don't mutate arguments.
|
|
48
|
+
- Small modules, small functions. `dns_shield/` is deliberately split so each
|
|
49
|
+
file has one job.
|
|
50
|
+
- Backticks in docstrings for `` `code` ``, and comments that explain *why*.
|
|
51
|
+
This codebase's comments carry measured facts and traps that are not
|
|
52
|
+
re-derivable from the code — please keep that standard.
|
|
53
|
+
- Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`,
|
|
54
|
+
`chore:`.
|
|
55
|
+
|
|
56
|
+
## Reporting a poisoning case
|
|
57
|
+
|
|
58
|
+
Genuinely useful bug reports include:
|
|
59
|
+
|
|
60
|
+
- the hostname and the country/ISP,
|
|
61
|
+
- `dns-shield check <host> --json` output, and
|
|
62
|
+
- `dig +short <host>` output from the same machine.
|
|
63
|
+
|
|
64
|
+
That lets the fingerprint be checked against a real case. Please redact anything
|
|
65
|
+
personal.
|
|
66
|
+
|
|
67
|
+
## Pull requests
|
|
68
|
+
|
|
69
|
+
- One logical change per PR.
|
|
70
|
+
- Tests for the change; the suite must pass offline.
|
|
71
|
+
- Note any user-visible change in `CHANGELOG.md` under *Unreleased*.
|
|
72
|
+
- If you add a verdict branch, add the negative test that proves it does not
|
|
73
|
+
misfire.
|
|
74
|
+
|
|
75
|
+
## Licence
|
|
76
|
+
|
|
77
|
+
Contributions are accepted under the MIT licence (see `LICENSE`).
|
dns_shield-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 beduldul
|
|
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.
|