feat: Implement SQLite schema and migrations for intrusion detection system
- Added schema definition and migration logic in app/db/schema.py. - Created tables for log sources, requests, IP stats, scans, blocklist, whitelist, and firewall rules. - Introduced functions to apply migrations and ensure database integrity. feat: Enhance scan functionality with database integration - Added _sync_blocklist_to_db function to synchronize blocklist with SQLite. - Updated run_scan function to handle database connections and scan records. - Implemented error handling for database operations during scans. feat: Introduce log analysis page and API endpoints - Created analysis.html template for displaying log analysis results. - Added routes for fetching top IPs, IP request timelines, and cross-source IPs. - Integrated analysis data into the dashboard and status endpoints. fix: Ensure whitelists are synchronized with the database - Implemented sync_whitelists_to_db function to persist in-memory whitelists. - Updated constants.py to define the path for the SQLite database. refactor: Modularize analysis helpers for better maintainability - Created _analysis_helpers function to encapsulate SQLite query logic. - Updated routes.py to utilize new analysis helper functions for API responses.
This commit is contained in:
1 parent
0137264fe4
commit
6036fa8b1e
17 files changed
+1292
-5
No files matched your search
@@ -60,6 +60,12 @@ SUBNET_THRESHOLD=3
|
|||||||
REPORT_RETENTION_DAYS=30
|
REPORT_RETENTION_DAYS=30
|
||||||
REPORT_RETENTION_MAX_FILES=10
|
REPORT_RETENTION_MAX_FILES=10
|
||||||
|
|
||||||
|
# --- SQLite store ---------------------------------------------------------
|
||||||
|
|
||||||
|
# Local SQLite database canonical store (logs, scans, blocklist/whitelist and
|
||||||
|
# firewall history). Created automatically on first run. Default: data/ids.db
|
||||||
|
#IDS_DB_PATH=/absolute/path/to/ids.db
|
||||||
|
|
||||||
# --- Scan / flagging defaults ----------------------------------------------
|
# --- Scan / flagging defaults ----------------------------------------------
|
||||||
|
|
||||||
# Rows in the top-IP report tables.
|
# Rows in the top-IP report tables.
|
||||||
|
|||||||
@@ -20,5 +20,10 @@ app/data/*
|
|||||||
!app/data/cloudflare/
|
!app/data/cloudflare/
|
||||||
!app/data/paloalto/
|
!app/data/paloalto/
|
||||||
|
|
||||||
|
# SQLite database (canonical store) + WAL/SHM sidecars
|
||||||
|
app/data/*.db
|
||||||
|
app/data/*.db-wal
|
||||||
|
app/data/*.db-shm
|
||||||
|
|
||||||
# generated reports
|
# generated reports
|
||||||
app/reports/
|
app/reports/
|
||||||
@@ -49,6 +49,10 @@ intrusion-detection/
|
|||||||
│ ├── scan/ scan pipeline
|
│ ├── scan/ scan pipeline
|
||||||
│ │ ├── __init__.py consolidated scanner (run_scan, ScanParams, ScanResult)
|
│ │ ├── __init__.py consolidated scanner (run_scan, ScanParams, ScanResult)
|
||||||
│ │ └── scan_state.py incremental log scan state (load/save)
|
│ │ └── scan_state.py incremental log scan state (load/save)
|
||||||
|
│ ├── db/ SQLite store (see .github/instructions/DATABASE.md)
|
||||||
|
│ │ ├── __init__.py connection factory + migrations runner
|
||||||
|
│ │ ├── schema.py DDL for all tables (PRAGMA user_version migrations)
|
||||||
|
│ │ └── repositories.py queries: logs, scans, blocklist, whitelist, firewall
|
||||||
│ ├── utils/ shared utilities
|
│ ├── utils/ shared utilities
|
||||||
│ │ ├── constants.py all config constants (paths, env vars, thresholds)
|
│ │ ├── constants.py all config constants (paths, env vars, thresholds)
|
||||||
│ │ ├── files.py read_lines, find_logs, write_csv/json/lines
|
│ │ ├── files.py read_lines, find_logs, write_csv/json/lines
|
||||||
@@ -67,8 +71,9 @@ intrusion-detection/
|
|||||||
│ ├── cloudflare/
|
│ ├── cloudflare/
|
||||||
│ │ ├── ips-v4.txt
|
│ │ ├── ips-v4.txt
|
||||||
│ │ └── ips-v6.txt
|
│ │ └── ips-v6.txt
|
||||||
│ └── paloalto/
|
│ ├── paloalto/
|
||||||
│ └── scanner-ips.txt
|
│ │ └── scanner-ips.txt
|
||||||
|
│ └── ids.db local SQLite store (log/scan/blocklist/whitelist history; auto-created)
|
||||||
├── reports/ generated reports (per-run + per-source)
|
├── reports/ generated reports (per-run + per-source)
|
||||||
│ ├── copyparty/
|
│ ├── copyparty/
|
||||||
│ ├── npm/
|
│ ├── npm/
|
||||||
@@ -253,6 +258,8 @@ Pages:
|
|||||||
| `/blocklist` | view the consolidated blocklist, add/remove entries |
|
| `/blocklist` | view the consolidated blocklist, add/remove entries |
|
||||||
| `/firewall` | view address lists + rules, preview/apply a sync |
|
| `/firewall` | view address lists + rules, preview/apply a sync |
|
||||||
| `/whitelist` | view/manage provider whitelist CIDRs |
|
| `/whitelist` | view/manage provider whitelist CIDRs |
|
||||||
|
| `/graph` | visualize the 0.0.0.0/8 address space (blocked/whitelisted pixels) |
|
||||||
|
| `/analysis` | detailed persisted-log analysis (top IPs, drill-down, cross-source) |
|
||||||
|
|
||||||
Scans run in a background thread (only one at a time) and report progress via
|
Scans run in a background thread (only one at a time) and report progress via
|
||||||
the JSON API, so the UI never blocks. Firewall sync is **dry-run by default**
|
the JSON API, so the UI never blocks. Firewall sync is **dry-run by default**
|
||||||
|
|||||||
+62
-1
@@ -60,11 +60,66 @@ class Analyzer:
|
|||||||
self.flagged: dict[str, IPStats] = {}
|
self.flagged: dict[str, IPStats] = {}
|
||||||
self.per_file: dict[str, int] = {}
|
self.per_file: dict[str, int] = {}
|
||||||
|
|
||||||
|
# --- sqlite persistence ------------------------------------------------
|
||||||
|
|
||||||
|
def persist_to_db(self, scan_id: int | None = None) -> None:
|
||||||
|
"""Ingest this analyzer's requests + per-IP stats into SQLite.
|
||||||
|
|
||||||
|
Lazily imported to avoid cycles. When ``scan_id`` is None a standalone
|
||||||
|
scan record is created for this analyzer so individual
|
||||||
|
(non-consolidated) runs are still tracked. Never raises: a DB failure
|
||||||
|
logs a warning but does not break the scan.
|
||||||
|
"""
|
||||||
|
if not self.requests and not self.stats:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from .db import get_connection
|
||||||
|
from .db import repositories as repo
|
||||||
|
from .utils.ip_utils import ip_version as _ip_ver
|
||||||
|
conn = get_connection()
|
||||||
|
if scan_id is None:
|
||||||
|
scan_id = repo.start_scan(conn, self.name,
|
||||||
|
params={"full": self.full})
|
||||||
|
source = self.name
|
||||||
|
rows: list[dict] = []
|
||||||
|
for r in self.requests:
|
||||||
|
rows.append({
|
||||||
|
"source": source, "scan_id": scan_id,
|
||||||
|
"log_file": ((r.extra or {}).get("log_file")
|
||||||
|
or (self.files[0] if self.files else None)),
|
||||||
|
"ts": r.ts, "ip": r.ip,
|
||||||
|
"version": _ip_ver(r.ip) or 0,
|
||||||
|
"method": r.method, "path": r.path,
|
||||||
|
"status": r.status,
|
||||||
|
"upstream_status": r.upstream_status,
|
||||||
|
"host": r.host, "user": r.user, "ua": r.ua,
|
||||||
|
"referer": r.referer, "denied": r.denied,
|
||||||
|
"banned": r.banned, "is_error": r.is_error,
|
||||||
|
"extra": r.extra,
|
||||||
|
})
|
||||||
|
repo.insert_requests(conn, rows)
|
||||||
|
repo.insert_ip_stats(conn, scan_id,
|
||||||
|
list(self.stats.values()),
|
||||||
|
set(self.block_candidates()))
|
||||||
|
repo.set_scan_source_counts(
|
||||||
|
conn, scan_id, source, len(self.requests), len(self.files))
|
||||||
|
repo.finish_scan(conn, scan_id, status="done",
|
||||||
|
log_files=len(self.files),
|
||||||
|
request_count=len(self.requests))
|
||||||
|
except Exception as exc: # never break the scan because of DB issues
|
||||||
|
if not self.quiet:
|
||||||
|
print(f"WARN: sqlite ingest skipped ({exc})")
|
||||||
# --- hooks -------------------------------------------------------------
|
# --- hooks -------------------------------------------------------------
|
||||||
|
|
||||||
def parse_line(self, line: str) -> Request | None:
|
def parse_line(self, line: str) -> Request | None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def attr_source(self, req: Request, log_file: str) -> None:
|
||||||
|
"""Record the originating log file onto a parsed request."""
|
||||||
|
if req.extra is None:
|
||||||
|
req.extra = {}
|
||||||
|
req.extra["log_file"] = req.extra.get("log_file", log_file)
|
||||||
|
|
||||||
def flag(self, stats: dict[str, IPStats]) -> dict[str, IPStats]:
|
def flag(self, stats: dict[str, IPStats]) -> dict[str, IPStats]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -111,6 +166,7 @@ class Analyzer:
|
|||||||
for line in read_lines(full_path):
|
for line in read_lines(full_path):
|
||||||
req = self.parse_line(line)
|
req = self.parse_line(line)
|
||||||
if req is not None:
|
if req is not None:
|
||||||
|
self.attr_source(req, name)
|
||||||
self.requests.append(req)
|
self.requests.append(req)
|
||||||
count += 1
|
count += 1
|
||||||
else:
|
else:
|
||||||
@@ -125,6 +181,7 @@ class Analyzer:
|
|||||||
for line, resume in read_lines_from(full_path, start):
|
for line, resume in read_lines_from(full_path, start):
|
||||||
req = self.parse_line(line)
|
req = self.parse_line(line)
|
||||||
if req is not None:
|
if req is not None:
|
||||||
|
self.attr_source(req, name)
|
||||||
self.requests.append(req)
|
self.requests.append(req)
|
||||||
count += 1
|
count += 1
|
||||||
last_resume = resume
|
last_resume = resume
|
||||||
@@ -262,7 +319,7 @@ class Analyzer:
|
|||||||
|
|
||||||
# --- entrypoint --------------------------------------------------------
|
# --- entrypoint --------------------------------------------------------
|
||||||
|
|
||||||
def run(self) -> dict[str, Any]:
|
def run(self, db_scan_id: int | None = None) -> dict[str, Any]:
|
||||||
validate_dir(self.log_path)
|
validate_dir(self.log_path)
|
||||||
ensure_dirs(self.report_dir, self.data_dir)
|
ensure_dirs(self.report_dir, self.data_dir)
|
||||||
refresh_whitelists()
|
refresh_whitelists()
|
||||||
@@ -273,6 +330,10 @@ class Analyzer:
|
|||||||
if not self.quiet:
|
if not self.quiet:
|
||||||
print(self.text_report())
|
print(self.text_report())
|
||||||
|
|
||||||
|
# Ingest into the SQLite store (creates its own scan record when no
|
||||||
|
# consolidated ``db_scan_id`` is supplied).
|
||||||
|
self.persist_to_db(scan_id=db_scan_id)
|
||||||
|
|
||||||
self.write_data()
|
self.write_data()
|
||||||
report_path = self.write_report()
|
report_path = self.write_report()
|
||||||
print(f"data files: {self.data_dir}")
|
print(f"data files: {self.data_dir}")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
173.245.48.0/20
|
||||||
|
103.21.244.0/22
|
||||||
|
103.22.200.0/22
|
||||||
|
103.31.4.0/22
|
||||||
|
141.101.64.0/18
|
||||||
|
108.162.192.0/18
|
||||||
|
190.93.240.0/20
|
||||||
|
188.114.96.0/20
|
||||||
|
197.234.240.0/22
|
||||||
|
198.41.128.0/17
|
||||||
|
162.158.0.0/15
|
||||||
|
104.16.0.0/13
|
||||||
|
104.24.0.0/14
|
||||||
|
172.64.0.0/13
|
||||||
|
131.0.72.0/22
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
2400:cb00::/32
|
||||||
|
2606:4700::/32
|
||||||
|
2803:f800::/32
|
||||||
|
2405:b500::/32
|
||||||
|
2405:8100::/32
|
||||||
|
2a06:98c0::/29
|
||||||
|
2c0f:f248::/32
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local SQLite database layer for the intrusion-detection system.
|
||||||
|
|
||||||
|
Owns the shared connection factory (WAL mode, busy timeout, single mutable
|
||||||
|
connection reused across the background-scan thread and web readers) and
|
||||||
|
applies schema migrations on open. Query helpers live in :mod:`repositories`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from ..utils.constants import DATA_DB_PATH
|
||||||
|
from .schema import apply_migrations
|
||||||
|
|
||||||
|
_connection: sqlite3.Connection | None = None
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def connect(path: str | None = None) -> sqlite3.Connection:
|
||||||
|
"""Open an initialized SQLite connection at ``path``.
|
||||||
|
|
||||||
|
Enables WAL for concurrent readers during the single-writer background
|
||||||
|
scan and sets a busy timeout so short write contention does not raise.
|
||||||
|
Foreign keys are on and migrations are applied.
|
||||||
|
"""
|
||||||
|
db_path = path or DATA_DB_PATH
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
|
||||||
|
conn = sqlite3.connect(
|
||||||
|
db_path,
|
||||||
|
check_same_thread=False, # background scan thread + web handlers
|
||||||
|
isolation_level=None, # autocommit; use explicit BEGIN for batches
|
||||||
|
)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
apply_migrations(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
"""Return the shared, lazily-created connection (thread-safe)."""
|
||||||
|
global _connection
|
||||||
|
with _lock:
|
||||||
|
if _connection is None:
|
||||||
|
_connection = connect()
|
||||||
|
return _connection
|
||||||
|
|
||||||
|
|
||||||
|
def close() -> None:
|
||||||
|
"""Close the shared connection, if any."""
|
||||||
|
global _connection
|
||||||
|
with _lock:
|
||||||
|
if _connection is not None:
|
||||||
|
try:
|
||||||
|
_connection.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
_connection = None
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Query helpers for the intrusion-detection SQLite store.
|
||||||
|
|
||||||
|
Functions are grouped by domain and take an explicit ``sqlite3.Connection`` so
|
||||||
|
callers control transaction boundaries. Bulk inserts are batched with
|
||||||
|
``executemany`` inside a single transaction for speed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Iterator, Sequence
|
||||||
|
|
||||||
|
from ..utils.models import IPStats
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> str:
|
||||||
|
"""ISO-8601 timestamp with seconds precision (UTC)."""
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Context manager for an explicit transaction (commit or rollback)."""
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scans
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start_scan(conn: sqlite3.Connection, kind: str, *,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
full: bool = False, dry_run: bool = False) -> int:
|
||||||
|
"""Record a scan start and return its id."""
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO scans (kind, started_at, status, full, dry_run, params_json) "
|
||||||
|
"VALUES (?, ?, 'running', ?, ?, ?)",
|
||||||
|
(kind, utcnow(), int(full), int(dry_run),
|
||||||
|
json.dumps(params) if params is not None else None))
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def finish_scan(conn: sqlite3.Connection, scan_id: int, *,
|
||||||
|
status: str, result: dict[str, Any] | None = None,
|
||||||
|
log_files: int = 0, request_count: int = 0,
|
||||||
|
error: str | None = None) -> None:
|
||||||
|
"""Mark a scan complete with outcome counters."""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE scans SET finished_at=?, status=?, result_json=?, "
|
||||||
|
"log_files=?, request_count=?, error=? WHERE id=?",
|
||||||
|
(utcnow(), status,
|
||||||
|
json.dumps(result) if result is not None else None,
|
||||||
|
log_files, request_count, error, scan_id))
|
||||||
|
|
||||||
|
|
||||||
|
def set_scan_source_counts(conn: sqlite3.Connection, scan_id: int,
|
||||||
|
source: str, requests: int, files: int) -> None:
|
||||||
|
"""Record per-source request/file counts for a scan."""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO scans_sources (scan_id, source, requests, files) "
|
||||||
|
"VALUES (?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(scan_id, source) DO UPDATE SET "
|
||||||
|
"requests=excluded.requests, files=excluded.files",
|
||||||
|
(scan_id, source, requests, files))
|
||||||
|
|
||||||
|
|
||||||
|
def latest_scans(conn: sqlite3.Connection, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
|
"""Most recent scan metadata rows."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, kind, started_at, finished_at, status, full, dry_run, "
|
||||||
|
"log_files, request_count, error FROM scans "
|
||||||
|
"ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_scan(conn: sqlite3.Connection, scan_id: int) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, kind, started_at, finished_at, status, full, dry_run, "
|
||||||
|
"params_json, result_json, log_files, request_count, error "
|
||||||
|
"FROM scans WHERE id=?", (scan_id,)).fetchone()
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logs + raw requests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_ts(ts: str) -> tuple[str, float | None]:
|
||||||
|
"""Best-effort ISO timestamp extraction from a log line timestamp string."""
|
||||||
|
if not ts:
|
||||||
|
return (ts, None)
|
||||||
|
epoch = None
|
||||||
|
cleaned = ts.strip()
|
||||||
|
# Accept a trailing timezone like +HH:MM or Z; fall back to naive parse.
|
||||||
|
try:
|
||||||
|
epoch = datetime.fromisoformat(cleaned.replace("Z", "+00:00")).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
epoch = datetime.fromisoformat(cleaned).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
epoch = None
|
||||||
|
return (ts, epoch)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_requests(conn: sqlite3.Connection, rows: Sequence[dict[str, Any]]) -> int:
|
||||||
|
"""Bulk-insert ``requests`` rows. Returns inserted count.
|
||||||
|
|
||||||
|
Each ``row`` maps keys: source, scan_id, log_file, ts, ip, version,
|
||||||
|
method, path, status, upstream_status, host, user, ua, referer, denied,
|
||||||
|
banned, is_error, malicious_hits, extra(dict|None) — matching
|
||||||
|
:mod:`app.utils.models.Request` plus provenance fields.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
created = utcnow()
|
||||||
|
tuples: list[tuple] = []
|
||||||
|
for r in rows:
|
||||||
|
ts, epoch = _parse_ts(r.get("ts") or "")
|
||||||
|
extra = r.get("extra")
|
||||||
|
tuples.append((
|
||||||
|
r.get("source"), r.get("scan_id"), r.get("log_file"), ts, epoch,
|
||||||
|
r.get("ip"), r.get("version"),
|
||||||
|
r.get("method"), r.get("path"),
|
||||||
|
r.get("status"), r.get("upstream_status"),
|
||||||
|
r.get("host"), r.get("user"), r.get("ua"), r.get("referer"),
|
||||||
|
int(bool(r.get("denied"))), int(bool(r.get("banned"))),
|
||||||
|
int(bool(r.get("is_error"))),
|
||||||
|
int(r.get("malicious_hits") or 0),
|
||||||
|
json.dumps(extra) if extra is not None else None,
|
||||||
|
created,
|
||||||
|
))
|
||||||
|
with transaction(conn):
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO requests (source, scan_id, log_file, ts, ts_epoch, ip, "
|
||||||
|
"version, method, path, status, upstream_status, host, user, ua, "
|
||||||
|
"referer, denied, banned, is_error, malicious_hits, extra, created_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
tuples)
|
||||||
|
return len(tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def query_requests(conn: sqlite3.Connection, *,
|
||||||
|
ip: str | None = None, source: str | None = None,
|
||||||
|
since: str | None = None, until: str | None = None,
|
||||||
|
path: str | None = None, limit: int = 500) -> list[dict[str, Any]]:
|
||||||
|
"""Return matching request rows (optionally filtered), newest last."""
|
||||||
|
clauses: list[str] = []
|
||||||
|
args: list[Any] = []
|
||||||
|
if ip:
|
||||||
|
clauses.append("ip = ?")
|
||||||
|
args.append(ip)
|
||||||
|
if source:
|
||||||
|
clauses.append("source = ?")
|
||||||
|
args.append(source)
|
||||||
|
if since:
|
||||||
|
clauses.append("COALESCE(ts, '') >= ?")
|
||||||
|
args.append(since)
|
||||||
|
if until:
|
||||||
|
clauses.append("COALESCE(ts, '') <= ?")
|
||||||
|
args.append(until)
|
||||||
|
if path:
|
||||||
|
clauses.append("path LIKE ?")
|
||||||
|
args.append(path)
|
||||||
|
sql = "SELECT * FROM requests"
|
||||||
|
if clauses:
|
||||||
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
|
sql += " ORDER BY id LIMIT ?"
|
||||||
|
args.append(int(limit))
|
||||||
|
return [dict(r) for r in conn.execute(sql, args).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IP aggregates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def insert_ip_stats(conn: sqlite3.Connection, scan_id: int,
|
||||||
|
stats: Sequence[IPStats],
|
||||||
|
blocked: set[str]) -> int:
|
||||||
|
"""Persist per-IP aggregates for a scan. ``blocked`` are direct blocked IPs."""
|
||||||
|
if not stats:
|
||||||
|
return 0
|
||||||
|
tuples: list[tuple] = []
|
||||||
|
for s in stats:
|
||||||
|
tuples.append((
|
||||||
|
scan_id, s.ip, s.version, s.total, s.errors, s.c4xx, s.c5xx,
|
||||||
|
s.denied, s.banned, s.first, s.last, s.malicious_hits,
|
||||||
|
json.dumps(dict(s.paths)), json.dumps(dict(s.methods)),
|
||||||
|
json.dumps(dict(s.hosts)), json.dumps(dict(s.uas)),
|
||||||
|
json.dumps(sorted(s.users)), s.whitelist or None,
|
||||||
|
int(s.ip in blocked),
|
||||||
|
))
|
||||||
|
with transaction(conn):
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO ip_stats (scan_id, ip, version, total, errors, c4xx, "
|
||||||
|
"c5xx, denied, banned, first_ts, last_ts, malicious_hits, paths_json, "
|
||||||
|
"methods_json, hosts_json, uas_json, users_json, whitelist, blocked) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
tuples)
|
||||||
|
return len(tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def ip_timeline(conn: sqlite3.Connection, ip: str,
|
||||||
|
limit: int = 2000) -> list[dict[str, Any]]:
|
||||||
|
"""Chronological request history for one IP across all sources."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT source, log_file, ts, ts_epoch, method, path, status, host, "
|
||||||
|
"ua, denied, banned, is_error, malicious_hits FROM requests "
|
||||||
|
"WHERE ip = ? ORDER BY COALESCE(ts_epoch, 0), id LIMIT ?",
|
||||||
|
(ip, limit)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def count_requests(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Total persisted request records."""
|
||||||
|
return int(conn.execute("SELECT COUNT(*) FROM requests").fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def count_scans(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Total persisted scan records."""
|
||||||
|
return int(conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def correlate_across_sources(conn: sqlite3.Connection,
|
||||||
|
limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
"""IPs seen in more than one log source, with per-source counts."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ip, COUNT(DISTINCT source) AS sources, "
|
||||||
|
"GROUP_CONCAT(DISTINCT source) AS source_list, COUNT(*) AS total "
|
||||||
|
"FROM requests WHERE ip IS NOT NULL AND ip != '' "
|
||||||
|
"GROUP BY ip HAVING sources > 1 "
|
||||||
|
"ORDER BY total DESC LIMIT ?", (limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def top_ips(conn: sqlite3.Connection, since: str | None = None,
|
||||||
|
limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
"""Top IPs by request count within an optional time window."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ip, version, COUNT(*) AS total, "
|
||||||
|
"SUM(is_error) AS errors, SUM(malicious_hits) AS malicious_hits, "
|
||||||
|
"MIN(COALESCE(ts_epoch, 0)) AS first_epoch, "
|
||||||
|
"MAX(COALESCE(ts_epoch, 0)) AS last_epoch "
|
||||||
|
"FROM requests WHERE ip IS NOT NULL AND ip != '' "
|
||||||
|
+ ("AND COALESCE(ts, '') >= ? " if since else "")
|
||||||
|
+ "GROUP BY ip ORDER BY total DESC LIMIT ?",
|
||||||
|
((since,) if since else ()) + (limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blocklist ledger
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def active_blocklist(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, cidr, version, added_at, added_by, last_seen "
|
||||||
|
"FROM blocklist_entries WHERE status='active' ORDER BY cidr").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def block_entry(conn: sqlite3.Connection, cidr: str, *,
|
||||||
|
added_by: str | None = None, last_seen: str | None = None,
|
||||||
|
reasons: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
"""Add a CIDR to the grow-only ledger (idempotent when already active).
|
||||||
|
|
||||||
|
Manages its own transaction for standalone use."""
|
||||||
|
with transaction(conn):
|
||||||
|
_block_entry_inner(conn, cidr, added_by=added_by,
|
||||||
|
last_seen=last_seen, reasons=reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_entry_inner(conn: sqlite3.Connection, cidr: str, *,
|
||||||
|
added_by: str | None = None,
|
||||||
|
last_seen: str | None = None,
|
||||||
|
reasons: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
"""Low-level block insert; caller owns the transaction."""
|
||||||
|
now = utcnow()
|
||||||
|
version = _cidr_version(cidr)
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM blocklist_entries WHERE cidr = ? AND status='active'",
|
||||||
|
(cidr,)).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
entry_id = int(existing["id"])
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE blocklist_entries SET last_seen = COALESCE(?, last_seen) "
|
||||||
|
"WHERE id = ?", (last_seen, entry_id))
|
||||||
|
if reasons:
|
||||||
|
insert_reasons(conn, entry_id, last_seen or "", reasons)
|
||||||
|
return
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO blocklist_entries (cidr, version, status, added_at, "
|
||||||
|
"added_by, last_seen) VALUES (?, ?, 'active', ?, ?, ?)",
|
||||||
|
(cidr, version, now, added_by, last_seen))
|
||||||
|
entry_id = int(cur.lastrowid)
|
||||||
|
if reasons:
|
||||||
|
insert_reasons(conn, entry_id, last_seen or "", reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def unblock_entry(conn: sqlite3.Connection, cidr: str, *,
|
||||||
|
actor: str | None = None) -> bool:
|
||||||
|
"""Remove a CIDR from active blocklist (idempotent). Returns True if removed."""
|
||||||
|
now = utcnow()
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE blocklist_entries SET status='removed', removed_at=? "
|
||||||
|
"WHERE cidr=? AND status='active'", (now, cidr))
|
||||||
|
removed = cur.rowcount > 0
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO blocklist_events (action, cidr, actor, fired_at, detail) "
|
||||||
|
"VALUES ('remove', ?, ?, ?, ?)",
|
||||||
|
(cidr, actor, now, None))
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def sync_blocklist(conn: sqlite3.Connection, desired_cidrs: Sequence[str], *,
|
||||||
|
added_by: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Reconcile active blocklist to ``desired_cidrs``.
|
||||||
|
|
||||||
|
Adds missing entries; marks active entries absent from ``desired_cidrs``
|
||||||
|
as removed (only ones the system added) so the ledger stays grow-only but
|
||||||
|
reflects the current policy. Returns the add/remove delta.
|
||||||
|
"""
|
||||||
|
desired = set(desired_cidrs)
|
||||||
|
current = {r["cidr"] for r in active_blocklist(conn)}
|
||||||
|
added = sorted(desired - current)
|
||||||
|
removed = sorted(current - desired)
|
||||||
|
now = utcnow()
|
||||||
|
with transaction(conn):
|
||||||
|
for cidr in added:
|
||||||
|
_block_entry_inner(conn, cidr, added_by=added_by, last_seen=now)
|
||||||
|
conn.executemany(
|
||||||
|
"UPDATE blocklist_entries SET status='removed', removed_at=? "
|
||||||
|
"WHERE cidr=? AND status='active'",
|
||||||
|
[(now, cidr) for cidr in removed])
|
||||||
|
return {"added": added, "removed": removed}
|
||||||
|
|
||||||
|
|
||||||
|
def insert_reasons(conn: sqlite3.Connection, entry_id: int, scan_session: str,
|
||||||
|
reasons: list[dict[str, Any]]) -> None:
|
||||||
|
tuples = [
|
||||||
|
(entry_id, r.get("scan_id"), r.get("ip") or "", r.get("reason"),
|
||||||
|
r.get("pattern"), r.get("severity"), r.get("sample_path"),
|
||||||
|
int(r.get("hits") or 0))
|
||||||
|
for r in reasons
|
||||||
|
]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO blocklist_reasons (entry_id, scan_id, ip, reason, pattern, "
|
||||||
|
"severity, sample_path, hits) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def blocklist_history(conn: sqlite3.Connection, limit: int = 500) -> list[dict[str, Any]]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, cidr, version, status, added_at, removed_at, added_by, "
|
||||||
|
"last_seen FROM blocklist_entries ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _cidr_version(cidr: str) -> int:
|
||||||
|
try:
|
||||||
|
import ipaddress
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
return net.version
|
||||||
|
except ValueError:
|
||||||
|
return 4 if ":" not in str(cidr) else 6
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Whitelists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_whitelist(conn: sqlite3.Connection, provider: str,
|
||||||
|
cidrs: Sequence[str], source: str | None = None) -> None:
|
||||||
|
"""Replace the CIDR set for a provider (mark old inactive, upsert new)."""
|
||||||
|
now = utcnow()
|
||||||
|
with transaction(conn):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE whitelist_entries SET active=0 WHERE provider=?", (provider,))
|
||||||
|
for cidr in cidrs:
|
||||||
|
version = _cidr_version(cidr)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO whitelist_entries (provider, cidr, version, source, "
|
||||||
|
"active, updated_at) VALUES (?,?,?,?,1,?) "
|
||||||
|
"ON CONFLICT(provider, cidr) DO UPDATE SET active=1, "
|
||||||
|
"source=excluded.source, updated_at=excluded.updated_at",
|
||||||
|
(provider, cidr, version, source, now))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO whitelist_sources (provider, source_type, last_refresh_at, "
|
||||||
|
"entry_count) VALUES (?, ?, ?, ?) ON CONFLICT(provider) DO UPDATE SET "
|
||||||
|
"source_type=excluded.source_type, last_refresh_at=excluded.last_refresh_at, "
|
||||||
|
"entry_count=excluded.entry_count",
|
||||||
|
(provider, source, now, len(cidrs)))
|
||||||
|
|
||||||
|
|
||||||
|
def is_whitelisted_db(conn: sqlite3.Connection, ip: str) -> bool:
|
||||||
|
"""Check whether an IP falls in any active whitelist CIDR."""
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT cidr FROM whitelist_entries WHERE active=1").fetchall()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(r["cidr"], strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if addr.version == net.version and addr in net:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Firewall snapshots
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def save_firewall_snapshot(conn: sqlite3.Connection, *,
|
||||||
|
address_lists_v4: list[dict],
|
||||||
|
address_lists_v6: list[dict],
|
||||||
|
rules_v4: list[dict], rules_v6: list[dict]) -> int:
|
||||||
|
"""Persist a full firewall snapshot; returns the snapshot id."""
|
||||||
|
now = utcnow()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO firewall_snapshots (captured_at, kind, trigger) "
|
||||||
|
"VALUES (?, 'snapshot', 'scan')", (now,))
|
||||||
|
snapshot_id = int(cur.lastrowid)
|
||||||
|
with transaction(conn):
|
||||||
|
tuples = [
|
||||||
|
(snapshot_id, "ipv4", e.get("list"), e.get("address"),
|
||||||
|
int(bool(e.get("dynamic"))), e.get("timeout"))
|
||||||
|
for e in address_lists_v4
|
||||||
|
] + [
|
||||||
|
(snapshot_id, "ipv6", e.get("list"), e.get("address"),
|
||||||
|
int(bool(e.get("dynamic"))), e.get("timeout"))
|
||||||
|
for e in address_lists_v6
|
||||||
|
]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO firewall_address_list_entries (snapshot_id, network, "
|
||||||
|
"list, address, dynamic, timeout) VALUES (?,?,?,?,?,?)", tuples)
|
||||||
|
rule_tuples = [
|
||||||
|
(snapshot_id, "ipv4", idx, e.get("chain"), e.get("action"),
|
||||||
|
e.get("dst-list"), None, e.get("comment"), json.dumps(e))
|
||||||
|
for idx, e in enumerate(rules_v4)
|
||||||
|
] + [
|
||||||
|
(snapshot_id, "ipv6", idx, e.get("chain"), e.get("action"),
|
||||||
|
e.get("dst-list"), None, e.get("comment"), json.dumps(e))
|
||||||
|
for idx, e in enumerate(rules_v6)
|
||||||
|
]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO firewall_rule_entries (snapshot_id, network, rule_index, "
|
||||||
|
"chain, action, dst_list, proto_port, comment, raw_json) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?)", rule_tuples)
|
||||||
|
return snapshot_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WHOIS cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_whois(conn: sqlite3.Connection, ip: str, name: str | None,
|
||||||
|
cidr: str | None) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO whois_info (ip, name, cidr, fetched_at) VALUES (?,?,?,?) "
|
||||||
|
"ON CONFLICT(ip) DO UPDATE SET name=excluded.name, cidr=excluded.cidr, "
|
||||||
|
"fetched_at=excluded.fetched_at",
|
||||||
|
(ip, name, cidr, utcnow()))
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SQLite schema and migrations for the intrusion-detection system.
|
||||||
|
|
||||||
|
Definitions live here as the single source of truth and are applied through
|
||||||
|
``PRAGMA user_version`` migrations (see ``apply_migrations``). Each entry is an
|
||||||
|
ordered list of DDL statements; the version is bumped in lock-step so the
|
||||||
|
migrations are idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
#: Latest schema version. Bump by one whenever a new migration is appended.
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
#: Ordered migrations: index -> list of DDL statements. A migration N upgrades a
|
||||||
|
#: database from user_version N-1 to N. Never edit an older migration in place;
|
||||||
|
#: append a new one instead.
|
||||||
|
MIGRATIONS: dict[int, list[str]] = {
|
||||||
|
1: [
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# log_sources — canonical provenance
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS log_sources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
log_path TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO log_sources (name, log_path)
|
||||||
|
VALUES ('copyparty', NULL), ('npm', NULL)
|
||||||
|
""",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# requests — one row per parsed request; insert-only
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
scan_id INTEGER,
|
||||||
|
log_file TEXT,
|
||||||
|
ts TEXT,
|
||||||
|
ts_epoch REAL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
version INTEGER,
|
||||||
|
method TEXT,
|
||||||
|
path TEXT,
|
||||||
|
status INTEGER,
|
||||||
|
upstream_status INTEGER,
|
||||||
|
host TEXT,
|
||||||
|
user TEXT,
|
||||||
|
ua TEXT,
|
||||||
|
referer TEXT,
|
||||||
|
denied INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_error INTEGER NOT NULL DEFAULT 0,
|
||||||
|
malicious_hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
extra TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_ip_ts ON requests (ip, ts)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_source_ts ON requests (source, ts)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_ts_epoch ON requests (ts_epoch)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_path ON requests (path)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_requests_scan ON requests (scan_id)",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ip_stats — per-IP aggregate per scan
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS ip_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scan_id INTEGER,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
version INTEGER,
|
||||||
|
total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
errors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
c4xx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
c5xx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
denied INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_ts TEXT,
|
||||||
|
last_ts TEXT,
|
||||||
|
malicious_hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
paths_json TEXT,
|
||||||
|
methods_json TEXT,
|
||||||
|
hosts_json TEXT,
|
||||||
|
uas_json TEXT,
|
||||||
|
users_json TEXT,
|
||||||
|
whitelist TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_ip_stats_scan_ip ON ip_stats (scan_id, ip)",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# scans + scans_sources — one row per analysis run
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
full INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||||
|
params_json TEXT,
|
||||||
|
result_json TEXT,
|
||||||
|
log_files INTEGER NOT NULL DEFAULT 0,
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_scans_started ON scans (started_at)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scans_sources (
|
||||||
|
scan_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
files INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (scan_id, source)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# blocklist — grow-only ledger + reasons + audit events
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS blocklist_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cidr TEXT NOT NULL,
|
||||||
|
version INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
removed_at TEXT,
|
||||||
|
added_by TEXT,
|
||||||
|
last_seen TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""CREATE UNIQUE INDEX IF NOT EXISTS
|
||||||
|
idx_blocklist_active_cidr ON blocklist_entries (cidr) WHERE status='active'""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS blocklist_reasons (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entry_id INTEGER,
|
||||||
|
scan_id INTEGER,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
pattern TEXT,
|
||||||
|
severity INTEGER,
|
||||||
|
sample_path TEXT,
|
||||||
|
hits INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_blereasons_ip ON blocklist_reasons (ip)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS blocklist_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
cidr TEXT NOT NULL,
|
||||||
|
actor TEXT,
|
||||||
|
fired_at TEXT NOT NULL,
|
||||||
|
detail TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_blevents_cidr ON blocklist_events (cidr)",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# whitelist — current entries + source refresh tracking
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS whitelist_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
cidr TEXT NOT NULL,
|
||||||
|
version INTEGER,
|
||||||
|
source TEXT,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wl_provider_cidr ON whitelist_entries (provider, cidr)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS whitelist_sources (
|
||||||
|
provider TEXT PRIMARY KEY,
|
||||||
|
source_type TEXT,
|
||||||
|
url TEXT,
|
||||||
|
file TEXT,
|
||||||
|
last_refresh_at TEXT,
|
||||||
|
entry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
refresh_error TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# firewall — snapshots of address lists + rules
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS firewall_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
captured_at TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'snapshot',
|
||||||
|
trigger TEXT NOT NULL DEFAULT 'scan'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS firewall_address_list_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_id INTEGER,
|
||||||
|
network TEXT,
|
||||||
|
list TEXT,
|
||||||
|
address TEXT,
|
||||||
|
dynamic INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timeout INTEGER
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fwal_snapshot ON firewall_address_list_entries (snapshot_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fwal_list_addr ON firewall_address_list_entries (list, address)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS firewall_rule_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_id INTEGER,
|
||||||
|
network TEXT,
|
||||||
|
rule_index INTEGER,
|
||||||
|
chain TEXT,
|
||||||
|
action TEXT,
|
||||||
|
dst_list TEXT,
|
||||||
|
proto_port TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
raw_json TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_fwr_snapshot ON firewall_rule_entries (snapshot_id)",
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# whois — structured cache
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS whois_info (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
cidr TEXT,
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Apply all pending migrations, bumping ``user_version`` in lock-step.
|
||||||
|
|
||||||
|
Safe to call on every process start: it only runs migrations whose version
|
||||||
|
is greater than the current ``PRAGMA user_version``.
|
||||||
|
"""
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
current = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
for version in range(current + 1, SCHEMA_VERSION + 1):
|
||||||
|
statements = MIGRATIONS.get(version)
|
||||||
|
if statements is None:
|
||||||
|
raise RuntimeError(f"missing migration for schema version {version}")
|
||||||
|
with conn:
|
||||||
|
for ddl in statements:
|
||||||
|
conn.execute(ddl)
|
||||||
|
conn.execute(f"PRAGMA user_version={version}")
|
||||||
+73
-2
@@ -40,6 +40,22 @@ from ..mikrotik import split_cidr_versions
|
|||||||
from ..utils.whois import consolidate_by_subnet
|
from ..utils.whois import consolidate_by_subnet
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_blocklist_to_db(connected: bool, desired_v4: list[str],
|
||||||
|
desired_v6: list[str], added_by: str | None = None) -> None:
|
||||||
|
"""Best-effort sync of the consolidated blocklist targets into SQLite.
|
||||||
|
|
||||||
|
Keep the DB ledger in sync regardless of whether a router sync happened so
|
||||||
|
analysis always sees the current policy."""
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
conn = get_connection()
|
||||||
|
repo.sync_blocklist(conn, list(desired_v4) + list(desired_v6),
|
||||||
|
added_by=added_by)
|
||||||
|
except Exception as exc: # never break the scan for a DB issue
|
||||||
|
print(f"WARN: could not sync blocklist to db ({exc})")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ScanParams:
|
class ScanParams:
|
||||||
"""Configuration for a consolidated scan.
|
"""Configuration for a consolidated scan.
|
||||||
@@ -93,6 +109,24 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
"""
|
"""
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# --- Step 0: open the SQLite store for this consolidated run -------------
|
||||||
|
db_scan_id: int | None = None
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
from ..utils.whitelists import sync_whitelists_to_db
|
||||||
|
conn = get_connection()
|
||||||
|
sync_whitelists_to_db()
|
||||||
|
params_map = {
|
||||||
|
"full": params.full, "dry_run": params.dry_run,
|
||||||
|
"update_firewall": params.update_firewall,
|
||||||
|
"quiet": params.quiet, "top": params.top,
|
||||||
|
}
|
||||||
|
db_scan_id = repo.start_scan(conn, "ids", params=params_map,
|
||||||
|
full=params.full, dry_run=params.dry_run)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"WARN: sqlite store unavailable ({exc})")
|
||||||
|
|
||||||
# --- Step 1-3: connect to MikroTik RouterOS and capture current state ---
|
# --- Step 1-3: connect to MikroTik RouterOS and capture current state ---
|
||||||
# If the router is unreachable we fall back to scan-only mode: still
|
# If the router is unreachable we fall back to scan-only mode: still
|
||||||
# generate reports and blocklists, but skip the firewall sync.
|
# generate reports and blocklists, but skip the firewall sync.
|
||||||
@@ -134,7 +168,7 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
print("=== Running copyparty scan ===")
|
print("=== Running copyparty scan ===")
|
||||||
cp_exists = True
|
cp_exists = True
|
||||||
try:
|
try:
|
||||||
cp_analyzer.run()
|
cp_analyzer.run(db_scan_id=db_scan_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"copyparty scan failed: {e}")
|
errors.append(f"copyparty scan failed: {e}")
|
||||||
print(f"Error running copyparty scan: {e}")
|
print(f"Error running copyparty scan: {e}")
|
||||||
@@ -143,7 +177,7 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
print("\n=== Running NPM scan ===")
|
print("\n=== Running NPM scan ===")
|
||||||
npm_exists = True
|
npm_exists = True
|
||||||
try:
|
try:
|
||||||
npm_analyzer.run()
|
npm_analyzer.run(db_scan_id=db_scan_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"NPM scan failed: {e}")
|
errors.append(f"NPM scan failed: {e}")
|
||||||
print(f"Error running NPM scan: {e}")
|
print(f"Error running NPM scan: {e}")
|
||||||
@@ -155,6 +189,14 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
print(f"\n{message}")
|
print(f"\n{message}")
|
||||||
if firewall is not None:
|
if firewall is not None:
|
||||||
firewall.disconnect()
|
firewall.disconnect()
|
||||||
|
if db_scan_id is not None:
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
repo.finish_scan(get_connection(), db_scan_id, status="error",
|
||||||
|
error="; ".join(errors))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return ScanResult(ok=False, errors=errors)
|
return ScanResult(ok=False, errors=errors)
|
||||||
|
|
||||||
# Consolidate block candidates
|
# Consolidate block candidates
|
||||||
@@ -240,6 +282,35 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
f"(keep {REPORT_RETENTION_DAYS}d / "
|
f"(keep {REPORT_RETENTION_DAYS}d / "
|
||||||
f"max {REPORT_RETENTION_MAX_FILES} per dir)")
|
f"max {REPORT_RETENTION_MAX_FILES} per dir)")
|
||||||
|
|
||||||
|
# --- Step 9: persist consolidated results to SQLite ---------------------
|
||||||
|
# Sync the blocklist ledger and close out the scan record regardless of
|
||||||
|
# whether the router sync was attempted.
|
||||||
|
_sync_blocklist_to_db(
|
||||||
|
firewall is not None, blocked_ipv4, blocked_ipv6,
|
||||||
|
added_by=("scan" if db_scan_id is None else f"scan:{db_scan_id}"))
|
||||||
|
if db_scan_id is not None:
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
conn = get_connection()
|
||||||
|
result_payload = {
|
||||||
|
"ok": True,
|
||||||
|
"cp_candidates": cp_candidates,
|
||||||
|
"npm_candidates": npm_candidates,
|
||||||
|
"blocked_ips": len(final_blocked_ips),
|
||||||
|
"blocked_targets": len(final_blocked_targets),
|
||||||
|
"firewall_connected": firewall is not None,
|
||||||
|
}
|
||||||
|
repo.finish_scan(
|
||||||
|
conn, db_scan_id, status="done",
|
||||||
|
result=result_payload, log_files=0,
|
||||||
|
request_count=(cp_analyzer and len(cp_analyzer.requests) or 0)
|
||||||
|
+ (npm_analyzer and len(npm_analyzer.requests) or 0),
|
||||||
|
error="; ".join(errors) or None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"WARN: could not finalize scan in db ({exc})")
|
||||||
|
|
||||||
return ScanResult(
|
return ScanResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
cp_exists=cp_exists,
|
cp_exists=cp_exists,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}Log Analysis — Intrusion Detection{%
|
||||||
|
endblock %} {% block content %}
|
||||||
|
<h1>Log Analysis</h1>
|
||||||
|
<p class="muted">
|
||||||
|
Detailed analysis of persisted log data from the SQLite store. Select an IP
|
||||||
|
to see its full request history across all log sources.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Top IPs</h2>
|
||||||
|
<span class="muted" id="top-disclaimer"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP</th>
|
||||||
|
<th class="num">Requests</th>
|
||||||
|
<th class="num">Errors</th>
|
||||||
|
<th class="num">Malicious</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="top-ips"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Cross-source IPs</h2>
|
||||||
|
<span class="muted">Seen in more than one log source</span>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP</th>
|
||||||
|
<th class="num">Sources</th>
|
||||||
|
<th>Source list</th>
|
||||||
|
<th class="num">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cross-source"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="ip-panel" hidden>
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2 id="ip-title">Request history</h2>
|
||||||
|
<a class="btn ghost" href="#" id="ip-close">✕ Close</a>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th class="num">Status</th>
|
||||||
|
<th>UA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ip-timeline"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showIp(ip) {
|
||||||
|
const panel = document.getElementById("ip-panel");
|
||||||
|
const body = document.getElementById("ip-timeline");
|
||||||
|
document.getElementById("ip-title").textContent = `Request history — ${ip}`;
|
||||||
|
panel.hidden = false;
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="6" class="muted">Loading…</td></tr>';
|
||||||
|
api(`/api/analysis/ip/${encodeURIComponent(ip)}`)
|
||||||
|
.then((data) => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const tl = data.timeline || [];
|
||||||
|
if (!tl.length) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="6" class="muted">No persisted requests for this IP.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const r of tl) {
|
||||||
|
const status = r.status ?? "—";
|
||||||
|
const row = el(
|
||||||
|
"tr",
|
||||||
|
el("td", {
|
||||||
|
class: "mono",
|
||||||
|
text: r.ts || "—",
|
||||||
|
}),
|
||||||
|
el("td", { text: r.source }),
|
||||||
|
el("td", { text: r.method || "—" }),
|
||||||
|
el("td", { class: "mono", text: r.path || "—" }),
|
||||||
|
el(
|
||||||
|
"td",
|
||||||
|
{
|
||||||
|
class: "num",
|
||||||
|
text: status,
|
||||||
|
style: status >= 400 ? "color:var(--danger)" : "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
el("td", { class: "muted", text: r.ua || "—" }),
|
||||||
|
);
|
||||||
|
if (r.is_error) row.classList.add("row-error");
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => toast(e.message, "error"));
|
||||||
|
panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTop() {
|
||||||
|
const body = document.getElementById("top-ips");
|
||||||
|
body.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const data = await api("/api/analysis/top-ips?limit=50");
|
||||||
|
body.innerHTML = "";
|
||||||
|
const ips = data.top_ips || [];
|
||||||
|
document.getElementById("top-disclaimer").textContent =
|
||||||
|
`showing top ${ips.length} IPs`;
|
||||||
|
if (!ips.length) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="5" class="muted">No persisted requests yet — run a scan to populate the SQLite store.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const r of ips) {
|
||||||
|
body.append(
|
||||||
|
el(
|
||||||
|
"tr",
|
||||||
|
el("td", { class: "mono", text: r.ip }),
|
||||||
|
el("td", { class: "num", text: r.total }),
|
||||||
|
el("td", { class: "num", text: r.errors }),
|
||||||
|
el("td", { class: "num", text: r.malicious_hits }),
|
||||||
|
el(
|
||||||
|
"td",
|
||||||
|
el(
|
||||||
|
"button",
|
||||||
|
{ class: "btn ghost", onclick: () => showIp(r.ip) },
|
||||||
|
"Detail",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="5" class="muted b">SQLite analysis unavailable.</td></tr>';
|
||||||
|
toast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCrossSource() {
|
||||||
|
const body = document.getElementById("cross-source");
|
||||||
|
body.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const data = await api("/api/analysis/cross-source?limit=100");
|
||||||
|
const ips = data.ips || [];
|
||||||
|
if (!ips.length) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="4" class="muted">No IPs seen across multiple sources yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const r of ips) {
|
||||||
|
body.append(
|
||||||
|
el(
|
||||||
|
"tr",
|
||||||
|
el("td", { class: "mono", text: r.ip }),
|
||||||
|
el("td", { class: "num", text: r.sources }),
|
||||||
|
el("td", { text: r.source_list }),
|
||||||
|
el("td", { class: "num", text: r.total }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="4" class="muted b">SQLite analysis unavailable.</td></tr>';
|
||||||
|
toast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("ip-close")
|
||||||
|
.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById("ip-panel").hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
loadTop();
|
||||||
|
loadCrossSource();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -40,6 +40,11 @@
|
|||||||
class="{{ 'active' if request.path == '/graph' }}"
|
class="{{ 'active' if request.path == '/graph' }}"
|
||||||
>Graph</a
|
>Graph</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
href="/analysis"
|
||||||
|
class="{{ 'active' if request.path == '/analysis' }}"
|
||||||
|
>Analysis</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="container">{% block content %}{% endblock %}</main>
|
<main class="container">{% block content %}{% endblock %}</main>
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ endblock %} {% block content %}
|
|||||||
s.firewall_snapshot && s.firewall_snapshot.exists ? "ok" : "muted",
|
s.firewall_snapshot && s.firewall_snapshot.exists ? "ok" : "muted",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
if (s.analysis && s.analysis.available) {
|
||||||
|
cards.push({
|
||||||
|
label: "SQLite stores",
|
||||||
|
value: `${s.analysis.request_count ?? 0} req / ${s.analysis.scan_count ?? 0} scans`,
|
||||||
|
tone: "ok",
|
||||||
|
});
|
||||||
|
}
|
||||||
const box = document.getElementById("status-cards");
|
const box = document.getElementById("status-cards");
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
for (const c of cards) {
|
for (const c of cards) {
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ MIKROTIK_DATA_DIR = os.path.join(DATA_DIR, "mikrotik")
|
|||||||
|
|
||||||
WHOIS_CACHE_PATH = os.path.join(DATA_DIR, "whois_cache.json")
|
WHOIS_CACHE_PATH = os.path.join(DATA_DIR, "whois_cache.json")
|
||||||
|
|
||||||
|
# Local SQLite database (see .github/instructions/DATABASE.md). The default
|
||||||
|
# lives under data/ and holds log records, scan metadata, blocklist/whitelist
|
||||||
|
# and firewall history. Override with IDS_DB_PATH (used by app/db).
|
||||||
|
DATA_DB_PATH = os.environ.get("IDS_DB_PATH", os.path.join(DATA_DIR, "ids.db"))
|
||||||
|
|
||||||
# Incremental-scan state: absolute log path -> last processed byte offset
|
# Incremental-scan state: absolute log path -> last processed byte offset
|
||||||
SCAN_STATE_PATH = os.path.join(DATA_DIR, "scan_state.json")
|
SCAN_STATE_PATH = os.path.join(DATA_DIR, "scan_state.json")
|
||||||
|
|
||||||
|
|||||||
@@ -275,3 +275,20 @@ def refresh_whitelists(force: bool = False) -> bool:
|
|||||||
update_whitelists()
|
update_whitelists()
|
||||||
_refreshed = True
|
_refreshed = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sync_whitelists_to_db() -> None:
|
||||||
|
"""Persist the current in-memory ``WHITELISTS`` into the SQLite store.
|
||||||
|
|
||||||
|
Best-effort (never raises): keeps the DB whitelist tables consistent with
|
||||||
|
the in-memory/provider state so lookups and analysis can rely on them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
conn = get_connection()
|
||||||
|
for provider, cidrs in WHITELISTS.items():
|
||||||
|
repo.set_whitelist(conn, provider, cidrs,
|
||||||
|
source="builtin/refresh")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
print(f"WARN: could not sync whitelists to db ({exc})")
|
||||||
@@ -131,6 +131,41 @@ def _sync_firewall(dry_run: bool):
|
|||||||
return _with_router(_sync)
|
return _with_router(_sync)
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_helpers():
|
||||||
|
"""SQLite-backed log-analysis query helpers (None when DB unavailable).
|
||||||
|
|
||||||
|
Functions take the shared DB connection each call so routes stay stateless
|
||||||
|
across the background scan thread.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def top_ips(since: str | None = None, limit: int = 50) -> list:
|
||||||
|
return repo.top_ips(get_connection(), since=since, limit=limit)
|
||||||
|
|
||||||
|
def ip_timeline(ip: str) -> list:
|
||||||
|
return repo.ip_timeline(get_connection(), ip=ip)
|
||||||
|
|
||||||
|
def cross_source(limit: int = 100) -> list:
|
||||||
|
return repo.correlate_across_sources(get_connection(), limit=limit)
|
||||||
|
|
||||||
|
def conn():
|
||||||
|
return get_connection()
|
||||||
|
|
||||||
|
def request_count(conn) -> int:
|
||||||
|
return repo.count_requests(conn)
|
||||||
|
|
||||||
|
def scan_count(conn) -> int:
|
||||||
|
return repo.count_scans(conn)
|
||||||
|
|
||||||
|
return {"top_ips": top_ips, "ip_timeline": ip_timeline,
|
||||||
|
"cross_source": cross_source, "conn": conn,
|
||||||
|
"request_count": request_count, "scan_count": scan_count}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# app factory
|
# app factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,6 +190,7 @@ def create_app() -> Flask:
|
|||||||
"_params_from_request": _params_from_request,
|
"_params_from_request": _params_from_request,
|
||||||
"_with_router": _with_router,
|
"_with_router": _with_router,
|
||||||
"_sync_firewall": _sync_firewall,
|
"_sync_firewall": _sync_firewall,
|
||||||
|
"_analysis": _analysis_helpers(),
|
||||||
}
|
}
|
||||||
register_routes(app, REPORT_SOURCES, SCAN_KINDS, web_scan, helpers)
|
register_routes(app, REPORT_SOURCES, SCAN_KINDS, web_scan, helpers)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -32,6 +32,40 @@ def register_routes(app: Flask,
|
|||||||
_with_router = helpers["_with_router"]
|
_with_router = helpers["_with_router"]
|
||||||
_sync_firewall = helpers["_sync_firewall"]
|
_sync_firewall = helpers["_sync_firewall"]
|
||||||
|
|
||||||
|
# --- log analysis (SQLite-backed) --------------------------------------
|
||||||
|
|
||||||
|
_analysis = helpers.get("_analysis")
|
||||||
|
|
||||||
|
@app.get("/analysis")
|
||||||
|
def analysis_page():
|
||||||
|
return render_template("analysis.html")
|
||||||
|
|
||||||
|
@app.get("/api/analysis/top-ips")
|
||||||
|
def analysis_top_ips():
|
||||||
|
if _analysis is None:
|
||||||
|
return jsonify({"error": "analysis unavailable"}), 503
|
||||||
|
since = (request.args.get("since") or "").strip() or None
|
||||||
|
limit = request.args.get("limit", 50, type=int)
|
||||||
|
return jsonify({"top_ips": _analysis["top_ips"](since=since, limit=limit)})
|
||||||
|
|
||||||
|
@app.get("/api/analysis/ip/<ip>")
|
||||||
|
def analysis_ip(ip):
|
||||||
|
if _analysis is None:
|
||||||
|
return jsonify({"error": "analysis unavailable"}), 503
|
||||||
|
return jsonify({"ip": ip,
|
||||||
|
"timeline": _analysis["ip_timeline"](ip)})
|
||||||
|
|
||||||
|
@app.get("/api/analysis/cross-source")
|
||||||
|
def analysis_cross_source():
|
||||||
|
if _analysis is None:
|
||||||
|
return jsonify({"error": "analysis unavailable"}), 503
|
||||||
|
limit = request.args.get("limit", 100, type=int)
|
||||||
|
return jsonify({"ips": _analysis["cross_source"](limit=limit)})
|
||||||
|
|
||||||
|
@app.get("/api/analysis/manifest")
|
||||||
|
def analysis_manifest():
|
||||||
|
return jsonify({"ok": _analysis is not None})
|
||||||
|
|
||||||
# --- pages -------------------------------------------------------------
|
# --- pages -------------------------------------------------------------
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -68,6 +102,17 @@ def register_routes(app: Flask,
|
|||||||
def status():
|
def status():
|
||||||
v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")
|
v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")
|
||||||
v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")
|
v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")
|
||||||
|
analysis = {}
|
||||||
|
if _analysis is not None:
|
||||||
|
try:
|
||||||
|
conn = _analysis["conn"]()
|
||||||
|
analysis = {
|
||||||
|
"available": True,
|
||||||
|
"request_count": _analysis["request_count"](conn),
|
||||||
|
"scan_count": _analysis["scan_count"](conn),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
analysis = {"available": False}
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"host": WEB_HOST,
|
"host": WEB_HOST,
|
||||||
"port": WEB_PORT,
|
"port": WEB_PORT,
|
||||||
@@ -79,6 +124,7 @@ def register_routes(app: Flask,
|
|||||||
"exists": os.path.isdir(MIKROTIK_DATA_DIR),
|
"exists": os.path.isdir(MIKROTIK_DATA_DIR),
|
||||||
"path": MIKROTIK_DATA_DIR,
|
"path": MIKROTIK_DATA_DIR,
|
||||||
},
|
},
|
||||||
|
"analysis": analysis,
|
||||||
})
|
})
|
||||||
|
|
||||||
# --- scans -------------------------------------------------------------
|
# --- scans -------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user