diff --git a/.env.example b/.env.example index 4f98a3d..6297b8a 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,14 @@ # so production deployments can set these via the OS and skip `.env`. # `.env` is git-ignored — never commit real secrets to it. +# --- Web server (localhost only) ------------------------------------------------- + +# The Flask web UI binds to localhost so it is only reachable from this machine. +# Do not expose WEB_HOST beyond 127.0.0.1! +WEB_HOST=127.0.0.1 +# Please choose a very high port number (e.g. 54666) to avoid conflicts with other services. +WEB_PORT=54666 + # --- Log sources ----------------------------------------------------------- # Directory containing copyparty access logs (YYYY-MM-DD.txt files). diff --git a/README.md b/README.md index e5c2c8a..1f4ce55 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,11 @@ intrusion-detection/ │ ├── whois.py query_whois_subnet, consolidate_by_subnet │ ├── mikrotik.py RouterOS .rsc script generation │ ├── files.py read_lines, find_logs, write_csv/json/lines -│ └── reporting.py Column, render_table, render_kv, timestamp_name +│ ├── reporting.py Column, render_table, render_kv, timestamp_name +│ ├── web.py Flask web UI (app factory, JSON API, pages) +│ ├── web_scan.py background scan runner + in-memory job store +│ ├── templates/ Jinja2 page templates (dashboard, scan, reports, …) +│ └── static/ style.css + app.js (dark-theme UI) ├── data/ whitelist source files (cached locally) │ ├── openfactory/ │ │ └── geofeed.csv @@ -88,6 +92,8 @@ Core responsibilities by module: | `app/whois.py` | `query_whois_subnet`, `consolidate_by_subnet` | | `app/mikrotik.py` | `mikrotik_script`, `write_mikrotik`, `split_cidr_versions` | | `app/firewall.py` | `MikrotikFirewall` — SSH connect, sync blocklist, audit state | +| `app/web.py` | Flask app factory, JSON API routes, page rendering | +| `app/web_scan.py` | background scan runner, in-memory job store, single-scan busy lock | | `app/files.py` | `read_lines`, `find_logs`, `validate_dir`, `ensure_dirs`, `write_csv/json/lines` | | `app/reporting.py` | `Column`, `render_table`, `render_kv`, `timestamp_name` | @@ -96,7 +102,7 @@ Console output and saved reports are the **same string** (`Analyzer.text_report( ## Requirements - Python 3.10+ -- `paramiko` (MikroTik SSH), `python-dotenv` (`.env` loading) +- `paramiko` (MikroTik SSH), `python-dotenv` (`.env` loading), `flask` (web UI) ```powershell pip install -r requirements.txt @@ -126,6 +132,8 @@ production can set the same variables via the OS and skip `.env` entirely. | `ROUTER_PASSWORD` | _(set in .env)_ | MikroTik connection | | `BLOCKLIST_NAME` | `Blocked` | MikroTik address list | | `WHITELIST_NAMES` | `Cloudflare,Openfactory` | MikroTik whitelists | +| `WEB_HOST` | `127.0.0.1` | web UI bind host | +| `WEB_PORT` | `54666` | web UI port | See `.env.example` for the complete list of supported variables (WHOIS tuning, scan thresholds, whitelist source URLs, and more). @@ -133,11 +141,15 @@ tuning, scan thresholds, whitelist source URLs, and more). ## Usage ```powershell -# unified entry point (both sources, optional firewall sync) +# web UI (default entry point) python app.py -python app.py --quiet -python app.py --update-firewall --dry-run -python app.py --update-firewall +python app.py --web-port 54666 + +# consolidated CLI scan (scan subcommand) +python app.py scan +python app.py scan --quiet +python app.py scan --update-firewall --dry-run +python app.py scan --update-firewall # individual scanners (still runnable directly) python -m app.copyparty @@ -157,19 +169,46 @@ All scripts accept CLI overrides: | `--min-denied N` | `1` (`copyparty.py`) | denied-hit threshold | | `--update-firewall` | off | sync blocklist to MikroTik via SSH | | `--dry-run` | off | preview firewall changes, no apply | +| `--web-host` | `127.0.0.1` | web UI bind host | +| `--web-port` | `54666` | web UI port | -`app.py` accepts all flags from both scanners plus `--copyparty-log-path`, -`--copyparty-report-dir`, `--npm-log-path`, `--npm-report-dir`, and -`--report-dir` for the consolidated output. +`python app.py scan` accepts all flags from both scanners plus +`--copyparty-log-path`, `--copyparty-report-dir`, `--npm-log-path`, +`--npm-report-dir`, and `--report-dir` for the consolidated output. Examples: ```powershell python -m app.npm --log-path D:\logs\npm --min-errors 10 --error-ratio 0.8 python -m app.copyparty --top 100 -python app.py --quiet --update-firewall --dry-run +python app.py scan --quiet --update-firewall --dry-run ``` +## Web UI + +`python app.py` starts a **localhost-only** Flask web UI (default +`http://127.0.0.1:54666`) for running scans and managing the MikroTik +blocklist / firewall. It is bound to `127.0.0.1` — do **not** expose +`WEB_HOST` beyond localhost. + +Pages: + +| Route | Purpose | +| ------------ | ----------------------------------------------------- | +| `/` | dashboard — status cards + latest scans | +| `/scan` | run copyparty / npm / consolidated scans (background) | +| `/reports` | browse reports per source | +| `/blocklist` | view the consolidated blocklist, add/remove entries | +| `/firewall` | view address lists + rules, preview/apply a sync | +| `/whitelist` | view/manage provider whitelist CIDRs | + +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** +from the UI — applying changes is an explicit action. The main JSON endpoints +(`/api/status`, `/api/scan/*`, `/api/reports/*`, `/api/blocklist`, +`/api/firewall/*`, `/api/whitelist`) are also usable directly from a REST +client. + ## Output Each script writes to its own report directory. `app.py` also writes diff --git a/app.py b/app.py index 58f7281..5fd94be 100644 --- a/app.py +++ b/app.py @@ -1,18 +1,55 @@ #!/usr/bin/env python3 """Intrusion Detection System — unified entry point. -Runs both copyparty and NPM log analyzers, consolidates blocklist -candidates, and optionally synchronizes the MikroTik RouterOS firewall. +By default this starts the localhost-only Flask web UI. Pass ``scan`` to +run the consolidated CLI scan instead. Usage: - python app.py --quiet - python app.py --update-firewall --dry-run - python app.py --update-firewall + python app.py # start the web UI (default) + python app.py --web-port 54666 # web UI with a custom port + python app.py scan [options] # run the consolidated CLI scan """ from __future__ import annotations -from app.scan import main +import argparse +import sys + +from app.constants import WEB_HOST, WEB_PORT + + +def _web_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="app.py", + description="Intrusion Detection System: web UI (default) or 'scan' subcommand.", + ) + parser.add_argument("--web-host", default=WEB_HOST, + help=f"web UI bind host (default: {WEB_HOST})") + parser.add_argument("--web-port", type=int, default=WEB_PORT, + help=f"web UI port (default: {WEB_PORT})") + return parser + + +def _run_web(host: str, port: int) -> None: + from app.web import serve + serve(host=host, port=port) + + +def _run_scan(argv: list[str]) -> None: + from app.scan import main as scan_main + scan_main(argv) + + +def main() -> None: + # `python app.py scan ...` runs the consolidated CLI scan. + if len(sys.argv) > 1 and sys.argv[1] == "scan": + _run_scan(sys.argv[2:]) + return + + # Default: start the web UI (parse_known_args tolerates stray flags). + args, _ = _web_parser().parse_known_args() + _run_web(args.web_host, args.web_port) + if __name__ == "__main__": main() diff --git a/app/constants.py b/app/constants.py index cf43374..82a4ed5 100644 --- a/app/constants.py +++ b/app/constants.py @@ -102,6 +102,15 @@ DEFAULT_ERROR_RATIO = float(os.environ.get( "DEFAULT_ERROR_RATIO", "0.5")) # npm DEFAULT_TOP_PATHS = int(os.environ.get("DEFAULT_TOP_PATHS", "20")) +# --------------------------------------------------------------------------- +# Web server (localhost only) +# --------------------------------------------------------------------------- + +# The Flask web UI binds to localhost on a very high port so it is only +# reachable from this machine. Do not expose WEB_HOST beyond 127.0.0.1. +WEB_HOST = os.environ.get("WEB_HOST", "127.0.0.1") +WEB_PORT = int(os.environ.get("WEB_PORT", "54666")) + # --------------------------------------------------------------------------- # Whitelist sources (configurable via environment) # --------------------------------------------------------------------------- diff --git a/app/scan.py b/app/scan.py index 0f9e36e..785565c 100644 --- a/app/scan.py +++ b/app/scan.py @@ -2,12 +2,13 @@ """Consolidated intrusion detection log scanner. Runs both copyparty and Nginx Proxy Manager (NPM) log analyzers, -and outputs a single consolidated MikroTik RouterOS blocklist script. +and outputs a single consolidated MikroTik RouterOS blocklist. """ from __future__ import annotations import argparse +from dataclasses import dataclass, field from .constants import ( COPYPARTY_LOG_PATH, @@ -29,7 +30,184 @@ from .mikrotik import split_cidr_versions from .whois import consolidate_by_subnet -def main() -> None: +@dataclass +class ScanParams: + """Configuration for a consolidated scan. + + Shared by the CLI entry point and the web interface so both run the + exact same pipeline. + """ + + copyparty_log_path: str = COPYPARTY_LOG_PATH + copyparty_report_dir: str = COPYPARTY_REPORT_DIR + min_denied: int = DEFAULT_MIN_DENIED + npm_log_path: str = NPM_LOG_PATH + npm_report_dir: str = NPM_REPORT_DIR + min_errors: int = DEFAULT_MIN_ERRORS + error_ratio: float = DEFAULT_ERROR_RATIO + report_dir: str = REPORT_DIR + top: int = DEFAULT_TOP + quiet: bool = False + update_firewall: bool = False + dry_run: bool = False + + +@dataclass +class ScanResult: + """Structured outcome of a consolidated scan.""" + + ok: bool + cp_exists: bool = False + npm_exists: bool = False + cp_candidates: int = 0 + npm_candidates: int = 0 + blocked_ips: int = 0 + blocked_targets: int = 0 + blocked_ipv4: list[str] = field(default_factory=list) + blocked_ipv6: list[str] = field(default_factory=list) + blocklist_v4_path: str | None = None + blocklist_v6_path: str | None = None + firewall_connected: bool = False + firewall_changes: dict | None = None + errors: list[str] = field(default_factory=list) + + +def run_scan(params: ScanParams) -> ScanResult: + """Run the full consolidated scan pipeline and return structured results. + + Steps: connect to MikroTik (optional) -> snapshot state -> run copyparty + + NPM analyzers -> consolidate candidates -> write blocklists -> compare + and apply firewall changes (optional). + """ + errors: list[str] = [] + + # --- Step 1-3: connect to MikroTik RouterOS and capture current state --- + # If the router is unreachable we fall back to scan-only mode: still + # generate reports and blocklists, but skip the firewall sync. + firewall: MikrotikFirewall | None = None + if params.update_firewall: + firewall = MikrotikFirewall() + try: + firewall.connect() + # Read current firewall rules + address lists and snapshot them to + # reports/mikrotik/data/ and the git-diffable data/mikrotik/. + firewall.save_state_report(params.report_dir) + except Exception as e: + message = f"could not read MikroTik router ({firewall.router_ip}): {e}" + errors.append(message) + print(f"\nWARNING: {message}") + firewall.disconnect() + firewall = None + + # --- Step 4: run copyparty + NPM analyzers -> blocklist candidates ------ + cp_analyzer = CopypartyAnalyzer( + log_path=params.copyparty_log_path, + report_dir=params.copyparty_report_dir, + top=params.top, + quiet=params.quiet, + min_denied=params.min_denied, + ) + + npm_analyzer = NpmAnalyzer( + log_path=params.npm_log_path, + report_dir=params.npm_report_dir, + top=params.top, + quiet=params.quiet, + min_errors=params.min_errors, + error_ratio=params.error_ratio, + ) + + print("=== Running copyparty scan ===") + cp_exists = True + try: + cp_analyzer.run() + except Exception as e: + errors.append(f"copyparty scan failed: {e}") + print(f"Error running copyparty scan: {e}") + cp_exists = False + + print("\n=== Running NPM scan ===") + npm_exists = True + try: + npm_analyzer.run() + except Exception as e: + errors.append(f"NPM scan failed: {e}") + print(f"Error running NPM scan: {e}") + npm_exists = False + + if not cp_exists and not npm_exists: + message = "Both scans failed. Unable to generate consolidated blocklist." + errors.append(message) + print(f"\n{message}") + if firewall is not None: + firewall.disconnect() + return ScanResult(ok=False, errors=errors) + + # Consolidate block candidates + consolidated_candidates: set[str] = set() + if cp_exists: + consolidated_candidates.update(cp_analyzer.block_candidates()) + if npm_exists: + consolidated_candidates.update(npm_analyzer.block_candidates()) + + # Exclude whitelisted IPs + final_blocked_ips = { + ip for ip in consolidated_candidates if not is_whitelisted(ip)} + + # Group into subnets via WHOIS if multiple malicious IPs in same subnet + print("\nGrouping blocked IPs into subnets using WHOIS...") + final_blocked_targets = consolidate_by_subnet( + final_blocked_ips, threshold=SUBNET_THRESHOLD) + blocked_ipv4, blocked_ipv6 = split_cidr_versions(final_blocked_targets) + + cp_candidates = len(cp_analyzer.block_candidates()) if cp_exists else 0 + npm_candidates = len(npm_analyzer.block_candidates()) if npm_exists else 0 + + print("\n=== Consolidated Summary ===") + print(f"copyparty block candidates: {cp_candidates}") + print(f"NPM block candidates: {npm_candidates}") + print(f"Total unified blocked IPs: {len(final_blocked_ips)}") + print(f"Total target blocks/subnets: {len(final_blocked_targets)}") + + # --- Step 6: write consolidated blocklists to reports/app/data ---------- + blocklist_v4_path, blocklist_v6_path = write_blocklist_files( + blocked_ipv4, blocked_ipv6, params.report_dir) + print(f"blocklist files:\n {blocklist_v4_path}\n {blocklist_v6_path}") + + # --- Steps 5+7: compare current state with candidates and apply --------- + firewall_changes: dict | None = None + if firewall is not None: + try: + firewall_changes = firewall.sync_blocklist( + blocked_ipv4, blocked_ipv6, dry_run=params.dry_run, + report_dir=params.report_dir) + finally: + firewall.disconnect() + action = "Would" if params.dry_run else "" + print(f"{action} add {len(firewall_changes['added_ipv4'])} IPv4 and " + f"{len(firewall_changes['added_ipv6'])} IPv6 addresses; " + f"remove {len(firewall_changes['removed_ipv4'])} IPv4 and " + f"{len(firewall_changes['removed_ipv6'])} IPv6 addresses.") + + return ScanResult( + ok=True, + cp_exists=cp_exists, + npm_exists=npm_exists, + cp_candidates=cp_candidates, + npm_candidates=npm_candidates, + blocked_ips=len(final_blocked_ips), + blocked_targets=len(final_blocked_targets), + blocked_ipv4=blocked_ipv4, + blocked_ipv6=blocked_ipv6, + blocklist_v4_path=blocklist_v4_path, + blocklist_v6_path=blocklist_v6_path, + firewall_connected=firewall is not None, + firewall_changes=firewall_changes, + errors=errors, + ) + + +def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Consolidated IDS Scanner: run copyparty + NPM analyzers and generate unified blocklist." ) @@ -63,109 +241,26 @@ def main() -> None: help="synchronize the MikroTik Blocked list after scanning") parser.add_argument("--dry-run", action="store_true", help="preview firewall changes without applying them") + return parser - args = parser.parse_args() - # --- Step 1-3: connect to MikroTik RouterOS and capture current state --- - # If the router is unreachable we fall back to scan-only mode: still - # generate reports and blocklists, but skip the firewall sync. - firewall: MikrotikFirewall | None = None - if args.update_firewall: - firewall = MikrotikFirewall() - try: - firewall.connect() - # Read current firewall rules + address lists and snapshot them to - # reports/mikrotik/data/ and the git-diffable data/mikrotik/. - firewall.save_state_report(args.report_dir) - except Exception as e: - print(f"\nWARNING: could not read MikroTik router " - f"({firewall.router_ip}): {e}") - firewall.disconnect() - firewall = None - - # --- Step 4: run copyparty + NPM analyzers -> blocklist candidates ------ - cp_analyzer = CopypartyAnalyzer( - log_path=args.copyparty_log_path, - report_dir=args.copyparty_report_dir, - top=args.top, - quiet=args.quiet, +def main(argv: list[str] | None = None) -> None: + args = _build_parser().parse_args(argv) + params = ScanParams( + copyparty_log_path=args.copyparty_log_path, + copyparty_report_dir=args.copyparty_report_dir, min_denied=args.min_denied, - ) - - npm_analyzer = NpmAnalyzer( - log_path=args.npm_log_path, - report_dir=args.npm_report_dir, - top=args.top, - quiet=args.quiet, + npm_log_path=args.npm_log_path, + npm_report_dir=args.npm_report_dir, min_errors=args.min_errors, error_ratio=args.error_ratio, + report_dir=args.report_dir, + top=args.top, + quiet=args.quiet, + update_firewall=args.update_firewall, + dry_run=args.dry_run, ) - - print("=== Running copyparty scan ===") - cp_exists = True - try: - cp_analyzer.run() - except Exception as e: - print(f"Error running copyparty scan: {e}") - cp_exists = False - - print("\n=== Running NPM scan ===") - npm_exists = True - try: - npm_analyzer.run() - except Exception as e: - print(f"Error running NPM scan: {e}") - npm_exists = False - - if not cp_exists and not npm_exists: - print("\nBoth scans failed. Unable to generate consolidated blocklist.") - if firewall is not None: - firewall.disconnect() - return - - # Consolidate block candidates - consolidated_candidates: set[str] = set() - if cp_exists: - consolidated_candidates.update(cp_analyzer.block_candidates()) - if npm_exists: - consolidated_candidates.update(npm_analyzer.block_candidates()) - - # Exclude whitelisted IPs - final_blocked_ips = { - ip for ip in consolidated_candidates if not is_whitelisted(ip)} - - # Group into subnets via WHOIS if multiple malicious IPs in same subnet - print("\nGrouping blocked IPs into subnets using WHOIS...") - final_blocked_targets = consolidate_by_subnet( - final_blocked_ips, threshold=SUBNET_THRESHOLD) - blocked_ipv4, blocked_ipv6 = split_cidr_versions(final_blocked_targets) - - print("\n=== Consolidated Summary ===") - print( - f"copyparty block candidates: {len(cp_analyzer.block_candidates()) if cp_exists else 0}") - print( - f"NPM block candidates: {len(npm_analyzer.block_candidates()) if npm_exists else 0}") - print(f"Total unified blocked IPs: {len(final_blocked_ips)}") - print(f"Total target blocks/subnets: {len(final_blocked_targets)}") - - # --- Step 6: write consolidated blocklists to reports/app/data ---------- - blocklist_v4_path, blocklist_v6_path = write_blocklist_files( - blocked_ipv4, blocked_ipv6, args.report_dir) - print(f"blocklist files:\n {blocklist_v4_path}\n {blocklist_v6_path}") - - # --- Steps 5+7: compare current state with candidates and apply --------- - if firewall is not None: - try: - changes = firewall.sync_blocklist( - blocked_ipv4, blocked_ipv6, dry_run=args.dry_run, - report_dir=args.report_dir) - finally: - firewall.disconnect() - action = "Would" if args.dry_run else "" - print(f"{action} add {len(changes['added_ipv4'])} IPv4 and " - f"{len(changes['added_ipv6'])} IPv6 addresses; " - f"remove {len(changes['removed_ipv4'])} IPv4 and " - f"{len(changes['removed_ipv6'])} IPv6 addresses.") + run_scan(params) if __name__ == "__main__": diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..a1d5aa8 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,77 @@ +// Shared helpers for the Intrusion Detection web UI. + +async function api(path, options = {}) { + const opts = { headers: {}, ...options }; + if (opts.body && typeof opts.body !== "string") { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(opts.body); + } + const res = await fetch(path, opts); + let data = null; + try { + data = await res.json(); + } catch (e) { + /* non-JSON */ + } + if (!res.ok) { + const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`; + throw new Error(msg); + } + return data; +} + +function escapeHtml(s) { + return String(s ?? "").replace( + /[&<>"']/g, + (c) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c], + ); +} + +function toast(message, type = "info") { + const box = document.getElementById("toasts"); + if (!box) return; + const node = document.createElement("div"); + node.className = `toast toast-${type}`; + node.textContent = message; + box.appendChild(node); + setTimeout(() => node.remove(), 4000); +} + +function fmtTime(s) { + if (!s) return "—"; + const d = new Date(s); + return isNaN(d) ? s : d.toLocaleString(); +} + +function badge(state) { + const node = document.createElement("span"); + node.className = `badge ${state}`; + node.textContent = state; + return node; +} + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; + else if (k === "html") node.innerHTML = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + } + for (const c of children) { + node.append(c == null ? "" : c); + } + return node; +} + +async function startScan(kind, params) { + return api(`/api/scan/${kind}`, { method: "POST", body: params }); +} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..02f82fe --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,199 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --surface-2: #263449; + --border: #334155; + --text: #e2e8f0; + --muted: #94a3b8; + --accent: #22d3ee; + --ok: #34d399; + --warn: #fbbf24; + --danger: #f87171; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.topbar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: .75rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { font-weight: 700; letter-spacing: .02em; white-space: nowrap; } + +.topbar nav { display: flex; gap: .25rem; flex-wrap: wrap; } + +.topbar nav a { + color: var(--muted); + text-decoration: none; + padding: .4rem .75rem; + border-radius: 8px; + font-size: .9rem; +} + +.topbar nav a:hover { color: var(--text); background: var(--surface-2); } +.topbar nav a.active { color: var(--accent); background: var(--surface-2); } + +.container { max-width: 1100px; margin: 0 auto; padding: 1.5rem; } + +h1 { font-size: 1.4rem; margin: 0 0 .25rem; } +h2 { font-size: 1.05rem; margin: 0; } +.muted { color: var(--muted); } +.mono { font-family: var(--mono); font-size: .85rem; } + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.25rem; + margin: 1rem 0; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: .75rem; +} + +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin: 1rem 0; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.25rem; +} + +.card-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } +.card-value { font-size: 1.5rem; font-weight: 700; margin-top: .25rem; } +.card-value.ok { color: var(--ok); } +.card-value.warn { color: var(--warn); } +.card-value.danger { color: var(--danger); } +.card-value.muted { color: var(--muted); } + +.table { width: 100%; border-collapse: collapse; font-size: .88rem; } + +.table th { + text-align: left; + color: var(--muted); + font-weight: 600; + border-bottom: 1px solid var(--border); + padding: .5rem .6rem; + font-size: .78rem; + text-transform: uppercase; + letter-spacing: .04em; +} + +.table td { padding: .5rem .6rem; border-bottom: 1px solid var(--border); } +.table tr:last-child td { border-bottom: none; } +.table .num { text-align: right; font-variant-numeric: tabular-nums; } + +.badge { + display: inline-block; + padding: .1rem .5rem; + border-radius: 999px; + font-size: .72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .03em; + background: var(--surface-2); + color: var(--muted); +} + +.badge.ok, .badge.done { background: rgba(52, 211, 153, .15); color: var(--ok); } +.badge.warn, .badge.running, .badge.queued { background: rgba(251, 191, 36, .15); color: var(--warn); } +.badge.danger, .badge.error { background: rgba(248, 113, 113, .15); color: var(--danger); } + +.btn { + display: inline-flex; + align-items: center; + gap: .4rem; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + padding: .45rem .9rem; + border-radius: 8px; + font-size: .88rem; + cursor: pointer; + text-decoration: none; +} + +.btn:hover { border-color: var(--accent); } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #062a35; font-weight: 600; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.danger { border-color: rgba(248, 113, 113, .5); color: var(--danger); } +.btn.danger:hover { background: rgba(248, 113, 113, .12); } +.btn.ghost { background: transparent; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +.panel-actions { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; flex-wrap: wrap; } + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +label { display: flex; flex-direction: column; gap: .3rem; font-size: .82rem; color: var(--muted); } +label.check { flex-direction: row; align-items: center; gap: .5rem; font-size: .88rem; color: var(--text); margin-top: 1.4rem; } + +input, select { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: .45rem .6rem; + font-size: .88rem; +} + +input:focus, select:focus { outline: none; border-color: var(--accent); } + +.job-status { margin-top: 1rem; font-family: var(--mono); font-size: .85rem; } + +#toasts { position: fixed; bottom: 1rem; right: 1rem; display: flex; flex-direction: column; gap: .5rem; z-index: 100; } + +.toast { + padding: .6rem 1rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + box-shadow: 0 6px 20px rgba(0, 0, 0, .4); + font-size: .88rem; + max-width: 360px; +} + +.toast-error { border-color: var(--danger); color: var(--danger); } +.toast-ok { border-color: var(--ok); color: var(--ok); } +.toast-warn { border-color: var(--warn); color: var(--warn); } + +pre.content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + overflow: auto; + max-height: 480px; + font-family: var(--mono); + font-size: .8rem; + white-space: pre-wrap; +} + +.list { display: flex; flex-direction: column; gap: .25rem; max-height: 300px; overflow: auto; padding: .25rem; } +.list a { color: var(--accent); text-decoration: none; font-family: var(--mono); font-size: .85rem; padding: .2rem .4rem; border-radius: 6px; } +.list a:hover { background: var(--surface-2); } diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..beb3f49 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,45 @@ + + + + + + {% block title %}Intrusion Detection{% endblock %} + + + +
+
🛡 Intrusion Detection
+ +
+
{% block content %}{% endblock %}
+
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/blocklist.html b/app/templates/blocklist.html new file mode 100644 index 0000000..1d5ba8f --- /dev/null +++ b/app/templates/blocklist.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} {% block title %}Blocklist — Intrusion Detection{% +endblock %} {% block content %} +

