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.
- loghunter/__init__.py +3 -0
- loghunter/cli.py +1108 -0
- loghunter/cli_init.py +567 -0
- loghunter/common/__init__.py +1 -0
- loghunter/common/allowlist.py +436 -0
- loghunter/common/clustering.py +326 -0
- loghunter/common/config.py +221 -0
- loghunter/common/display.py +323 -0
- loghunter/common/errors.py +45 -0
- loghunter/common/finding.py +239 -0
- loghunter/common/loader/__init__.py +136 -0
- loghunter/common/loader/diagnostics.py +94 -0
- loghunter/common/loader/discovery.py +335 -0
- loghunter/common/loader/io.py +76 -0
- loghunter/common/loader/pipeline.py +1010 -0
- loghunter/common/loader/sniff.py +184 -0
- loghunter/common/loader/types.py +207 -0
- loghunter/common/loader/windowing.py +523 -0
- loghunter/common/output.py +93 -0
- loghunter/common/paths.py +105 -0
- loghunter/common/sources.py +392 -0
- loghunter/data/allowlist/connections.txt +50 -0
- loghunter/data/allowlist/domains_devices.txt +5 -0
- loghunter/data/allowlist/domains_homelab.txt +5 -0
- loghunter/data/allowlist/domains_universal.txt +125 -0
- loghunter/data/config_example.toml +144 -0
- loghunter/detectors/__init__.py +5 -0
- loghunter/detectors/auth.py +27 -0
- loghunter/detectors/aws.py +671 -0
- loghunter/detectors/beacon.py +258 -0
- loghunter/detectors/dns.py +778 -0
- loghunter/detectors/dnsblock.py +29 -0
- loghunter/detectors/duration.py +178 -0
- loghunter/detectors/protocol.py +26 -0
- loghunter/detectors/scan.py +735 -0
- loghunter/detectors/ssl.py +25 -0
- loghunter/detectors/syslog.py +266 -0
- loghunter/detectors/weird.py +27 -0
- loghunter/digest/__init__.py +43 -0
- loghunter/digest/_stats.py +182 -0
- loghunter/digest/blob.py +698 -0
- loghunter/digest/cloudtrail.py +341 -0
- loghunter/digest/conn.py +367 -0
- loghunter/digest/dns.py +364 -0
- loghunter/digest/syslog.py +269 -0
- loghunter/exporters/__init__.py +534 -0
- loghunter/exporters/cloudtrail.py +499 -0
- loghunter/exporters/splunk.py +222 -0
- loghunter/outputs/__init__.py +1 -0
- loghunter/outputs/allowlist.py +75 -0
- loghunter/outputs/csv.py +70 -0
- loghunter/outputs/email.py +44 -0
- loghunter/outputs/html.py +99 -0
- loghunter/outputs/json.py +77 -0
- loghunter/outputs/text.py +1422 -0
- loghunter/parsers/__init__.py +1 -0
- loghunter/parsers/cloudtrail.py +287 -0
- loghunter/parsers/dnsmasq.py +331 -0
- loghunter/parsers/syslog.py +150 -0
- loghunter/parsers/zeek.py +294 -0
- loghunter/parsers/zeek_tsv.py +310 -0
- loghunter/runner.py +1895 -0
- loghunter_cli-0.1.0.dev0.dist-info/METADATA +336 -0
- loghunter_cli-0.1.0.dev0.dist-info/RECORD +122 -0
- loghunter_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- loghunter_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- loghunter_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- loghunter_cli-0.1.0.dev0.dist-info/top_level.txt +4 -0
- migrations/cloudtrail_parquet.py +59 -0
- migrations/conn_fft.py +550 -0
- migrations/conn_scan.py +1097 -0
- migrations/dns_dbscan.py +520 -0
- migrations/get_syslog.py +402 -0
- migrations/syslog_drain3.py +479 -0
- scratch/junk/parquet.py +59 -0
- tests/__init__.py +1 -0
- tests/_cloudtrail_fakes.py +116 -0
- tests/conftest.py +17 -0
- tests/test_allowlist_defaults_accessor.py +90 -0
- tests/test_architecture_spine.py +302 -0
- tests/test_aws_detector.py +504 -0
- tests/test_be_like_water.py +106 -0
- tests/test_cli_help.py +342 -0
- tests/test_cli_multi_positional.py +458 -0
- tests/test_cloudtrail_exporter.py +631 -0
- tests/test_cloudtrail_exporter_botocore.py +207 -0
- tests/test_cloudtrail_parser.py +393 -0
- tests/test_clustering.py +85 -0
- tests/test_clustering_interruptible.py +404 -0
- tests/test_config_cli.py +1006 -0
- tests/test_config_example_drift.py +164 -0
- tests/test_digest_blob.py +1237 -0
- tests/test_digest_cli.py +1040 -0
- tests/test_digest_cloudtrail.py +980 -0
- tests/test_digest_conn.py +1189 -0
- tests/test_digest_dns.py +770 -0
- tests/test_digest_stats.py +282 -0
- tests/test_digest_syslog.py +724 -0
- tests/test_display.py +370 -0
- tests/test_dns_detector.py +1010 -0
- tests/test_dnsmasq_parser.py +467 -0
- tests/test_duration_detector.py +491 -0
- tests/test_export_orchestrator_shape.py +153 -0
- tests/test_init_wizard.py +707 -0
- tests/test_loader.py +3639 -0
- tests/test_loader_package_surface.py +115 -0
- tests/test_loader_window_model.py +215 -0
- tests/test_output_path_cascade.py +575 -0
- tests/test_resolve_path.py +111 -0
- tests/test_root_provenance.py +212 -0
- tests/test_runner.py +2599 -0
- tests/test_scan_detector.py +455 -0
- tests/test_search_paths.py +50 -0
- tests/test_sniff_orchestrator.py +373 -0
- tests/test_sniff_recognizers.py +573 -0
- tests/test_source_resolution_seam.py +471 -0
- tests/test_sources.py +648 -0
- tests/test_splunk_exporter.py +351 -0
- tests/test_syslog_detector.py +458 -0
- tests/test_syslog_parser.py +582 -0
- tests/test_text_output.py +1225 -0
- 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
|
+
)
|