loghunter-cli 0.1.0.dev0__py3-none-any.whl

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.
Files changed (122) hide show
  1. loghunter/__init__.py +3 -0
  2. loghunter/cli.py +1108 -0
  3. loghunter/cli_init.py +567 -0
  4. loghunter/common/__init__.py +1 -0
  5. loghunter/common/allowlist.py +436 -0
  6. loghunter/common/clustering.py +326 -0
  7. loghunter/common/config.py +221 -0
  8. loghunter/common/display.py +323 -0
  9. loghunter/common/errors.py +45 -0
  10. loghunter/common/finding.py +239 -0
  11. loghunter/common/loader/__init__.py +136 -0
  12. loghunter/common/loader/diagnostics.py +94 -0
  13. loghunter/common/loader/discovery.py +335 -0
  14. loghunter/common/loader/io.py +76 -0
  15. loghunter/common/loader/pipeline.py +1010 -0
  16. loghunter/common/loader/sniff.py +184 -0
  17. loghunter/common/loader/types.py +207 -0
  18. loghunter/common/loader/windowing.py +523 -0
  19. loghunter/common/output.py +93 -0
  20. loghunter/common/paths.py +105 -0
  21. loghunter/common/sources.py +392 -0
  22. loghunter/data/allowlist/connections.txt +50 -0
  23. loghunter/data/allowlist/domains_devices.txt +5 -0
  24. loghunter/data/allowlist/domains_homelab.txt +5 -0
  25. loghunter/data/allowlist/domains_universal.txt +125 -0
  26. loghunter/data/config_example.toml +144 -0
  27. loghunter/detectors/__init__.py +5 -0
  28. loghunter/detectors/auth.py +27 -0
  29. loghunter/detectors/aws.py +671 -0
  30. loghunter/detectors/beacon.py +258 -0
  31. loghunter/detectors/dns.py +778 -0
  32. loghunter/detectors/dnsblock.py +29 -0
  33. loghunter/detectors/duration.py +178 -0
  34. loghunter/detectors/protocol.py +26 -0
  35. loghunter/detectors/scan.py +735 -0
  36. loghunter/detectors/ssl.py +25 -0
  37. loghunter/detectors/syslog.py +266 -0
  38. loghunter/detectors/weird.py +27 -0
  39. loghunter/digest/__init__.py +43 -0
  40. loghunter/digest/_stats.py +182 -0
  41. loghunter/digest/blob.py +698 -0
  42. loghunter/digest/cloudtrail.py +341 -0
  43. loghunter/digest/conn.py +367 -0
  44. loghunter/digest/dns.py +364 -0
  45. loghunter/digest/syslog.py +269 -0
  46. loghunter/exporters/__init__.py +534 -0
  47. loghunter/exporters/cloudtrail.py +499 -0
  48. loghunter/exporters/splunk.py +222 -0
  49. loghunter/outputs/__init__.py +1 -0
  50. loghunter/outputs/allowlist.py +75 -0
  51. loghunter/outputs/csv.py +70 -0
  52. loghunter/outputs/email.py +44 -0
  53. loghunter/outputs/html.py +99 -0
  54. loghunter/outputs/json.py +77 -0
  55. loghunter/outputs/text.py +1422 -0
  56. loghunter/parsers/__init__.py +1 -0
  57. loghunter/parsers/cloudtrail.py +287 -0
  58. loghunter/parsers/dnsmasq.py +331 -0
  59. loghunter/parsers/syslog.py +150 -0
  60. loghunter/parsers/zeek.py +294 -0
  61. loghunter/parsers/zeek_tsv.py +310 -0
  62. loghunter/runner.py +1895 -0
  63. loghunter_cli-0.1.0.dev0.dist-info/METADATA +336 -0
  64. loghunter_cli-0.1.0.dev0.dist-info/RECORD +122 -0
  65. loghunter_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
  66. loghunter_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  67. loghunter_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  68. loghunter_cli-0.1.0.dev0.dist-info/top_level.txt +4 -0
  69. migrations/cloudtrail_parquet.py +59 -0
  70. migrations/conn_fft.py +550 -0
  71. migrations/conn_scan.py +1097 -0
  72. migrations/dns_dbscan.py +520 -0
  73. migrations/get_syslog.py +402 -0
  74. migrations/syslog_drain3.py +479 -0
  75. scratch/junk/parquet.py +59 -0
  76. tests/__init__.py +1 -0
  77. tests/_cloudtrail_fakes.py +116 -0
  78. tests/conftest.py +17 -0
  79. tests/test_allowlist_defaults_accessor.py +90 -0
  80. tests/test_architecture_spine.py +302 -0
  81. tests/test_aws_detector.py +504 -0
  82. tests/test_be_like_water.py +106 -0
  83. tests/test_cli_help.py +342 -0
  84. tests/test_cli_multi_positional.py +458 -0
  85. tests/test_cloudtrail_exporter.py +631 -0
  86. tests/test_cloudtrail_exporter_botocore.py +207 -0
  87. tests/test_cloudtrail_parser.py +393 -0
  88. tests/test_clustering.py +85 -0
  89. tests/test_clustering_interruptible.py +404 -0
  90. tests/test_config_cli.py +1006 -0
  91. tests/test_config_example_drift.py +164 -0
  92. tests/test_digest_blob.py +1237 -0
  93. tests/test_digest_cli.py +1040 -0
  94. tests/test_digest_cloudtrail.py +980 -0
  95. tests/test_digest_conn.py +1189 -0
  96. tests/test_digest_dns.py +770 -0
  97. tests/test_digest_stats.py +282 -0
  98. tests/test_digest_syslog.py +724 -0
  99. tests/test_display.py +370 -0
  100. tests/test_dns_detector.py +1010 -0
  101. tests/test_dnsmasq_parser.py +467 -0
  102. tests/test_duration_detector.py +491 -0
  103. tests/test_export_orchestrator_shape.py +153 -0
  104. tests/test_init_wizard.py +707 -0
  105. tests/test_loader.py +3639 -0
  106. tests/test_loader_package_surface.py +115 -0
  107. tests/test_loader_window_model.py +215 -0
  108. tests/test_output_path_cascade.py +575 -0
  109. tests/test_resolve_path.py +111 -0
  110. tests/test_root_provenance.py +212 -0
  111. tests/test_runner.py +2599 -0
  112. tests/test_scan_detector.py +455 -0
  113. tests/test_search_paths.py +50 -0
  114. tests/test_sniff_orchestrator.py +373 -0
  115. tests/test_sniff_recognizers.py +573 -0
  116. tests/test_source_resolution_seam.py +471 -0
  117. tests/test_sources.py +648 -0
  118. tests/test_splunk_exporter.py +351 -0
  119. tests/test_syslog_detector.py +458 -0
  120. tests/test_syslog_parser.py +582 -0
  121. tests/test_text_output.py +1225 -0
  122. tests/test_zeek_tsv_parser.py +580 -0
