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,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