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,436 @@
|
|
|
1
|
+
"""Allowlist loading and matching.
|
|
2
|
+
|
|
3
|
+
Two formats:
|
|
4
|
+
- Pattern files: flat text, one glob or regex per line, # comments.
|
|
5
|
+
Used for high-volume domain and IP lists.
|
|
6
|
+
- Stanza entries: TOML [[allowlist.entry]] blocks with match type, detector scoping,
|
|
7
|
+
and human-readable comments. Loaded from config inline or from allowlist_dir/*.toml.
|
|
8
|
+
|
|
9
|
+
AllowlistMatcher is the runner's single interface for pre-detector suppression.
|
|
10
|
+
|
|
11
|
+
Flat numeric rule format (for conn log suppression)
|
|
12
|
+
─────────────────────────────────────────────────────
|
|
13
|
+
One rule per line. # comments supported. Blank lines ignored.
|
|
14
|
+
A rule is whitespace-separated tokens:
|
|
15
|
+
|
|
16
|
+
IP/CIDR/wildcard fields — zero, one, or two; unordered for pair matching
|
|
17
|
+
port/proto token — leading colon: :443 :123/udp :*/tcp
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
192.0.2.10 198.51.100.1 :22/tcp # specific pair, specific port+proto
|
|
21
|
+
192.0.2.10 :22 # any flow involving this IP on port 22
|
|
22
|
+
192.0.2.0/24 :443 # entire subnet, port 443, any proto
|
|
23
|
+
* :123/udp # any host, UDP 123
|
|
24
|
+
:6556 # port only — suppress everywhere
|
|
25
|
+
192.0.2.33 # bare IP — all traffic involving this host
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ipaddress
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from fnmatch import fnmatch
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import pandas as pd
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AllowlistEntry:
|
|
42
|
+
"""A single stanza-style allowlist entry with match type and optional detector scope."""
|
|
43
|
+
|
|
44
|
+
match: str
|
|
45
|
+
comment: str = ""
|
|
46
|
+
detectors: list[str] = field(default_factory=list)
|
|
47
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class NumericRule:
|
|
52
|
+
"""A parsed flat-file numeric suppression rule.
|
|
53
|
+
|
|
54
|
+
ip1, ip2 — IP address, CIDR range, '*' wildcard, or None (matches anything).
|
|
55
|
+
IP pair matching is unordered: a rule fires regardless of which end is src/dst.
|
|
56
|
+
port — destination port number, or None to match any port.
|
|
57
|
+
proto — 'tcp', 'udp', 'icmp', or None to match any protocol.
|
|
58
|
+
detectors — if non-empty, rule applies only to listed detectors.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
ip1: str | None = None
|
|
62
|
+
ip2: str | None = None
|
|
63
|
+
port: int | None = None
|
|
64
|
+
proto: str | None = None
|
|
65
|
+
detectors: list[str] = field(default_factory=list)
|
|
66
|
+
comment: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AllowlistMatcher:
|
|
70
|
+
"""Pre-loaded allowlist ready to query.
|
|
71
|
+
|
|
72
|
+
Constructed by the framework and passed to detectors via DetectorContext.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
domain_patterns: list[str] | None = None,
|
|
78
|
+
entries: list[AllowlistEntry] | None = None,
|
|
79
|
+
numeric_rules: list[NumericRule] | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._domain_patterns: list[str] = domain_patterns or []
|
|
82
|
+
self._entries: list[AllowlistEntry] = entries or []
|
|
83
|
+
self._numeric_rules: list[NumericRule] = numeric_rules or []
|
|
84
|
+
|
|
85
|
+
def is_domain_allowed(self, domain: str, detector: str | None = None) -> bool:
|
|
86
|
+
"""Return True if domain matches any pattern in the loaded domain lists."""
|
|
87
|
+
for pattern in self._domain_patterns:
|
|
88
|
+
if pattern.startswith("re:"):
|
|
89
|
+
if re.search(pattern[3:], domain):
|
|
90
|
+
return True
|
|
91
|
+
else:
|
|
92
|
+
if fnmatch(domain, pattern):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def filter_df(self, df: pd.DataFrame, detector: str) -> pd.DataFrame:
|
|
97
|
+
"""Remove allowlisted rows from a normalized log DataFrame.
|
|
98
|
+
|
|
99
|
+
Connection logs use canonical src, dst, port, proto columns and numeric rules.
|
|
100
|
+
DNS logs use query plus domain pattern files. Missing columns are handled
|
|
101
|
+
gracefully — rules referencing absent columns are skipped rather than erroring.
|
|
102
|
+
"""
|
|
103
|
+
if df.empty:
|
|
104
|
+
return df
|
|
105
|
+
|
|
106
|
+
if "query" in df.columns:
|
|
107
|
+
return self._filter_domain_df(df, detector)
|
|
108
|
+
|
|
109
|
+
return self._filter_numeric_df(df, detector)
|
|
110
|
+
|
|
111
|
+
def _filter_domain_df(self, df: pd.DataFrame, detector: str) -> pd.DataFrame:
|
|
112
|
+
"""Remove DNS rows whose query matches a loaded domain pattern."""
|
|
113
|
+
drop_mask = df["query"].map(
|
|
114
|
+
lambda q: self.is_domain_allowed(str(q), detector)
|
|
115
|
+
or self.is_domain_allowed("x." + str(q), detector)
|
|
116
|
+
)
|
|
117
|
+
return df[~drop_mask].copy()
|
|
118
|
+
|
|
119
|
+
def _filter_numeric_df(self, df: pd.DataFrame, detector: str) -> pd.DataFrame:
|
|
120
|
+
"""Remove connection rows matching flat numeric suppression rules."""
|
|
121
|
+
has_src = "src" in df.columns
|
|
122
|
+
has_dst = "dst" in df.columns
|
|
123
|
+
has_port = "port" in df.columns
|
|
124
|
+
has_proto = "proto" in df.columns
|
|
125
|
+
|
|
126
|
+
drop_mask = pd.Series(False, index=df.index)
|
|
127
|
+
|
|
128
|
+
for rule in self._numeric_rules:
|
|
129
|
+
if rule.detectors and detector not in rule.detectors:
|
|
130
|
+
continue
|
|
131
|
+
rule_mask = _numeric_rule_mask(df, rule, has_src, has_dst, has_port, has_proto)
|
|
132
|
+
drop_mask |= rule_mask
|
|
133
|
+
|
|
134
|
+
return df[~drop_mask].copy()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _numeric_rule_mask(
|
|
138
|
+
df: pd.DataFrame,
|
|
139
|
+
rule: NumericRule,
|
|
140
|
+
has_src: bool,
|
|
141
|
+
has_dst: bool,
|
|
142
|
+
has_port: bool,
|
|
143
|
+
has_proto: bool,
|
|
144
|
+
) -> pd.Series:
|
|
145
|
+
"""Build a boolean mask for rows matching rule (True = this row is allowlisted)."""
|
|
146
|
+
idx = df.index
|
|
147
|
+
mask = pd.Series(True, index=idx)
|
|
148
|
+
|
|
149
|
+
# Port filter
|
|
150
|
+
if rule.port is not None:
|
|
151
|
+
if not has_port:
|
|
152
|
+
return pd.Series(False, index=idx)
|
|
153
|
+
mask &= df["port"] == rule.port
|
|
154
|
+
|
|
155
|
+
# Proto filter
|
|
156
|
+
if rule.proto is not None:
|
|
157
|
+
if not has_proto:
|
|
158
|
+
return pd.Series(False, index=idx)
|
|
159
|
+
mask &= df["proto"] == rule.proto
|
|
160
|
+
|
|
161
|
+
# IP filter
|
|
162
|
+
if rule.ip1 is not None or rule.ip2 is not None:
|
|
163
|
+
if not has_src or not has_dst:
|
|
164
|
+
return pd.Series(False, index=idx)
|
|
165
|
+
ip_mask = _ip_pair_mask(df, rule.ip1, rule.ip2)
|
|
166
|
+
mask &= ip_mask
|
|
167
|
+
|
|
168
|
+
return mask
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _ip_pair_mask(
|
|
172
|
+
df: pd.DataFrame,
|
|
173
|
+
ip1: str | None,
|
|
174
|
+
ip2: str | None,
|
|
175
|
+
) -> pd.Series:
|
|
176
|
+
"""Return True for rows whose (src, dst) pair matches the rule (unordered).
|
|
177
|
+
|
|
178
|
+
With one IP field: src OR dst matches that IP.
|
|
179
|
+
With two IP fields: (src matches ip1 AND dst matches ip2) OR vice versa.
|
|
180
|
+
"""
|
|
181
|
+
if ip2 is None:
|
|
182
|
+
# Single IP — matches any flow involving ip1
|
|
183
|
+
return _ip_series_matches(df["src"], ip1) | _ip_series_matches(df["dst"], ip1)
|
|
184
|
+
|
|
185
|
+
if ip1 is None:
|
|
186
|
+
return _ip_series_matches(df["src"], ip2) | _ip_series_matches(df["dst"], ip2)
|
|
187
|
+
|
|
188
|
+
# Ordered pair in either direction
|
|
189
|
+
fwd = _ip_series_matches(df["src"], ip1) & _ip_series_matches(df["dst"], ip2)
|
|
190
|
+
rev = _ip_series_matches(df["src"], ip2) & _ip_series_matches(df["dst"], ip1)
|
|
191
|
+
return fwd | rev
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _ip_series_matches(series: pd.Series, spec: str) -> pd.Series:
|
|
195
|
+
"""Vectorized: return True for each row where the IP matches spec.
|
|
196
|
+
|
|
197
|
+
spec may be: '*' (wildcard), a CIDR range, or an exact IP address.
|
|
198
|
+
CIDR ranges use ipaddress stdlib — no additional dependencies.
|
|
199
|
+
"""
|
|
200
|
+
if spec == "*":
|
|
201
|
+
return pd.Series(True, index=series.index)
|
|
202
|
+
|
|
203
|
+
if "/" in spec:
|
|
204
|
+
try:
|
|
205
|
+
net = ipaddress.ip_network(spec, strict=False)
|
|
206
|
+
except ValueError:
|
|
207
|
+
return pd.Series(False, index=series.index)
|
|
208
|
+
return series.map(lambda ip: _ip_in_network(ip, net))
|
|
209
|
+
|
|
210
|
+
return series == spec
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _ip_in_network(ip_str: Any, net: ipaddress.IPv4Network | ipaddress.IPv6Network) -> bool:
|
|
214
|
+
"""Return True if ip_str is a valid IP contained in net."""
|
|
215
|
+
if not isinstance(ip_str, str):
|
|
216
|
+
return False
|
|
217
|
+
try:
|
|
218
|
+
return ipaddress.ip_address(ip_str) in net
|
|
219
|
+
except ValueError:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_numeric_rule_line(line: str) -> NumericRule | None:
|
|
224
|
+
"""Parse one line of a flat numeric rule file into a NumericRule.
|
|
225
|
+
|
|
226
|
+
Returns None for blank lines, comment-only lines, or malformed rules.
|
|
227
|
+
"""
|
|
228
|
+
if "#" in line:
|
|
229
|
+
line = line[: line.index("#")]
|
|
230
|
+
line = line.strip()
|
|
231
|
+
if not line:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
tokens = line.split()
|
|
235
|
+
ip_tokens: list[str] = []
|
|
236
|
+
port: int | None = None
|
|
237
|
+
proto: str | None = None
|
|
238
|
+
|
|
239
|
+
for token in tokens:
|
|
240
|
+
if token.startswith(":"):
|
|
241
|
+
# Port/proto token: :443 :123/udp :*/tcp
|
|
242
|
+
port_part = token[1:]
|
|
243
|
+
if "/" in port_part:
|
|
244
|
+
port_str, proto_str = port_part.rsplit("/", 1)
|
|
245
|
+
if proto_str != "*":
|
|
246
|
+
proto = proto_str.lower()
|
|
247
|
+
else:
|
|
248
|
+
port_str = port_part
|
|
249
|
+
if port_str != "*":
|
|
250
|
+
try:
|
|
251
|
+
port = int(port_str)
|
|
252
|
+
except ValueError:
|
|
253
|
+
return None # malformed port
|
|
254
|
+
else:
|
|
255
|
+
ip_tokens.append(token)
|
|
256
|
+
|
|
257
|
+
if len(ip_tokens) > 2:
|
|
258
|
+
return None # too many IP fields
|
|
259
|
+
|
|
260
|
+
ip1 = ip_tokens[0] if len(ip_tokens) >= 1 else None
|
|
261
|
+
ip2 = ip_tokens[1] if len(ip_tokens) >= 2 else None
|
|
262
|
+
|
|
263
|
+
return NumericRule(ip1=ip1, ip2=ip2, port=port, proto=proto)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_pattern_file(path: Path) -> list[str]:
|
|
267
|
+
"""Load a flat domain pattern file, stripping comments and blank lines.
|
|
268
|
+
|
|
269
|
+
Inline # comments are stripped before the pattern is recorded, matching
|
|
270
|
+
the behaviour of the numeric rule parser.
|
|
271
|
+
"""
|
|
272
|
+
patterns: list[str] = []
|
|
273
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
274
|
+
if "#" in line:
|
|
275
|
+
line = line[: line.index("#")]
|
|
276
|
+
line = line.strip()
|
|
277
|
+
if line:
|
|
278
|
+
patterns.append(line)
|
|
279
|
+
return patterns
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def load_numeric_rule_file(path: Path) -> list[NumericRule]:
|
|
283
|
+
"""Load a flat numeric rule file and return parsed NumericRule objects."""
|
|
284
|
+
rules: list[NumericRule] = []
|
|
285
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
286
|
+
rule = _parse_numeric_rule_line(line)
|
|
287
|
+
if rule is not None:
|
|
288
|
+
rules.append(rule)
|
|
289
|
+
return rules
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def load_stanza_file(path: Path) -> list[AllowlistEntry]:
|
|
293
|
+
"""Load a TOML stanza file and return a list of AllowlistEntry objects."""
|
|
294
|
+
import tomllib
|
|
295
|
+
|
|
296
|
+
with path.open("rb") as fh:
|
|
297
|
+
data = tomllib.load(fh)
|
|
298
|
+
|
|
299
|
+
entries: list[AllowlistEntry] = []
|
|
300
|
+
for raw in data.get("allowlist", {}).get("entry", []):
|
|
301
|
+
extra = {k: v for k, v in raw.items() if k not in ("match", "comment", "detectors")}
|
|
302
|
+
entries.append(AllowlistEntry(
|
|
303
|
+
match=raw["match"],
|
|
304
|
+
comment=raw.get("comment", ""),
|
|
305
|
+
detectors=_as_list(raw.get("detectors", [])),
|
|
306
|
+
extra=extra,
|
|
307
|
+
))
|
|
308
|
+
return entries
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Shipped package data — located relative to this file so it works for both
|
|
312
|
+
# editable installs (loghunter/data/) and regular installs (site-packages/loghunter/data/).
|
|
313
|
+
# Package-local: NOT routed through LH_ROOT (that rail is for config-file values).
|
|
314
|
+
_PACKAGE_DIR = Path(__file__).parent.parent
|
|
315
|
+
_SHIPPED_DOMAIN_FILES: list[Path] = [
|
|
316
|
+
_PACKAGE_DIR / "data" / "allowlist" / "domains_universal.txt",
|
|
317
|
+
_PACKAGE_DIR / "data" / "allowlist" / "domains_homelab.txt",
|
|
318
|
+
_PACKAGE_DIR / "data" / "allowlist" / "domains_devices.txt",
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_matcher(config: dict[str, Any]) -> AllowlistMatcher:
|
|
323
|
+
"""Construct an AllowlistMatcher from the [allowlist] config section.
|
|
324
|
+
|
|
325
|
+
Config-supplied path values flow through ``resolve_path(value, root)`` so
|
|
326
|
+
LH_ROOT applies to relative paths. When a key is absent (raw / notebook
|
|
327
|
+
configs that skipped ``cfg.load``), the fallback is read from the single
|
|
328
|
+
source of truth in ``config.py`` via ``default_allowlist_paths()``.
|
|
329
|
+
"""
|
|
330
|
+
from loghunter.common.config import default_allowlist_paths
|
|
331
|
+
from loghunter.common.paths import effective_root, resolve_path
|
|
332
|
+
|
|
333
|
+
allowlist_cfg = config.get("allowlist", {})
|
|
334
|
+
root = effective_root(config)
|
|
335
|
+
defaults = default_allowlist_paths()
|
|
336
|
+
|
|
337
|
+
# Domain pattern files (used by DNS detector).
|
|
338
|
+
# Shipped package files loaded first as a base; user-configured paths layer on top.
|
|
339
|
+
domain_patterns: list[str] = []
|
|
340
|
+
for shipped in _SHIPPED_DOMAIN_FILES:
|
|
341
|
+
if shipped.exists():
|
|
342
|
+
domain_patterns.extend(load_pattern_file(shipped))
|
|
343
|
+
domain_pattern_paths = allowlist_cfg.get("domain_patterns")
|
|
344
|
+
if domain_pattern_paths is None:
|
|
345
|
+
domain_pattern_paths = defaults["domain_patterns"]
|
|
346
|
+
for path_str in _as_list(domain_pattern_paths):
|
|
347
|
+
resolved = resolve_path(path_str, root)
|
|
348
|
+
if resolved is None:
|
|
349
|
+
continue
|
|
350
|
+
path = Path(resolved)
|
|
351
|
+
if path.exists() and path not in _SHIPPED_DOMAIN_FILES:
|
|
352
|
+
domain_patterns.extend(load_pattern_file(path))
|
|
353
|
+
|
|
354
|
+
# TOML stanza entries (classification — kept for future detectors)
|
|
355
|
+
entries: list[AllowlistEntry] = []
|
|
356
|
+
for raw in allowlist_cfg.get("entry", []):
|
|
357
|
+
extra = {k: v for k, v in raw.items() if k not in ("match", "comment", "detectors")}
|
|
358
|
+
entries.append(AllowlistEntry(
|
|
359
|
+
match=raw["match"],
|
|
360
|
+
comment=raw.get("comment", ""),
|
|
361
|
+
detectors=_as_list(raw.get("detectors", [])),
|
|
362
|
+
extra=extra,
|
|
363
|
+
))
|
|
364
|
+
|
|
365
|
+
allowlist_dir = allowlist_cfg.get("allowlist_dir")
|
|
366
|
+
if allowlist_dir is None:
|
|
367
|
+
allowlist_dir = defaults["allowlist_dir"]
|
|
368
|
+
resolved_dir = resolve_path(allowlist_dir, root)
|
|
369
|
+
if resolved_dir:
|
|
370
|
+
dir_path = Path(resolved_dir)
|
|
371
|
+
if dir_path.is_dir():
|
|
372
|
+
for toml_file in sorted(dir_path.glob("*.toml")):
|
|
373
|
+
entries.extend(load_stanza_file(toml_file))
|
|
374
|
+
|
|
375
|
+
# Flat numeric connection rule files (suppression — used by filter_df).
|
|
376
|
+
# These are strictly local/site-specific. Unlike domains, connection suppressions
|
|
377
|
+
# encode local topology and behavior, so LogHunter never loads bundled defaults.
|
|
378
|
+
numeric_rules: list[NumericRule] = []
|
|
379
|
+
connection_rule_paths = allowlist_cfg.get("connection_rules")
|
|
380
|
+
if connection_rule_paths is None:
|
|
381
|
+
connection_rule_paths = defaults["connection_rules"]
|
|
382
|
+
for path_str in _as_list(connection_rule_paths):
|
|
383
|
+
resolved = resolve_path(path_str, root)
|
|
384
|
+
if resolved is None:
|
|
385
|
+
continue
|
|
386
|
+
path = Path(resolved)
|
|
387
|
+
if path.exists():
|
|
388
|
+
numeric_rules.extend(load_numeric_rule_file(path))
|
|
389
|
+
|
|
390
|
+
# Convert existing ip_pair and dst_port stanza entries to NumericRules
|
|
391
|
+
# so that filter_df() applies them without requiring format migration.
|
|
392
|
+
for entry in entries:
|
|
393
|
+
rule = _stanza_to_numeric_rule(entry)
|
|
394
|
+
if rule is not None:
|
|
395
|
+
numeric_rules.append(rule)
|
|
396
|
+
|
|
397
|
+
return AllowlistMatcher(
|
|
398
|
+
domain_patterns=domain_patterns,
|
|
399
|
+
entries=entries,
|
|
400
|
+
numeric_rules=numeric_rules,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _as_list(value: Any) -> list[str]:
|
|
405
|
+
"""Return a forgiving string list from TOML arrays or comma-separated strings."""
|
|
406
|
+
if value is None:
|
|
407
|
+
return []
|
|
408
|
+
if isinstance(value, str):
|
|
409
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
410
|
+
if isinstance(value, list):
|
|
411
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
412
|
+
return [str(value).strip()]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _stanza_to_numeric_rule(entry: AllowlistEntry) -> NumericRule | None:
|
|
416
|
+
"""Convert a TOML stanza entry to a NumericRule for use in filter_df().
|
|
417
|
+
|
|
418
|
+
Only ip_pair and dst_port match types are supported; others return None.
|
|
419
|
+
"""
|
|
420
|
+
if entry.match == "ip_pair":
|
|
421
|
+
src = entry.extra.get("src")
|
|
422
|
+
dst = entry.extra.get("dst")
|
|
423
|
+
dst_port = entry.extra.get("dst_port")
|
|
424
|
+
port = int(dst_port) if dst_port is not None else None
|
|
425
|
+
return NumericRule(
|
|
426
|
+
ip1=src,
|
|
427
|
+
ip2=dst,
|
|
428
|
+
port=port,
|
|
429
|
+
detectors=list(entry.detectors),
|
|
430
|
+
comment=entry.comment,
|
|
431
|
+
)
|
|
432
|
+
if entry.match == "dst_port":
|
|
433
|
+
value = entry.extra.get("value")
|
|
434
|
+
port = int(value) if value is not None else None
|
|
435
|
+
return NumericRule(port=port, detectors=list(entry.detectors), comment=entry.comment)
|
|
436
|
+
return None
|