feat(prune): add functionality to remove old single-host IP blocks while keeping CIDR ranges intact
This commit is contained in:
1 parent
714aaf901e
commit
5ece4cd113
3 files changed
+114
-6
No files matched your search
+71
-3
@@ -11,7 +11,8 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import ipaddress
|
||||||
from typing import Any, Iterator, Sequence
|
from typing import Any, Iterator, Sequence
|
||||||
|
|
||||||
from ..utils.models import IPStats
|
from ..utils.models import IPStats
|
||||||
@@ -104,7 +105,8 @@ def _parse_ts(ts: str) -> tuple[str, float | None]:
|
|||||||
cleaned = ts.strip()
|
cleaned = ts.strip()
|
||||||
# Accept a trailing timezone like +HH:MM or Z; fall back to naive parse.
|
# Accept a trailing timezone like +HH:MM or Z; fall back to naive parse.
|
||||||
try:
|
try:
|
||||||
epoch = datetime.fromisoformat(cleaned.replace("Z", "+00:00")).timestamp()
|
epoch = datetime.fromisoformat(
|
||||||
|
cleaned.replace("Z", "+00:00")).timestamp()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
try:
|
try:
|
||||||
epoch = datetime.fromisoformat(cleaned).timestamp()
|
epoch = datetime.fromisoformat(cleaned).timestamp()
|
||||||
@@ -339,7 +341,7 @@ def record_blocklist(conn: sqlite3.Connection, cidrs: Sequence[str], *,
|
|||||||
with transaction(conn):
|
with transaction(conn):
|
||||||
for cidr in to_add:
|
for cidr in to_add:
|
||||||
_block_entry_inner(conn, cidr, added_by=added_by, last_seen=now,
|
_block_entry_inner(conn, cidr, added_by=added_by, last_seen=now,
|
||||||
reasons=reasons)
|
reasons=reasons)
|
||||||
# Refresh last_seen on entries that are still desired so the ledger
|
# Refresh last_seen on entries that are still desired so the ledger
|
||||||
# reflects recency for existing rows too.
|
# reflects recency for existing rows too.
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
@@ -348,6 +350,52 @@ def record_blocklist(conn: sqlite3.Connection, cidrs: Sequence[str], *,
|
|||||||
return {"added": to_add, "active": len(before) + len(to_add)}
|
return {"added": to_add, "active": len(before) + len(to_add)}
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_single_ip_blocks(conn: sqlite3.Connection, *,
|
||||||
|
older_than_days: int = 30,
|
||||||
|
actor: str | None = "retention") -> list[str]:
|
||||||
|
"""Remove active single-host entries older than ``older_than_days``.
|
||||||
|
|
||||||
|
Keeps CIDR ranges/subnets intact. Host entries are detected via
|
||||||
|
``ipaddress.ip_network(..., strict=False)`` and only removed when the
|
||||||
|
network is exactly one address (/32 for IPv4, /128 for IPv6).
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - \
|
||||||
|
timedelta(days=max(0, older_than_days))
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT cidr, added_at, last_seen FROM blocklist_entries "
|
||||||
|
"WHERE status='active'").fetchall()
|
||||||
|
|
||||||
|
to_remove: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
cidr = str(row["cidr"])
|
||||||
|
if not _is_single_host_cidr(cidr):
|
||||||
|
continue
|
||||||
|
seen = _parse_iso_ts(
|
||||||
|
row["last_seen"]) or _parse_iso_ts(row["added_at"])
|
||||||
|
if seen is None:
|
||||||
|
continue
|
||||||
|
if seen < cutoff:
|
||||||
|
to_remove.append(cidr)
|
||||||
|
|
||||||
|
if not to_remove:
|
||||||
|
return []
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
with transaction(conn):
|
||||||
|
conn.executemany(
|
||||||
|
"UPDATE blocklist_entries SET status='removed', removed_at=? "
|
||||||
|
"WHERE cidr=? AND status='active'",
|
||||||
|
[(now, cidr) for cidr in to_remove],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO blocklist_events (action, cidr, actor, fired_at, detail) "
|
||||||
|
"VALUES ('remove', ?, ?, ?, ?)",
|
||||||
|
[(cidr, actor, now, f"single-ip-retention>{older_than_days}d")
|
||||||
|
for cidr in to_remove],
|
||||||
|
)
|
||||||
|
return sorted(to_remove)
|
||||||
|
|
||||||
|
|
||||||
def sync_blocklist(conn: sqlite3.Connection, desired_cidrs: Sequence[str], *,
|
def sync_blocklist(conn: sqlite3.Connection, desired_cidrs: Sequence[str], *,
|
||||||
added_by: str | None = None) -> dict[str, Any]:
|
added_by: str | None = None) -> dict[str, Any]:
|
||||||
"""Reconcile active blocklist to ``desired_cidrs``.
|
"""Reconcile active blocklist to ``desired_cidrs``.
|
||||||
@@ -402,6 +450,26 @@ def _cidr_version(cidr: str) -> int:
|
|||||||
return 4 if ":" not in str(cidr) else 6
|
return 4 if ":" not in str(cidr) else 6
|
||||||
|
|
||||||
|
|
||||||
|
def _is_single_host_cidr(cidr: str) -> bool:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
return net.num_addresses == 1
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_ts(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Whitelists
|
# Whitelists
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ from ..utils.whois import consolidate_by_subnet
|
|||||||
|
|
||||||
|
|
||||||
def _sync_blocklist_to_db(connected: bool, desired_v4: list[str],
|
def _sync_blocklist_to_db(connected: bool, desired_v4: list[str],
|
||||||
desired_v6: list[str], added_by: str | None = None) -> None:
|
desired_v6: list[str],
|
||||||
|
added_by: str | None = None) -> dict | None:
|
||||||
"""Best-effort grow-only sync of block targets into the SQLite ledger.
|
"""Best-effort grow-only sync of block targets into the SQLite ledger.
|
||||||
|
|
||||||
Uses ``record_blocklist`` (add-only) so the ledger is the authoritative
|
Uses ``record_blocklist`` (add-only) so the ledger is the authoritative
|
||||||
@@ -53,8 +54,13 @@ def _sync_blocklist_to_db(connected: bool, desired_v4: list[str],
|
|||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
from ..db import repositories as repo
|
from ..db import repositories as repo
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
return repo.record_blocklist(
|
result = repo.record_blocklist(
|
||||||
conn, list(desired_v4) + list(desired_v6), added_by=added_by)
|
conn, list(desired_v4) + list(desired_v6), added_by=added_by)
|
||||||
|
# Retention policy: expire stale single-host entries after 30 days
|
||||||
|
# while keeping CIDR ranges/subnets intact.
|
||||||
|
pruned = repo.prune_old_single_ip_blocks(conn, older_than_days=30)
|
||||||
|
result["pruned_single_ips"] = pruned
|
||||||
|
return result
|
||||||
except Exception as exc: # never break the scan for a DB issue
|
except Exception as exc: # never break the scan for a DB issue
|
||||||
print(f"WARN: could not sync blocklist to db ({exc})")
|
print(f"WARN: could not sync blocklist to db ({exc})")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import os
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.db import connect
|
from app.db import connect
|
||||||
from app.db import repositories as repo
|
from app.db import repositories as repo
|
||||||
@@ -110,7 +111,8 @@ class BlocklistTest(unittest.TestCase):
|
|||||||
added_by="scan:1")
|
added_by="scan:1")
|
||||||
self.assertEqual(r1["active"], 2)
|
self.assertEqual(r1["active"], 2)
|
||||||
# a later scan only sees 5.5.5.5; 6.6.6.6 stays (cumulative).
|
# a later scan only sees 5.5.5.5; 6.6.6.6 stays (cumulative).
|
||||||
r2 = repo.record_blocklist(self.conn, ["5.5.5.5/32"], added_by="scan:2")
|
r2 = repo.record_blocklist(
|
||||||
|
self.conn, ["5.5.5.5/32"], added_by="scan:2")
|
||||||
active = [e["cidr"] for e in repo.active_blocklist(self.conn)]
|
active = [e["cidr"] for e in repo.active_blocklist(self.conn)]
|
||||||
self.assertIn("6.6.6.6/32", active)
|
self.assertIn("6.6.6.6/32", active)
|
||||||
self.assertEqual(r2["active"], 2)
|
self.assertEqual(r2["active"], 2)
|
||||||
@@ -122,6 +124,38 @@ class BlocklistTest(unittest.TestCase):
|
|||||||
[e["cidr"] for e in repo.active_blocklist(self.conn)])
|
[e["cidr"] for e in repo.active_blocklist(self.conn)])
|
||||||
self.assertFalse(repo.unblock_entry(self.conn, "7.7.7.7/32"))
|
self.assertFalse(repo.unblock_entry(self.conn, "7.7.7.7/32"))
|
||||||
|
|
||||||
|
def test_prune_old_single_ip_blocks_keeps_ranges(self):
|
||||||
|
old = (datetime.now(timezone.utc) - timedelta(days=31)).isoformat(
|
||||||
|
timespec="seconds")
|
||||||
|
with repo.transaction(self.conn):
|
||||||
|
repo._block_entry_inner(self.conn, "1.2.3.4/32", last_seen=old)
|
||||||
|
repo._block_entry_inner(
|
||||||
|
self.conn, "2001:db8::1/128", last_seen=old)
|
||||||
|
repo._block_entry_inner(self.conn, "1.2.3.0/24", last_seen=old)
|
||||||
|
repo._block_entry_inner(self.conn, "2001:db8::/64", last_seen=old)
|
||||||
|
|
||||||
|
removed = repo.prune_old_single_ip_blocks(
|
||||||
|
self.conn, older_than_days=30)
|
||||||
|
self.assertCountEqual(removed, ["1.2.3.4/32", "2001:db8::1/128"])
|
||||||
|
|
||||||
|
active = {e["cidr"] for e in repo.active_blocklist(self.conn)}
|
||||||
|
self.assertIn("1.2.3.0/24", active)
|
||||||
|
self.assertIn("2001:db8::/64", active)
|
||||||
|
self.assertNotIn("1.2.3.4/32", active)
|
||||||
|
self.assertNotIn("2001:db8::1/128", active)
|
||||||
|
|
||||||
|
def test_prune_old_single_ip_blocks_respects_recency(self):
|
||||||
|
fresh = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat(
|
||||||
|
timespec="seconds")
|
||||||
|
with repo.transaction(self.conn):
|
||||||
|
repo._block_entry_inner(self.conn, "9.9.9.9/32", last_seen=fresh)
|
||||||
|
|
||||||
|
removed = repo.prune_old_single_ip_blocks(
|
||||||
|
self.conn, older_than_days=30)
|
||||||
|
self.assertEqual(removed, [])
|
||||||
|
self.assertIn("9.9.9.9/32",
|
||||||
|
{e["cidr"] for e in repo.active_blocklist(self.conn)})
|
||||||
|
|
||||||
|
|
||||||
class WhitelistTest(unittest.TestCase):
|
class WhitelistTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
Reference in new issue
Block a user