@@ -0,0 +1,467 @@
1
+ """Tests for the dnsmasq/Pi-hole parser (parsers/dnsmasq.py).
2
+
3
+ All IP addresses use RFC 5737 documentation space: 192.0.2.x, 198.51.100.x.
4
+ All domain names use .test or .invalid placeholder TLDs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import timedelta, timezone
10
+ from datetime import datetime
11
+
12
+ import pytest
13
+
14
+ from loghunter.parsers.dnsmasq import parse_line
15
+ from loghunter.parsers.syslog import parse_timestamp
16
+
17
+
18
+ # ── Fixture helpers ───────────────────────────────────────────────────────────
19
+
20
+ def _line(message: str) -> str:
21
+ """Wrap a dnsmasq inner message in a valid outer header."""
22
+ return f"Jun 1 12:00:00 dnsmasq[623]: {message}"
23
+
24
+
25
+ def _line_with_host(message: str) -> str:
26
+ """Wrap a dnsmasq inner message in a valid syslog header with a hostname."""
27
+ return f"Jun 1 12:00:00 resolver-host dnsmasq[623]: {message}"
28
+
29
+
30
+ # ── Event-type parsing ────────────────────────────────────────────────────────
31
+
32
+ def test_parse_query_line() -> None:
33
+ raw = _line("query[A] example.test from 192.0.2.1")
34
+ result = parse_line(raw)
35
+ assert result is not None
36
+ assert result["event_type"] == "query"
37
+ assert result["qtype"] == "A"
38
+ assert result["query"] == "example.test"
39
+ assert result["src"] == "192.0.2.1"
40
+ assert result["dst"] is None
41
+ assert result["answer"] is None
42
+
43
+
44
+ def test_parse_query_line_with_syslog_hostname() -> None:
45
+ raw = _line_with_host("query[A] example.test from 192.0.2.1")
46
+ result = parse_line(raw)
47
+ assert result is not None
48
+ assert result["event_type"] == "query"
49
+ assert result["query"] == "example.test"
50
+ assert result["src"] == "192.0.2.1"
51
+
52
+
53
+ def test_parse_forwarded_line() -> None:
54
+ raw = _line("forwarded example.test to 198.51.100.53")
55
+ result = parse_line(raw)
56
+ assert result is not None
57
+ assert result["event_type"] == "forwarded"
58
+ assert result["query"] == "example.test"
59
+ assert result["dst"] == "198.51.100.53"
60
+ assert result["src"] is None
61
+
62
+
63
+ def test_parse_reply_line() -> None:
64
+ raw = _line("reply example.test is 203.0.113.1")
65
+ result = parse_line(raw)
66
+ assert result is not None
67
+ assert result["event_type"] == "reply"
68
+ assert result["query"] == "example.test"
69
+ assert result["answer"] == "203.0.113.1"
70
+
71
+
72
+ def test_parse_cached_line() -> None:
73
+ raw = _line("cached example.test is 203.0.113.1")
74
+ result = parse_line(raw)
75
+ assert result is not None
76
+ assert result["event_type"] == "cached"
77
+ assert result["query"] == "example.test"
78
+ assert result["answer"] == "203.0.113.1"
79
+
80
+
81
+ def test_parse_cached_stale_line() -> None:
82
+ """cached-stale maps to event_type 'cached', not 'cached-stale'."""
83
+ raw = _line("cached-stale example.test is 203.0.113.1")
84
+ result = parse_line(raw)
85
+ assert result is not None
86
+ assert result["event_type"] == "cached"
87
+ assert result["query"] == "example.test"
88
+
89
+
90
+ def test_parse_gravity_blocked_address() -> None:
91
+ raw = _line("gravity blocked bad.example.test is 0.0.0.0")
92
+ result = parse_line(raw)
93
+ assert result is not None
94
+ assert result["event_type"] == "gravity_blocked"
95
+ assert result["query"] == "bad.example.test"
96
+ assert result["answer"] == "0.0.0.0"
97
+
98
+
99
+ def test_parse_gravity_blocked_nodata() -> None:
100
+ """NODATA answer is captured as a plain string passthrough."""
101
+ raw = _line("gravity blocked bad.example.test is NODATA")
102
+ result = parse_line(raw)
103
+ assert result is not None
104
+ assert result["event_type"] == "gravity_blocked"
105
+ assert result["answer"] == "NODATA"
106
+
107
+
108
+ def test_parse_gravity_blocked_cname_address() -> None:
109
+ raw = _line("gravity blocked (CNAME) alias.example.test is 0.0.0.0")
110
+ result = parse_line(raw)
111
+ assert result is not None
112
+ assert result["event_type"] == "gravity_blocked"
113
+ assert result["query"] == "alias.example.test"
114
+ assert result["answer"] == "0.0.0.0"
115
+
116
+
117
+ def test_parse_gravity_blocked_cname_nodata() -> None:
118
+ raw = _line("gravity blocked (CNAME) alias.example.test is NODATA")
119
+ result = parse_line(raw)
120
+ assert result is not None
121
+ assert result["event_type"] == "gravity_blocked"
122
+ assert result["query"] == "alias.example.test"
123
+ assert result["answer"] == "NODATA"
124
+
125
+
126
+ def test_parse_gravity_blocked_ipv6_zero() -> None:
127
+ """:: (IPv6 null) answer is captured correctly."""
128
+ raw = _line("gravity blocked bad.example.test is ::")
129
+ result = parse_line(raw)
130
+ assert result is not None
131
+ assert result["event_type"] == "gravity_blocked"
132
+ assert result["answer"] == "::"
133
+
134
+
135
+ def test_parse_config_slash_form() -> None:
136
+ """/etc/hosts source form → event_type 'config'."""
137
+ raw = _line("/etc/hosts example.test is 192.0.2.10")
138
+ result = parse_line(raw)
139
+ assert result is not None
140
+ assert result["event_type"] == "config"
141
+ assert result["query"] == "example.test"
142
+ assert result["answer"] == "192.0.2.10"
143
+
144
+
145
+ def test_parse_config_slash_form_with_syslog_hostname() -> None:
146
+ raw = _line_with_host("/etc/hosts example.test is 192.0.2.10")
147
+ result = parse_line(raw)
148
+ assert result is not None
149
+ assert result["event_type"] == "config"
150
+ assert result["query"] == "example.test"
151
+ assert result["answer"] == "192.0.2.10"
152
+
153
+
154
+ def test_parse_config_keyword_form() -> None:
155
+ """'config' keyword source form → event_type 'config'."""
156
+ raw = _line("config example.test is NODATA")
157
+ result = parse_line(raw)
158
+ assert result is not None
159
+ assert result["event_type"] == "config"
160
+ assert result["query"] == "example.test"
161
+ assert result["answer"] == "NODATA"
162
+
163
+
164
+ def test_parse_validation_line() -> None:
165
+ raw = _line("validation result is SECURE")
166
+ result = parse_line(raw)
167
+ assert result is not None
168
+ assert result["event_type"] == "validation"
169
+ assert result["validation"] == "SECURE"
170
+ assert result["query"] is None
171
+
172
+
173
+ def test_parse_unknown_passthrough() -> None:
174
+ """Unrecognized inner message → event_type 'unknown', query=None, raw/message kept."""
175
+ raw = _line("something totally unrecognized xyz-12345")
176
+ result = parse_line(raw)
177
+ assert result is not None
178
+ assert result["event_type"] == "unknown"
179
+ assert result["query"] is None
180
+ assert result["raw"] == raw
181
+ assert "unrecognized" in result["message"]
182
+
183
+
184
+ # ── Outer-grammar failures and null inputs ────────────────────────────────────
185
+
186
+ def test_parse_outer_fail_returns_none() -> None:
187
+ assert parse_line("not a valid dnsmasq line at all") is None
188
+
189
+
190
+ def test_parse_blank_returns_none() -> None:
191
+ assert parse_line("") is None
192
+
193
+
194
+ def test_parse_comment_returns_none() -> None:
195
+ assert parse_line("# this is a comment") is None
196
+
197
+
198
+ # ── Timestamp behaviour ───────────────────────────────────────────────────────
199
+
200
+ def test_timestamp_utc_aware() -> None:
201
+ """Parsed timestamp carries tzinfo=timezone.utc."""
202
+ raw = _line("query[A] example.test from 192.0.2.1")
203
+ result = parse_line(raw)
204
+ assert result is not None
205
+ assert result["ts"] is not None
206
+ assert result["ts"].tzinfo == timezone.utc
207
+
208
+
209
+ def test_timestamp_year_rollback() -> None:
210
+ """A timestamp more than 7 days in the future is rolled back to the prior year."""
211
+ future = (
212
+ __import__("datetime").datetime.now(timezone.utc) + timedelta(days=10)
213
+ ).replace(hour=12, minute=0, second=0, microsecond=0)
214
+ raw = f"{future.strftime('%b')} {future.day:2d} 12:00:00 dnsmasq[1]: query[A] x.test from 192.0.2.1"
215
+ result = parse_line(raw)
216
+ assert result is not None
217
+ ts = result["ts"]
218
+ assert ts is not None
219
+ assert ts == future.replace(year=future.year - 1)
220
+
221
+
222
+ # ── Schema contract ───────────────────────────────────────────────────────────
223
+
224
+ def test_canonical_key_is_query_not_domain() -> None:
225
+ """The emitted dict uses 'query' as the key, never 'domain'."""
226
+ raw = _line("query[A] example.test from 192.0.2.1")
227
+ result = parse_line(raw)
228
+ assert result is not None
229
+ assert "query" in result
230
+ assert "domain" not in result
231
+
232
+
233
+ def test_host_is_empty_string() -> None:
234
+ """Parser leaves host as '' — the loader fills it from the filename stem."""
235
+ raw = _line("query[A] example.test from 192.0.2.1")
236
+ result = parse_line(raw)
237
+ assert result is not None
238
+ assert result["host"] == ""
239
+
240
+
241
+ def test_all_keys_present_on_every_non_none_result() -> None:
242
+ """Every non-None result has the full canonical key set."""
243
+ expected_keys = {
244
+ "ts", "src", "query", "event_type", "qtype",
245
+ "dst", "answer", "validation", "host", "raw", "message",
246
+ }
247
+ lines = [
248
+ _line("query[A] example.test from 192.0.2.1"),
249
+ _line("forwarded example.test to 198.51.100.53"),
250
+ _line("reply example.test is 203.0.113.1"),
251
+ _line("cached example.test is 203.0.113.1"),
252
+ _line("gravity blocked bad.example.test is 0.0.0.0"),
253
+ _line("/etc/hosts example.test is 192.0.2.10"),
254
+ _line("validation result is SECURE"),
255
+ _line("dnssec-query[DS] example.test to 198.51.100.53"), # dnssec_query
256
+ _line("special domain example.test is 192.0.2.10"), # special
257
+ _line("DHCP 192.0.2.50 is myhost.test"), # dhcp
258
+ _line("Pi-hole hostname pihole.test is 192.0.2.1"), # pihole_hostname
259
+ _line("regex denied telemetry.example.test is 0.0.0.0"), # regex_blocked
260
+ _line("something totally unrecognized xyz-12345"), # unknown
261
+ ]
262
+ for raw in lines:
263
+ result = parse_line(raw)
264
+ assert result is not None, f"expected non-None for: {raw!r}"
265
+ assert set(result.keys()) == expected_keys, (
266
+ f"key mismatch for {raw!r}: got {set(result.keys())}"
267
+ )
268
+
269
+
270
+ # ── dnssec_query event type ───────────────────────────────────────────────────
271
+
272
+ def test_parse_dnssec_query_ds() -> None:
273
+ raw = _line("dnssec-query[DS] example.test to 198.51.100.53")
274
+ result = parse_line(raw)
275
+ assert result is not None
276
+ assert result["event_type"] == "dnssec_query"
277
+ assert result["qtype"] == "DS"
278
+ assert result["query"] == "example.test"
279
+ assert result["dst"] == "198.51.100.53"
280
+ assert result["src"] is None
281
+
282
+
283
+ def test_parse_dnssec_query_dnskey() -> None:
284
+ raw = _line("dnssec-query[DNSKEY] example.test to 198.51.100.53")
285
+ result = parse_line(raw)
286
+ assert result is not None
287
+ assert result["event_type"] == "dnssec_query"
288
+ assert result["qtype"] == "DNSKEY"
289
+
290
+
291
+ def test_parse_dnssec_query_not_forwarded() -> None:
292
+ """dnssec-query lines must not be misclassified as forwarded."""
293
+ raw = _line("dnssec-query[DS] example.test to 198.51.100.53")
294
+ result = parse_line(raw)
295
+ assert result is not None
296
+ assert result["event_type"] != "forwarded"
297
+
298
+
299
+ # ── special event type ────────────────────────────────────────────────────────
300
+
301
+ def test_parse_special_domain() -> None:
302
+ raw = _line("special domain example.test is 192.0.2.10")
303
+ result = parse_line(raw)
304
+ assert result is not None
305
+ assert result["event_type"] == "special"
306
+ assert result["query"] == "example.test"
307
+ assert result["answer"] == "192.0.2.10"
308
+
309
+
310
+ def test_parse_special_domain_not_config() -> None:
311
+ """special domain lines must not be misclassified as config."""
312
+ raw = _line("special domain example.test is 192.0.2.10")
313
+ result = parse_line(raw)
314
+ assert result is not None
315
+ assert result["event_type"] != "config"
316
+
317
+
318
+ def test_parse_special_domain_subdomain_variant() -> None:
319
+ """Subdomain-prefixed special domain (mask-h2 style) is captured correctly."""
320
+ raw = _line("special domain mask-h2.example.test is 192.0.2.10")
321
+ result = parse_line(raw)
322
+ assert result is not None
323
+ assert result["event_type"] == "special"
324
+ assert result["query"] == "mask-h2.example.test"
325
+
326
+
327
+ # ── dhcp event type ───────────────────────────────────────────────────────────
328
+
329
+ def test_parse_dhcp_ip_then_hostname() -> None:
330
+ """DHCP <ip> is <hostname> field order parses to dhcp with DNS fields None."""
331
+ raw = _line("DHCP 192.0.2.50 is myhost.test")
332
+ result = parse_line(raw)
333
+ assert result is not None
334
+ assert result["event_type"] == "dhcp"
335
+ assert result["query"] is None
336
+ assert result["src"] is None
337
+ assert result["qtype"] is None
338
+ assert result["validation"] is None
339
+
340
+
341
+ def test_parse_dhcp_hostname_then_ip() -> None:
342
+ """DHCP <hostname> is <ip> field order also parses to dhcp with DNS fields None."""
343
+ raw = _line("DHCP myhost.test is 192.0.2.50")
344
+ result = parse_line(raw)
345
+ assert result is not None
346
+ assert result["event_type"] == "dhcp"
347
+ assert result["query"] is None
348
+ assert result["src"] is None
349
+ assert result["qtype"] is None
350
+ assert result["validation"] is None
351
+
352
+
353
+ def test_parse_unknown_still_works() -> None:
354
+ """Genuinely unrecognized inner messages still fall through to event_type 'unknown'."""
355
+ raw = _line("something totally unrecognized xyz-12345")
356
+ result = parse_line(raw)
357
+ assert result is not None
358
+ assert result["event_type"] == "unknown"
359
+ assert result["query"] is None
360
+ assert result["raw"] == raw
361
+
362
+
363
+ # ── pihole_hostname event type ────────────────────────────────────────────────
364
+
365
+ def test_parse_pihole_hostname_address() -> None:
366
+ raw = _line("Pi-hole hostname pihole.test is 192.0.2.1")
367
+ result = parse_line(raw)
368
+ assert result is not None
369
+ assert result["event_type"] == "pihole_hostname"
370
+ assert result["query"] == "pihole.test"
371
+ assert result["answer"] == "192.0.2.1"
372
+
373
+
374
+ def test_parse_pihole_hostname_nodata() -> None:
375
+ """NODATA answer form is captured as-is — do not special-case."""
376
+ raw = _line("Pi-hole hostname pihole.test is NODATA")
377
+ result = parse_line(raw)
378
+ assert result is not None
379
+ assert result["event_type"] == "pihole_hostname"
380
+ assert result["answer"] == "NODATA"
381
+
382
+
383
+ def test_parse_pihole_hostname_not_config() -> None:
384
+ """Pi-hole hostname lines must not be misclassified as config."""
385
+ raw = _line("Pi-hole hostname pihole.test is 192.0.2.1")
386
+ result = parse_line(raw)
387
+ assert result is not None
388
+ assert result["event_type"] != "config"
389
+
390
+
391
+ def test_parse_pihole_hostname_unknown_still_unknown() -> None:
392
+ """Genuinely unrecognized lines are unaffected by the pihole_hostname grammar."""
393
+ raw = _line("something totally unrecognized xyz-99999")
394
+ result = parse_line(raw)
395
+ assert result is not None
396
+ assert result["event_type"] == "unknown"
397
+ assert result["query"] is None
398
+
399
+
400
+ # ── regex_blocked event type ──────────────────────────────────────────────────
401
+
402
+ def test_parse_regex_blocked_denied_address() -> None:
403
+ """Primary spelling (regex denied) with an address answer."""
404
+ raw = _line("regex denied telemetry.example.test is 0.0.0.0")
405
+ result = parse_line(raw)
406
+ assert result is not None
407
+ assert result["event_type"] == "regex_blocked"
408
+ assert result["query"] == "telemetry.example.test"
409
+ assert result["answer"] == "0.0.0.0"
410
+ assert result["validation"] == "regex denied"
411
+
412
+
413
+ def test_parse_regex_blocked_not_config() -> None:
414
+ """regex denied lines must not be misclassified as config."""
415
+ raw = _line("regex denied telemetry.example.test is 0.0.0.0")
416
+ result = parse_line(raw)
417
+ assert result is not None
418
+ assert result["event_type"] != "config"
419
+
420
+
421
+ def test_parse_regex_blocked_not_gravity_blocked() -> None:
422
+ """regex_blocked is a distinct event type from gravity_blocked."""
423
+ raw = _line("regex denied telemetry.example.test is 0.0.0.0")
424
+ result = parse_line(raw)
425
+ assert result is not None
426
+ assert result["event_type"] != "gravity_blocked"
427
+
428
+
429
+ def test_parse_regex_blacklisted_variant() -> None:
430
+ """'regex blacklisted' spelling also maps to regex_blocked."""
431
+ raw = _line("regex blacklisted tracker.example.test is 0.0.0.0")
432
+ result = parse_line(raw)
433
+ assert result is not None
434
+ assert result["event_type"] == "regex_blocked"
435
+ assert result["validation"] == "regex blacklisted"
436
+
437
+
438
+ def test_parse_exactly_denied_variant() -> None:
439
+ """'exactly denied' spelling also maps to regex_blocked."""
440
+ raw = _line("exactly denied tracker.example.test is 0.0.0.0")
441
+ result = parse_line(raw)
442
+ assert result is not None
443
+ assert result["event_type"] == "regex_blocked"
444
+ assert result["validation"] == "exactly denied"
445
+
446
+
447
+ def test_parse_regex_blocked_nodata_answer() -> None:
448
+ """NODATA answer form is captured as-is."""
449
+ raw = _line("regex denied telemetry.example.test is NODATA")
450
+ result = parse_line(raw)
451
+ assert result is not None
452
+ assert result["event_type"] == "regex_blocked"
453
+ assert result["answer"] == "NODATA"
454
+
455
+
456
+ def test_parse_canary_domain_still_unknown() -> None:
457
+ """DoH/DDR canary dispositions are not promoted — they stay in the unknown bucket."""
458
+ canary_lines = [
459
+ _line("Designated Resolver domain example.invalid is NODATA"),
460
+ _line("Mozilla canary domain example.invalid is NXDOMAIN"),
461
+ ]
462
+ for raw in canary_lines:
463
+ result = parse_line(raw)
464
+ assert result is not None, f"expected non-None for: {raw!r}"
465
+ assert result["event_type"] == "unknown", (
466
+ f"expected unknown for canary line, got {result['event_type']!r}"
467
+ )