Blocklist

+

+ Current consolidated blocklist (from the latest scan). Adding/removing touches + the MikroTik router. +

+ +
+

Add / remove entry

+
+ + + +
+
+ +
+
+

IPv4

+
+ + + + + + + + +
Address
+
+ +
+
+

IPv6

+
+ + + + + + + + +
Address
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..f93d8b3 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} {% block title %}Dashboard — Intrusion Detection{% +endblock %} {% block content %} +

Dashboard

+

+ Localhost-only control panel for the intrusion detection scanner. +

+ +
+ +
+
+

Latest scans

+ Run a scan → +
+ + + + + + + + + + + + +
JobKindStateStartedFinishedBlocked
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/app/templates/firewall.html b/app/templates/firewall.html new file mode 100644 index 0000000..e1deb24 --- /dev/null +++ b/app/templates/firewall.html @@ -0,0 +1,176 @@ +{% extends "base.html" %} {% block title %}Firewall — Intrusion Detection{% +endblock %} {% block content %} +

Firewall

+

+ Current MikroTik state (from the latest data/mikrotik snapshot). + Sync previews or applies the blocklist diff. +

+ +
+
+ + + +
+
+ +
+

Address lists

+
+
+ +
+

Firewall rules

+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/app/templates/reports.html b/app/templates/reports.html new file mode 100644 index 0000000..5d6e0be --- /dev/null +++ b/app/templates/reports.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} {% block title %}Reports — Intrusion Detection{% +endblock %} {% block content %} +

