-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_dns_security.py
More file actions
369 lines (276 loc) · 14.5 KB
/
Copy pathtest_dns_security.py
File metadata and controls
369 lines (276 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
"""
Deterministic, offline tests for src/dns_security.py.
NO real network: every test monkeypatches the single resolution chokepoint
(`_doh`) so canned DNS answers (or a simulated resolver failure) can be fed in.
Focus areas (per audit):
* SPF 'all' policy classification: -all / ~all / ?all / +all / missing
* DMARC policy: reject / quarantine / none / absent
* DKIM present vs not-probed
* Resolver / timeout FAILURE must NOT produce false 'missing/insecure'
findings — it must surface as lookup_failed (unknown state).
* No crash on malformed records; orchestrator never raises.
"""
import pytest
import src.dns_security as dns
from src.dns_security import DNSLookupError
# ─── DoH fakes ───────────────────────────────────────────────────────────────
def _answer(data):
return {"data": data}
def make_doh(table, fail=False, fail_types=None):
"""Build a fake _doh.
table: dict keyed by (name, rtype) -> list[answer-dict]. Missing key = NXDOMAIN
(authoritative empty answer).
fail: if True, every lookup raises DNSLookupError (total resolver failure).
fail_types: set of rtypes that should raise DNSLookupError (selective failure).
"""
fail_types = fail_types or set()
def _fake(name, rtype):
if fail:
raise DNSLookupError(f"simulated total failure for {name}/{rtype}")
if rtype in fail_types:
raise DNSLookupError(f"simulated {rtype} failure for {name}")
return table.get((name, rtype), [])
return _fake
def patch_doh(monkeypatch, fake):
monkeypatch.setattr(dns, "_doh", fake)
# ─── SPF policy classification ────────────────────────────────────────────────
def _spf(monkeypatch, record):
patch_doh(monkeypatch, make_doh({("d.test", "TXT"): [_answer(record)]}))
return dns.check_spf("d.test")
def test_spf_hardfail_minus_all(monkeypatch):
r = _spf(monkeypatch, "v=spf1 include:_spf.google.com -all")
assert r.present and r.lookup_failed is False
assert r.all_mechanism == "-all"
assert r.valid is True
# -all is correct: no SPF policy finding emitted.
assert not any(f.category == "SPF" for f in r.findings)
def test_spf_softfail_tilde_all(monkeypatch):
r = _spf(monkeypatch, "v=spf1 mx ~all")
assert r.all_mechanism == "~all"
assert any(f.title == "Weak SPF Policy (Softfail)" for f in r.findings)
def test_spf_neutral_question_all(monkeypatch):
r = _spf(monkeypatch, "v=spf1 a ?all")
assert r.all_mechanism == "?all"
assert any(f.title == "Neutral SPF Policy" for f in r.findings)
def test_spf_passall_critical(monkeypatch):
r = _spf(monkeypatch, "v=spf1 +all")
assert r.all_mechanism == "+all"
assert r.valid is False
assert any(f.severity == "CRITICAL" for f in r.findings)
def test_spf_missing_record(monkeypatch):
# Authoritative empty answer => genuinely absent => HIGH finding, NOT a failure.
patch_doh(monkeypatch, make_doh({}))
r = dns.check_spf("d.test")
assert r.present is False
assert r.lookup_failed is False
assert any(f.title == "Missing SPF Record" for f in r.findings)
def test_spf_all_not_matched_in_include_hostname(monkeypatch):
# 'include:sendall.example.com' must NOT be parsed as an 'all' mechanism.
r = _spf(monkeypatch, "v=spf1 include:sendall.example.com -all")
assert r.all_mechanism == "-all"
def test_spf_lookup_failure_is_not_missing(monkeypatch):
# Resolver failure must NOT assert 'Missing SPF'.
patch_doh(monkeypatch, make_doh({}, fail=True))
r = dns.check_spf("d.test")
assert r.lookup_failed is True
assert r.present is False
assert r.findings == [] # no false finding
def test_spf_multichunk_txt_concatenation(monkeypatch):
# Long TXT split into quoted chunks must be joined before parsing.
patch_doh(monkeypatch, make_doh(
{("d.test", "TXT"): [_answer('"v=spf1 include:a.com " "include:b.com -all"')]}
))
r = dns.check_spf("d.test")
assert r.present is True
assert r.all_mechanism == "-all"
assert "a.com" in r.includes and "b.com" in r.includes
def test_spf_excessive_lookups(monkeypatch):
rec = "v=spf1 " + " ".join(f"include:h{i}.com" for i in range(11)) + " -all"
r = _spf(monkeypatch, rec)
assert any(f.title == "Excessive SPF Lookups" for f in r.findings)
# ─── DMARC policy classification ──────────────────────────────────────────────
def _dmarc(monkeypatch, record, name="_dmarc.d.test"):
patch_doh(monkeypatch, make_doh({(name, "TXT"): [_answer(record)]}))
return dns.check_dmarc("d.test")
def test_dmarc_reject(monkeypatch):
r = _dmarc(monkeypatch, "v=DMARC1; p=reject; rua=mailto:a@d.test")
assert r.present and r.policy == "reject" and r.valid is True
assert not any(f.title.startswith("DMARC Policy") for f in r.findings)
def test_dmarc_none_flagged(monkeypatch):
r = _dmarc(monkeypatch, "v=DMARC1; p=none; rua=mailto:a@d.test")
assert r.policy == "none"
assert any(f.title == "DMARC Policy is 'None'" for f in r.findings)
def test_dmarc_quarantine(monkeypatch):
r = _dmarc(monkeypatch, "v=DMARC1; p=quarantine; rua=mailto:a@d.test")
assert r.policy == "quarantine" and r.valid is True
def test_dmarc_absent(monkeypatch):
patch_doh(monkeypatch, make_doh({}))
r = dns.check_dmarc("d.test")
assert r.present is False and r.lookup_failed is False
assert any(f.title == "Missing DMARC Record" for f in r.findings)
def test_dmarc_lookup_failure_is_not_missing(monkeypatch):
patch_doh(monkeypatch, make_doh({}, fail=True))
r = dns.check_dmarc("d.test")
assert r.lookup_failed is True
assert r.present is False
assert r.findings == []
def test_dmarc_org_fallback_failure_not_missing(monkeypatch):
# Apex _dmarc authoritatively absent, but org-domain fallback lookup fails.
# Must be inconclusive (lookup_failed), NOT 'Missing DMARC'.
calls = {"n": 0}
def fake(name, rtype):
calls["n"] += 1
if name == "_dmarc.sub.example.com":
return [] # authoritative empty
raise DNSLookupError("org fallback failed")
patch_doh(monkeypatch, fake)
r = dns.check_dmarc("sub.example.com")
assert r.lookup_failed is True
assert r.findings == []
def test_dmarc_invalid_pct(monkeypatch):
r = _dmarc(monkeypatch, "v=DMARC1; p=reject; pct=abc; rua=mailto:a@d.test")
assert r.pct == 100
assert any(f.title == "Invalid DMARC pct Tag" for f in r.findings)
def test_dmarc_subdomain_policy_none(monkeypatch):
r = _dmarc(monkeypatch, "v=DMARC1; p=reject; sp=none; rua=mailto:a@d.test")
assert r.subdomain_policy == "none"
assert any(f.title == "Weak DMARC Subdomain Policy" for f in r.findings)
def test_dmarc_case_insensitive(monkeypatch):
r = _dmarc(monkeypatch, "v=DMARC1; P=Reject; RUA=mailto:a@d.test")
assert r.policy == "reject" and r.valid is True
# ─── DKIM ─────────────────────────────────────────────────────────────────────
def test_dkim_present(monkeypatch):
sel = "google"
key = "v=DKIM1; k=rsa; p=" + "A" * 400
patch_doh(monkeypatch, make_doh(
{(f"{sel}._domainkey.d.test", "TXT"): [_answer(key)]}
))
r = dns.check_dkim("d.test")
assert sel in r.found_selectors
assert r.lookup_failed is False
assert not any(f.title == "No DKIM Selectors Found" for f in r.findings)
def test_dkim_not_probed_authoritative_absent(monkeypatch):
# All selectors authoritatively absent => genuine 'no DKIM' (MEDIUM finding),
# NOT a lookup failure.
patch_doh(monkeypatch, make_doh({}))
r = dns.check_dkim("d.test")
assert r.found_selectors == []
assert r.lookup_failed is False
assert any(f.title == "No DKIM Selectors Found" for f in r.findings)
def test_dkim_lookup_failure_is_not_absent(monkeypatch):
# Every selector probe errors => cannot assert DKIM absent.
patch_doh(monkeypatch, make_doh({}, fail=True))
r = dns.check_dkim("d.test")
assert r.lookup_failed is True
assert r.found_selectors == []
assert not any(f.title == "No DKIM Selectors Found" for f in r.findings)
def test_dkim_revoked_key(monkeypatch):
sel = "default"
patch_doh(monkeypatch, make_doh(
{(f"{sel}._domainkey.d.test", "TXT"): [_answer("v=DKIM1; k=rsa; p=")]}
))
r = dns.check_dkim("d.test")
assert sel in r.found_selectors
assert any(f.title == "Revoked DKIM Key" for f in r.findings)
# ─── DNSSEC ───────────────────────────────────────────────────────────────────
def test_dnssec_enabled(monkeypatch):
patch_doh(monkeypatch, make_doh({("d.test", "DS"): [_answer("12345 13 2 abcd")]}))
r = dns.check_dnssec("d.test")
assert r.enabled is True and r.lookup_failed is False
assert r.issues == []
def test_dnssec_absent(monkeypatch):
patch_doh(monkeypatch, make_doh({}))
r = dns.check_dnssec("d.test")
assert r.enabled is False and r.lookup_failed is False
assert any("DNSSEC not enabled" in i for i in r.issues)
def test_dnssec_lookup_failure_not_disabled(monkeypatch):
patch_doh(monkeypatch, make_doh({}, fail=True))
r = dns.check_dnssec("d.test")
assert r.lookup_failed is True
assert r.enabled is False
assert r.issues == [] # no false 'not enabled'
# ─── Spoofability scoring ─────────────────────────────────────────────────────
def test_spoofability_softfail_zero_when_dmarc_reject():
"""SPF ~all must not inflate spoofability when DMARC p=reject enforces."""
spf = dns.SPFResult(present=True, all_mechanism="~all", valid=True)
dkim = dns.DKIMResult(found_selectors=["google"])
dmarc = dns.DMARCResult(present=True, policy="reject")
spoofable, score = dns.calculate_spoofability(spf, dkim, dmarc)
assert spoofable is False
assert score == 0
def test_spf_softfail_finding_demoted_under_dmarc_reject(monkeypatch):
"""Report must not present SPF ~all as a standalone weakness under p=reject."""
def fake_spf(domain):
r = dns.SPFResult(present=True, record="v=spf1 ~all", all_mechanism="~all", valid=True)
r.findings.append(dns.AuditFinding(
title="Weak SPF Policy (Softfail)",
description="softfail",
remediation="use -all",
severity="LOW",
category="SPF",
))
return r
monkeypatch.setattr(dns, "check_spf", fake_spf)
monkeypatch.setattr(dns, "check_dmarc", lambda d: dns.DMARCResult(
present=True, policy="reject", record="v=DMARC1; p=reject", pct=100))
monkeypatch.setattr(dns, "check_dkim", lambda d: dns.DKIMResult(found_selectors=["google"]))
monkeypatch.setattr(dns, "check_dnssec", lambda d: dns.DNSSecResult(enabled=True))
monkeypatch.setattr(dns, "check_caa", lambda d: dns.CAAResult(present=True))
monkeypatch.setattr(dns, "check_mx", lambda d: [])
monkeypatch.setattr(dns, "check_zone_transfer", lambda d: (False, []))
monkeypatch.setattr(dns, "check_wildcard_dns", lambda d: False)
res = dns.check_dns_security("example.com")
# findings are plain dicts from _finding()
spf_items = [f for f in res.findings if "SPF" in f.get("title", "") or "softfail" in f.get("title", "").lower()]
assert spf_items, res.findings
assert all(f.get("severity") == "INFO" for f in spf_items)
assert any("DMARC" in f.get("title", "") or "mitigated" in f.get("title", "").lower() for f in spf_items)
def test_spoofability_lookup_failure_not_scored(monkeypatch):
# All three failed => score 0, NOT a false 'spoofable'.
spf = dns.SPFResult(lookup_failed=True)
dkim = dns.DKIMResult(lookup_failed=True)
dmarc = dns.DMARCResult(lookup_failed=True)
spoofable, score = dns.calculate_spoofability(spf, dkim, dmarc)
assert score == 0 and spoofable is False
def test_spoofability_genuinely_open(monkeypatch):
spf = dns.SPFResult() # not present
dkim = dns.DKIMResult() # no selectors
dmarc = dns.DMARCResult() # not present
spoofable, score = dns.calculate_spoofability(spf, dkim, dmarc)
assert spoofable is True and score == 10
# ─── Orchestrator: fail-soft + no false findings on total resolver failure ────
def test_orchestrator_total_failure_no_false_findings(monkeypatch):
# Every DNS lookup fails. The scan must NOT crash and must NOT emit any
# 'missing/insecure/spoofable' finding.
patch_doh(monkeypatch, make_doh({}, fail=True))
# socket.gethostbyname is only reached via MX/zone-transfer; guard it too.
monkeypatch.setattr(dns.socket, "gethostbyname",
lambda *a, **k: (_ for _ in ()).throw(OSError("no net")))
res = dns.check_dns_security("d.test")
assert res.spf.lookup_failed and res.dmarc.lookup_failed
assert res.dkim.lookup_failed and res.dnssec.lookup_failed
assert res.caa.lookup_failed
assert res.wildcard_dns is False
assert res.email_spoofable is False
assert res.findings == [] # cardinal sin avoided: zero false positives
def test_orchestrator_does_not_raise_on_wildcard_failure(monkeypatch):
# Only the wildcard A query fails; everything else authoritatively absent.
# Previously this raised DNSLookupError out of the orchestrator.
patch_doh(monkeypatch, make_doh({}, fail_types={"A"}))
monkeypatch.setattr(dns.socket, "gethostbyname",
lambda *a, **k: (_ for _ in ()).throw(OSError("no net")))
res = dns.check_dns_security("d.test") # must not raise
assert res.wildcard_dns is False
def test_orchestrator_malformed_records_no_crash(monkeypatch):
table = {
("d.test", "TXT"): [_answer('"v=spf1"'), _answer("garbage not spf")],
("_dmarc.d.test", "TXT"): [_answer("v=DMARC1; p=; pct=; sp=")],
("d.test", "DS"): [_answer("")],
}
patch_doh(monkeypatch, make_doh(table))
monkeypatch.setattr(dns.socket, "gethostbyname",
lambda *a, **k: (_ for _ in ()).throw(OSError("no net")))
res = dns.check_dns_security("d.test") # must not raise
assert res.domain == "d.test"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))