mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-11 02:12:04 +03:00
reputation: firehol netset cache with local range index
This commit is contained in:
parent
d45fcb5a59
commit
48f7865987
3 changed files with 183 additions and 0 deletions
93
src/netcheck/reputation.py
Normal file
93
src/netcheck/reputation.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from bisect import bisect_right
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from netcheck.config import Providers
|
||||||
|
|
||||||
|
|
||||||
|
def parse_netset(text: str) -> list[str]:
|
||||||
|
entries: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
entries.append(stripped)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
class NetsetIndex:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Sorted (start, end, name) ranges per address family; a lookup is one
|
||||||
|
# bisect plus a short backward scan, which beats a per-CIDR loop over
|
||||||
|
# the ~1M prefixes a full FireHOL set contains.
|
||||||
|
self._ranges: dict[int, list[tuple[int, int, str]]] = {4: [], 6: []}
|
||||||
|
self._starts: dict[int, list[int]] = {4: [], 6: []}
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def add(self, name: str, cidrs: list[str]) -> None:
|
||||||
|
for entry in cidrs:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(entry, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
self._ranges[network.version].append(
|
||||||
|
(int(network.network_address), int(network.broadcast_address), name)
|
||||||
|
)
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
def _reindex(self) -> None:
|
||||||
|
for version in (4, 6):
|
||||||
|
self._ranges[version].sort()
|
||||||
|
self._starts[version] = [start for start, _, _ in self._ranges[version]]
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def hits(self, ip: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if self._dirty:
|
||||||
|
self._reindex()
|
||||||
|
version = address.version
|
||||||
|
value = int(address)
|
||||||
|
found: list[str] = []
|
||||||
|
position = bisect_right(self._starts[version], value)
|
||||||
|
for start, end, name in reversed(self._ranges[version][:position]):
|
||||||
|
if end >= value and name not in found:
|
||||||
|
found.append(name)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_netsets(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
providers: Providers,
|
||||||
|
cache_dir: Path,
|
||||||
|
) -> NetsetIndex:
|
||||||
|
directory = Path(cache_dir) / "firehol"
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
index = NetsetIndex()
|
||||||
|
for url in providers.firehol_netsets:
|
||||||
|
name = url.rsplit("/", 1)[-1].removesuffix(".netset")
|
||||||
|
path = directory / f"{name}.netset"
|
||||||
|
fresh = (
|
||||||
|
path.exists()
|
||||||
|
and (time.time() - os.path.getmtime(path)) < providers.firehol_refresh_hours * 3600
|
||||||
|
)
|
||||||
|
if not fresh:
|
||||||
|
try:
|
||||||
|
response = await client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
tmp = path.with_suffix(".netset.tmp")
|
||||||
|
tmp.write_text(response.text, encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
index.add(name, parse_netset(path.read_text(encoding="utf-8")))
|
||||||
|
return index
|
||||||
13
tests/fixtures/api/firehol_sample.netset
vendored
Normal file
13
tests/fixtures/api/firehol_sample.netset
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#
|
||||||
|
# firehol_level1
|
||||||
|
#
|
||||||
|
# Source: example
|
||||||
|
# Maintainer: FireHOL Team
|
||||||
|
#
|
||||||
|
0.0.0.0/8
|
||||||
|
10.0.0.0/8
|
||||||
|
198.51.100.0/24
|
||||||
|
203.0.113.44
|
||||||
|
2001:db8::/32
|
||||||
|
|
||||||
|
192.0.2.0/25
|
||||||
77
tests/test_reputation.py
Normal file
77
tests/test_reputation.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from netcheck.reputation import NetsetIndex, parse_netset
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_netset_strips_comments_and_blank_lines(fixtures_dir):
|
||||||
|
cidrs = parse_netset((fixtures_dir / "api" / "firehol_sample.netset").read_text(encoding="utf-8"))
|
||||||
|
assert cidrs == [
|
||||||
|
"0.0.0.0/8",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"198.51.100.0/24",
|
||||||
|
"203.0.113.44",
|
||||||
|
"2001:db8::/32",
|
||||||
|
"192.0.2.0/25",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_netset_of_an_empty_file_is_empty():
|
||||||
|
assert parse_netset("# only a comment\n\n") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_matches_an_ip_inside_a_listed_prefix():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("firehol_level1", ["198.51.100.0/24"])
|
||||||
|
assert index.hits("198.51.100.7") == ["firehol_level1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_does_not_match_an_ip_outside_every_prefix():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("firehol_level1", ["198.51.100.0/24"])
|
||||||
|
assert index.hits("203.0.113.7") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_matches_a_bare_host_entry():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("abusers", ["203.0.113.44"])
|
||||||
|
assert index.hits("203.0.113.44") == ["abusers"]
|
||||||
|
assert index.hits("203.0.113.45") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_reports_every_list_an_ip_appears_on():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("level1", ["198.51.100.0/24"])
|
||||||
|
index.add("level2", ["198.51.100.0/25"])
|
||||||
|
index.add("clean", ["203.0.113.0/24"])
|
||||||
|
assert sorted(index.hits("198.51.100.7")) == ["level1", "level2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_handles_ipv6_separately_from_ipv4():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("v6list", ["2001:db8::/32"])
|
||||||
|
index.add("v4list", ["203.0.113.0/24"])
|
||||||
|
assert index.hits("2001:db8::1") == ["v6list"]
|
||||||
|
assert index.hits("203.0.113.1") == ["v4list"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_ignores_malformed_entries_instead_of_raising():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("junk", ["not-an-ip", "999.1.1.1/24", "198.51.100.0/24"])
|
||||||
|
assert index.hits("198.51.100.7") == ["junk"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_lookup_of_a_malformed_ip_is_empty_not_an_error():
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add("level1", ["198.51.100.0/24"])
|
||||||
|
assert index.hits("not-an-ip") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_built_from_the_real_fixture_matches_the_expected_entries(fixtures_dir):
|
||||||
|
index = NetsetIndex()
|
||||||
|
index.add(
|
||||||
|
"firehol_level1",
|
||||||
|
parse_netset((fixtures_dir / "api" / "firehol_sample.netset").read_text(encoding="utf-8")),
|
||||||
|
)
|
||||||
|
assert index.hits("10.1.2.3") == ["firehol_level1"]
|
||||||
|
assert index.hits("192.0.2.10") == ["firehol_level1"]
|
||||||
|
assert index.hits("192.0.2.200") == []
|
||||||
Loading…
Add table
Reference in a new issue