Reports

+

Browse the timestamped reports written by each scanner.

+ +
+
+ + +
+
+
+ +
+

Report content

+
+Select a report on the left to view it.
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/app/templates/scan.html b/app/templates/scan.html new file mode 100644 index 0000000..4908c18 --- /dev/null +++ b/app/templates/scan.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} {% block title %}Scan — Intrusion Detection{% endblock +%} {% block content %} +

Run a scan

+

+ Scans run in the background; only one at a time. You can leave this page and + come back. +

+ +
+
+ + + + + + + +
+
+ + +
+
+
+ +
+

Recent jobs

+ + + + + + + + + + + + +
JobKindStateStartedFinishedBlocked
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/app/templates/whitelist.html b/app/templates/whitelist.html new file mode 100644 index 0000000..c52a41b --- /dev/null +++ b/app/templates/whitelist.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} {% block title %}Whitelist — Intrusion Detection{% +endblock %} {% block content %} +

Whitelist

+

+ Provider whitelists (from the router snapshot). Never add a whole cloud + provider to the blocklist. +

+ +
+

Add / remove CIDR

+
+ + + + +
+
+ +
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/app/web.py b/app/web.py new file mode 100644 index 0000000..b4dd56d --- /dev/null +++ b/app/web.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Flask web UI for the intrusion-detection system. + +A localhost-only web server (``127.0.0.1:``) for running scans, +browsing reports, and managing the MikroTik blocklist / firewall. + +Run directly: ``python -m app.web`` +Or via the entry point: ``python app.py --web`` (see ``serve()``). +""" + +from __future__ import annotations + +import json +import os + +from flask import Flask, abort, jsonify, render_template, request + +from .constants import ( + APP_REPORT_DATA_DIR, + APP_REPORT_DIR, + BLOCKLIST_NAME, + COPYPARTY_LOG_PATH, + COPYPARTY_REPORT_DIR, + DEFAULT_ERROR_RATIO, + DEFAULT_MIN_DENIED, + DEFAULT_MIN_ERRORS, + DEFAULT_TOP, + MIKROTIK_DATA_DIR, + MIKROTIK_REPORT_DIR, + NPM_LOG_PATH, + NPM_REPORT_DIR, + REPORT_DIR, + WEB_HOST, + WEB_PORT, + WHITELIST_NAMES, +) +from .firewall import MikrotikFirewall +from .ip_utils import ip_version +from .scan import ScanParams +from . import web_scan + +#: report sources exposed by the API -> report directory +REPORT_SOURCES = { + "copyparty": COPYPARTY_REPORT_DIR, + "npm": NPM_REPORT_DIR, + "app": APP_REPORT_DIR, + "mikrotik": MIKROTIK_REPORT_DIR, +} + +SCAN_KINDS = ("copyparty", "npm", "consolidated") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _count_lines(path: str) -> int | None: + """Return the line count of a file, or None if it cannot be read.""" + try: + with open(path, "r", encoding="utf-8") as f: + return sum(1 for _ in f) + except OSError: + return None + + +def _read_lines(path: str) -> list[str]: + """Return non-empty lines of a file, or [] if it cannot be read.""" + try: + with open(path, "r", encoding="utf-8") as f: + return [line.strip() for line in f if line.strip()] + except OSError: + return [] + + +def _read_json(path: str, default: list | dict | None = None) -> list | dict: + """Read a JSON file, returning ``default`` (or []) on failure.""" + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return default if default is not None else [] + + +def _params_from_request() -> ScanParams: + """Build ScanParams from the JSON request body, falling back to defaults.""" + data = request.get_json(silent=True) or {} + + def _pick(key: str, default): + value = data.get(key) + return default if value is None or value == "" else value + + return ScanParams( + copyparty_log_path=str( + _pick("copyparty_log_path", COPYPARTY_LOG_PATH)), + copyparty_report_dir=str( + _pick("copyparty_report_dir", COPYPARTY_REPORT_DIR)), + min_denied=int(_pick("min_denied", DEFAULT_MIN_DENIED)), + npm_log_path=str(_pick("npm_log_path", NPM_LOG_PATH)), + npm_report_dir=str(_pick("npm_report_dir", NPM_REPORT_DIR)), + min_errors=int(_pick("min_errors", DEFAULT_MIN_ERRORS)), + error_ratio=float(_pick("error_ratio", DEFAULT_ERROR_RATIO)), + report_dir=str(_pick("report_dir", REPORT_DIR)), + top=int(_pick("top", DEFAULT_TOP)), + quiet=bool(data.get("quiet", False)), + update_firewall=bool(data.get("update_firewall", False)), + dry_run=bool(data.get("dry_run", False)), + ) + + +def _with_router(fn): + """Connect to the router, run ``fn(fw)``, always disconnect. + + Returns a JSON error response (502) if the router is unreachable. + """ + fw = MikrotikFirewall() + try: + fw.connect() + return fn(fw) + except Exception as e: + return jsonify({"error": str(e)}), 502 + finally: + fw.disconnect() + + +def _sync_firewall(dry_run: bool): + """Sync the latest consolidated blocklist to the router (preview or apply).""" + v4 = _read_lines(os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")) + v6 = _read_lines(os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")) + + def _sync(fw): + changes = fw.sync_blocklist(v4, v6, dry_run=dry_run) + return jsonify({**changes, "dry_run": dry_run}) + + return _with_router(_sync) + + +# --------------------------------------------------------------------------- +# app factory +# --------------------------------------------------------------------------- + +def create_app() -> Flask: + """Build and configure the Flask application.""" + app = Flask(__name__) + + @app.get("/") + def index(): + return render_template("dashboard.html") + + @app.get("/scan") + def scan_page(): + return render_template("scan.html") + + @app.get("/reports") + def reports_page(): + return render_template("reports.html") + + @app.get("/blocklist") + def blocklist_page(): + return render_template("blocklist.html") + + @app.get("/firewall") + def firewall_page(): + return render_template("firewall.html") + + @app.get("/whitelist") + def whitelist_page(): + return render_template("whitelist.html", whitelist_names=WHITELIST_NAMES) + + # --- status ------------------------------------------------------------ + + @app.get("/api/status") + def status(): + v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt") + v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt") + return jsonify({ + "host": WEB_HOST, + "port": WEB_PORT, + "scan_running": web_scan.is_scan_running(), + "latest_scans": web_scan.latest_scans(5), + "blocklist_ipv4_count": _count_lines(v4_path), + "blocklist_ipv6_count": _count_lines(v6_path), + "firewall_snapshot": { + "exists": os.path.isdir(MIKROTIK_DATA_DIR), + "path": MIKROTIK_DATA_DIR, + }, + }) + + # --- scans ------------------------------------------------------------- + + @app.post("/api/scan/") + def start_scan(kind): + if kind not in SCAN_KINDS: + abort(404, f"unknown scan kind: {kind}") + job = web_scan.start_scan(kind, _params_from_request()) + # A busy-lock rejection is a bare {"error": ...}; a started job has + # "error" == None, so test for a truthy value, not key presence. + if job.get("error"): + return jsonify(job), 409 + return jsonify(job), 202 + + @app.get("/api/scan/status") + def scan_status(): + return jsonify({ + "running": web_scan.is_scan_running(), + "latest_scans": web_scan.latest_scans(10), + }) + + @app.get("/api/scan/") + def scan_job(job_id): + job = web_scan.get_scan(job_id) + if job is None: + abort(404, f"unknown job: {job_id}") + return jsonify(job) + + # --- reports ----------------------------------------------------------- + + @app.get("/api/reports/") + def list_reports(source): + base = REPORT_SOURCES.get(source) + if base is None: + abort(404, f"unknown report source: {source}") + names = sorted( + n for n in os.listdir(base) + if os.path.isfile(os.path.join(base, n))) + return jsonify({"source": source, "files": names}) + + @app.get("/api/reports//") + def get_report(source, name): + base = REPORT_SOURCES.get(source) + if base is None: + abort(404, f"unknown report source: {source}") + full = os.path.realpath(os.path.join(base, name)) + if not full.startswith(os.path.realpath(base)): + abort(403, "invalid report path") + if not os.path.isfile(full): + abort(404, "report not found") + with open(full, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + return jsonify({"source": source, "name": name, "content": content}) + + # --- blocklist --------------------------------------------------------- + + @app.get("/api/blocklist") + def blocklist(): + return jsonify({ + "ipv4": _read_lines( + os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")), + "ipv6": _read_lines( + os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")), + }) + + @app.post("/api/blocklist/add") + def blocklist_add(): + data = request.get_json(silent=True) or {} + ip = (data.get("ip") or "").strip() + version = ip_version(ip) + if not ip or version not in (4, 6): + return jsonify({"error": "missing or invalid 'ip' field"}), 400 + + def _add(fw): + if version == 4: + fw.write_address_list_ipv4(BLOCKLIST_NAME, [ip]) + else: + fw.write_address_list_ipv6(BLOCKLIST_NAME, [ip]) + return jsonify({"ok": True, "action": "add", "ip": ip}) + + return _with_router(_add) + + @app.post("/api/blocklist/remove") + def blocklist_remove(): + data = request.get_json(silent=True) or {} + ip = (data.get("ip") or "").strip() + version = ip_version(ip) + if not ip or version not in (4, 6): + return jsonify({"error": "missing or invalid 'ip' field"}), 400 + + def _remove(fw): + path = "/ip" if version == 4 else "/ipv6" + fw.execute_command( + f"{path} firewall address-list remove " + f"[find list={BLOCKLIST_NAME} address={ip}]") + return jsonify({"ok": True, "action": "remove", "ip": ip}) + + return _with_router(_remove) + + # --- firewall ---------------------------------------------------------- + + @app.get("/api/firewall/state") + def firewall_state(): + return jsonify({ + "address_lists": { + "ipv4": _read_json( + os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json")), + "ipv6": _read_json( + os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json")), + }, + "firewall_rules": { + "ipv4": _read_json( + os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv4.json")), + "ipv6": _read_json( + os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv6.json")), + }, + "snapshot_dir": MIKROTIK_DATA_DIR, + }) + + @app.post("/api/firewall/sync") + def firewall_sync(): + """Preview the blocklist sync (dry-run, no changes applied).""" + return _sync_firewall(dry_run=True) + + @app.post("/api/firewall/apply") + def firewall_apply(): + """Apply the blocklist sync (add new / remove stale entries).""" + return _sync_firewall(dry_run=False) + + # --- whitelist --------------------------------------------------------- + + @app.get("/api/whitelist") + def whitelist(): + addr_v4 = _read_json( + os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json")) + addr_v6 = _read_json( + os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json")) + result: dict[str, dict[str, list[str]]] = {} + for name in WHITELIST_NAMES: + result[name] = { + "ipv4": sorted(e.get("address") for e in addr_v4 + if e.get("list") == name), + "ipv6": sorted(e.get("address") for e in addr_v6 + if e.get("list") == name), + } + return jsonify({"names": WHITELIST_NAMES, "whitelists": result}) + + @app.post("/api/whitelist/update") + def whitelist_update(): + data = request.get_json(silent=True) or {} + name = (data.get("name") or "").strip() + address = (data.get("address") or "").strip() + action = (data.get("action") or "add").strip().lower() + version = ip_version(address) + + if name not in WHITELIST_NAMES: + return jsonify({"error": f"unknown whitelist: {name}"}), 400 + if not address or version not in (4, 6): + return jsonify({"error": "missing or invalid 'address' field"}), 400 + if action not in ("add", "remove"): + return jsonify({"error": "action must be 'add' or 'remove'"}), 400 + + def _update(fw): + path = "/ip" if version == 4 else "/ipv6" + if action == "add": + if version == 4: + fw.write_address_list_ipv4(name, [address]) + else: + fw.write_address_list_ipv6(name, [address]) + else: + fw.execute_command( + f"{path} firewall address-list remove " + f"[find list={name} address={address}]") + return jsonify({"ok": True, "action": action, + "name": name, "address": address}) + + return _with_router(_update) + + return app + + +def serve(host: str = WEB_HOST, port: int = WEB_PORT) -> None: + """Run the web server on localhost at the configured high port.""" + app = create_app() + print(f"Intrusion Detection web UI running at http://{host}:{port}") + app.run(host=host, port=port, use_reloader=False) + + +if __name__ == "__main__": + serve() diff --git a/app/web_scan.py b/app/web_scan.py new file mode 100644 index 0000000..804321d --- /dev/null +++ b/app/web_scan.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Background scan runner for the web UI. + +Runs scans in a daemon thread so HTTP requests never block, keeps an +in-memory job store, and serializes scans with a busy lock (WHOIS and +whitelist refresh must not run concurrently). +""" + +from __future__ import annotations + +import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal + +from .copyparty import CopypartyAnalyzer +from .npm import NpmAnalyzer +from .scan import ScanParams, ScanResult, run_scan + +ScanKind = Literal["copyparty", "npm", "consolidated"] + +#: serializes scans — only one runs at a time +_scan_lock = threading.Lock() + +#: in-memory job store (job_id -> ScanJob) +_jobs: dict[str, "ScanJob"] = {} +_jobs_lock = threading.Lock() + + +def _now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +@dataclass +class ScanJob: + """One background scan job.""" + + job_id: str + kind: ScanKind + params: ScanParams + state: str = "queued" # queued | running | done | error + started: str | None = None + finished: str | None = None + result: ScanResult | None = None + error: str | None = None + busy: bool = False + details: dict = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + result = self.result + return { + "job_id": self.job_id, + "kind": self.kind, + "state": self.state, + "started": self.started, + "finished": self.finished, + "error": self.error, + "busy": self.busy, + "details": self.details, + "result": None if result is None else { + "ok": result.ok, + "cp_exists": result.cp_exists, + "npm_exists": result.npm_exists, + "cp_candidates": result.cp_candidates, + "npm_candidates": result.npm_candidates, + "blocked_ips": result.blocked_ips, + "blocked_targets": result.blocked_targets, + "blocklist_v4_path": result.blocklist_v4_path, + "blocklist_v6_path": result.blocklist_v6_path, + "firewall_connected": result.firewall_connected, + "firewall_changes": result.firewall_changes, + "errors": result.errors, + }, + } + + +def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]: + """Start a scan in the background and return its job dict. + + If another scan is already running, returns a dict with an ``error`` key. + """ + if not _scan_lock.acquire(blocking=False): + return {"error": "a scan is already running; try again when it finishes"} + + job = ScanJob(job_id=uuid.uuid4().hex[:12], kind=kind, + params=params, busy=True) + with _jobs_lock: + _jobs[job.job_id] = job + + def _run() -> None: + job.started = _now() + job.state = "running" + try: + if kind == "consolidated": + job.result = run_scan(params) + elif kind == "copyparty": + info = CopypartyAnalyzer( + log_path=params.copyparty_log_path, + report_dir=params.copyparty_report_dir, + top=params.top, + quiet=params.quiet, + min_denied=params.min_denied, + ).run() + job.result = ScanResult(ok=True) + job.details = {"report": info.get("report"), + "data_dir": info.get("data_dir")} + elif kind == "npm": + info = NpmAnalyzer( + log_path=params.npm_log_path, + report_dir=params.npm_report_dir, + top=params.top, + quiet=params.quiet, + min_errors=params.min_errors, + error_ratio=params.error_ratio, + ).run() + job.result = ScanResult(ok=True) + job.details = {"report": info.get("report"), + "data_dir": info.get("data_dir")} + else: # pragma: no cover - guarded by type hint + raise ValueError(f"unknown scan kind: {kind}") + job.state = "done" + except Exception as e: + job.error = str(e) + job.state = "error" + finally: + job.finished = _now() + job.busy = False + _scan_lock.release() + + threading.Thread(target=_run, daemon=True, + name=f"scan-{job.job_id}").start() + return job.to_dict() + + +def get_scan(job_id: str) -> dict[str, Any] | None: + """Return a single job as a dict, or None if unknown.""" + with _jobs_lock: + job = _jobs.get(job_id) + return job.to_dict() if job else None + + +def latest_scans(limit: int = 10) -> list[dict[str, Any]]: + """Return the most recently started jobs, newest first.""" + with _jobs_lock: + jobs = sorted(_jobs.values(), + key=lambda j: j.started or "", reverse=True) + return [j.to_dict() for j in jobs[:limit]] + + +def is_scan_running() -> bool: + """True if any job currently holds the scan lock.""" + return _scan_lock.locked() diff --git a/data/whois_cache.json b/data/whois_cache.json index 26247e3..8e03677 100644 --- a/data/whois_cache.json +++ b/data/whois_cache.json @@ -964,5 +964,7 @@ "74.7.241.46": "74.7.0.0/16", "216.73.216.67": "216.73.216.0/22", "185.198.240.238": "185.198.240.0/24", - "34.125.213.146": "34.125.208.0/20" + "34.125.213.146": "34.125.208.0/20", + "222.176.200.21": "222.176.0.0/13", + "106.117.109.102": "106.117.0.0/16" } \ No newline at end of file diff --git a/reports/app/data/blocklist_ipv4.txt b/reports/app/data/blocklist_ipv4.txt index 24f0c45..a4de749 100644 --- a/reports/app/data/blocklist_ipv4.txt +++ b/reports/app/data/blocklist_ipv4.txt @@ -24,6 +24,7 @@ 104.28.247.220/32 104.28.254.131/32 104.28.254.134/32 +106.117.109.102/32 106.63.0.0/17 107.170.9.103/32 108.130.204.104/32 @@ -298,6 +299,7 @@ 217.217.252.197/32 220.202.112.100/32 221.13.93.71/32 +222.176.200.21/32 222.176.200.251/32 223.199.187.171/32 23.102.123.231/32 diff --git a/requirements.txt b/requirements.txt index 3a44a23..92377ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ paramiko -python-dotenv \ No newline at end of file +python-dotenv +flask \ No newline at end of file