Compare commits
36
Commits
94f62ae591
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2e479958 | ||
|
|
5967a43eb5 | ||
|
|
a9a03c1347 | ||
|
|
03f3c65a58 | ||
|
|
5ece4cd113 | ||
|
|
714aaf901e | ||
|
|
6051d2b548 | ||
|
|
2fb4032766 | ||
|
|
ba48054293 | ||
|
|
4a30f6b582 | ||
|
|
1e80be746e | ||
|
|
5d81f617f0 | ||
|
|
4960eef312 | ||
|
|
aef132fe94 | ||
|
|
7f392ef908 | ||
|
|
6036fa8b1e | ||
|
|
0137264fe4 | ||
|
|
4ba40dfef2 | ||
|
|
40dc4237c3 | ||
|
|
880877ee0f | ||
|
|
2fda2d817f | ||
|
|
dca47e81ee | ||
|
|
55dad05652 | ||
|
|
c2eba88ac8 | ||
|
|
0d1cd21468 | ||
|
|
0d07cc5377 | ||
|
|
57179d4aab | ||
|
|
236e203baa | ||
|
|
af5da6c332 | ||
|
|
67576e3c69 | ||
|
|
9f8ab0f61c | ||
|
|
41230ce04c | ||
|
|
80a67635de | ||
|
|
48c5629b4b | ||
|
|
894a2eec33 | ||
|
|
c131759a7c |
No files matched your search
@@ -0,0 +1,63 @@
|
|||||||
|
# https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Common settings – apply to every file
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Python (*.py)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.py]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 88 # matches Black formatter default line length
|
||||||
|
# Optional: enforce a newline at the end of the file (already set globally)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{js,jsx,ts,tsx}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# JSON (*.json, *.jsonc)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.json]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = off # JSON is often compact; do not enforce length
|
||||||
|
trim_trailing_whitespace = false # JSON parsers ignore trailing spaces
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# HTML (*.html, *.htm)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{html,htm}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# CSS & SCSS (*.css, *.scss)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{css,scss}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Markdown (*.md, *.markdown)
|
||||||
|
# -------------------------------------------------
|
||||||
|
[*.{md,markdown}]
|
||||||
|
max_line_length = off # Markdown lines can be long for readability
|
||||||
|
trim_trailing_whitespace = false # Preserve intentional spaces (e.g., line breaks)
|
||||||
+89
-75
@@ -1,76 +1,90 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Intrusion Detection System — environment template
|
# Intrusion Detection System — environment template
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Copy this file to `.env` for local development and fill in your values:
|
# Copy this file to `.env` for local development and fill in your values:
|
||||||
#
|
#
|
||||||
# copy .env.example .env
|
# copy .env.example .env
|
||||||
#
|
#
|
||||||
# Precedence: real system environment variables win over values in `.env`,
|
# Precedence: real system environment variables win over values in `.env`,
|
||||||
# so production deployments can set these via the OS and skip `.env`.
|
# so production deployments can set these via the OS and skip `.env`.
|
||||||
# `.env` is git-ignored — never commit real secrets to it.
|
# `.env` is git-ignored — never commit real secrets to it.
|
||||||
|
|
||||||
# --- Web server (localhost only) -------------------------------------------------
|
# --- Web server (localhost only) -------------------------------------------------
|
||||||
|
|
||||||
# The Flask web UI binds to localhost so it is only reachable from this machine.
|
# 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!
|
# Do not expose WEB_HOST beyond 127.0.0.1!
|
||||||
WEB_HOST=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.
|
# Please choose a very high port number (e.g. 54666) to avoid conflicts with other services.
|
||||||
WEB_PORT=54666
|
WEB_PORT=54666
|
||||||
|
|
||||||
# --- Log sources -----------------------------------------------------------
|
# --- Log sources -----------------------------------------------------------
|
||||||
|
|
||||||
# Directory containing copyparty access logs (YYYY-MM-DD.txt files).
|
# Directory containing copyparty access logs (YYYY-MM-DD.txt files).
|
||||||
COPYPARTY_LOG_PATH=P:\copyparty
|
COPYPARTY_LOG_PATH=P:\copyparty
|
||||||
|
|
||||||
# Directory containing Nginx Proxy Manager proxy logs (.log / .gz files).
|
# Directory containing Nginx Proxy Manager proxy logs (.log / .gz files).
|
||||||
NPM_LOG_PATH=Y:\npm\logs
|
NPM_LOG_PATH=Y:\npm\logs
|
||||||
|
|
||||||
# --- MikroTik RouterOS connection -----------------------------------------
|
# --- MikroTik RouterOS connection -----------------------------------------
|
||||||
|
|
||||||
# IP/hostname of the router to sync the blocklist to.
|
# IP/hostname of the router to sync the blocklist to.
|
||||||
ROUTER_IP=192.168.88.1
|
ROUTER_IP=192.168.88.1
|
||||||
|
|
||||||
# SSH user for the router.
|
# SSH user for the router.
|
||||||
ROUTER_USER=admin
|
ROUTER_USER=admin
|
||||||
|
|
||||||
# SSH password for the router (required when --update-firewall is used).
|
# SSH password for the router (required when --update-firewall is used).
|
||||||
ROUTER_PASSWORD=change-me
|
ROUTER_PASSWORD=change-me
|
||||||
|
|
||||||
# Address list on the router that holds blocked IPs/subnets.
|
# Address list on the router that holds blocked IPs/subnets.
|
||||||
BLOCKLIST_NAME=Blocked
|
BLOCKLIST_NAME=Blocked
|
||||||
|
|
||||||
# Comma-separated address lists that are treated as whitelisted (never blocked).
|
# Comma-separated address lists that are treated as whitelisted (never blocked).
|
||||||
WHITELIST_NAMES=Cloudflare,Openfactory
|
WHITELIST_NAMES=Cloudflare,Init7
|
||||||
|
|
||||||
# --- WHOIS subnet consolidation --------------------------------------------
|
# --- WHOIS subnet consolidation --------------------------------------------
|
||||||
|
|
||||||
WHOIS_SERVER=whois.radb.net
|
WHOIS_SERVER=whois.radb.net
|
||||||
WHOIS_PORT=43
|
WHOIS_PORT=43
|
||||||
WHOIS_TIMEOUT=3.0
|
WHOIS_TIMEOUT=3.0
|
||||||
|
|
||||||
# Minimum number of same-subnet IPs before blocking the whole subnet.
|
# Minimum number of same-subnet IPs before blocking the whole subnet.
|
||||||
SUBNET_THRESHOLD=3
|
SUBNET_THRESHOLD=3
|
||||||
|
|
||||||
# --- Scan / flagging defaults ----------------------------------------------
|
# --- Report retention ------------------------------------------------------
|
||||||
|
|
||||||
# Rows in the top-IP report tables.
|
# Each subdirectory under /reports/ keeps at most REPORT_RETENTION_MAX_FILES
|
||||||
DEFAULT_TOP=30
|
# files, all newer than REPORT_RETENTION_DAYS days. Older timestamped reports
|
||||||
|
# are pruned automatically after each consolidated scan. The blocklist files
|
||||||
# copyparty: minimum denied hits before flagging an IP.
|
# in reports/app/data/ are always protected.
|
||||||
DEFAULT_MIN_DENIED=1
|
REPORT_RETENTION_DAYS=30
|
||||||
|
REPORT_RETENTION_MAX_FILES=10
|
||||||
# npm: minimum error responses before flagging an IP.
|
|
||||||
DEFAULT_MIN_ERRORS=5
|
# --- SQLite store ---------------------------------------------------------
|
||||||
|
|
||||||
# npm: minimum error ratio before flagging an IP.
|
# Local SQLite database canonical store (logs, scans, blocklist/whitelist and
|
||||||
DEFAULT_ERROR_RATIO=0.5
|
# firewall history). Created automatically on first run. Default: data/ids.db
|
||||||
|
#IDS_DB_PATH=/absolute/path/to/ids.db
|
||||||
# Rows in the "top error paths" report section.
|
|
||||||
DEFAULT_TOP_PATHS=20
|
# --- Scan / flagging defaults ----------------------------------------------
|
||||||
|
|
||||||
# --- Whitelist sources -----------------------------------------------------
|
# Rows in the top-IP report tables.
|
||||||
|
DEFAULT_TOP=30
|
||||||
OPENFACTORY_URL=https://openfactory.net/geofeed.csv
|
|
||||||
CLOUDFLARE_URL_V4=https://www.cloudflare.com/ips-v4
|
# copyparty: minimum denied hits before flagging an IP.
|
||||||
CLOUDFLARE_URL_V6=https://www.cloudflare.com/ips-v6
|
DEFAULT_MIN_DENIED=1
|
||||||
|
|
||||||
|
# npm: minimum error responses before flagging an IP.
|
||||||
|
DEFAULT_MIN_ERRORS=5
|
||||||
|
|
||||||
|
# npm: minimum error ratio before flagging an IP.
|
||||||
|
DEFAULT_ERROR_RATIO=0.5
|
||||||
|
|
||||||
|
# Rows in the "top error paths" report section.
|
||||||
|
DEFAULT_TOP_PATHS=20
|
||||||
|
|
||||||
|
# --- Whitelist sources -----------------------------------------------------
|
||||||
|
|
||||||
|
CLOUDFLARE_URL_V4=https://www.cloudflare.com/ips-v4
|
||||||
|
CLOUDFLARE_URL_V6=https://www.cloudflare.com/ips-v6
|
||||||
PALOALTO_URL=https://docs-cortex.paloaltonetworks.com/r/1/Cortex-Xpanse/Scanning-activity
|
PALOALTO_URL=https://docs-cortex.paloaltonetworks.com/r/1/Cortex-Xpanse/Scanning-activity
|
||||||
+21
-6
@@ -1,16 +1,31 @@
|
|||||||
# instructions for agents
|
# instructions for agents
|
||||||
.github/instructions/
|
.github/instructions/
|
||||||
|
.agents
|
||||||
|
skills-lock.json
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# python cache
|
# python cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
# incremental-scan state (regenerated each run)
|
# python virtual environment
|
||||||
data/scan_state.json
|
.venv/
|
||||||
|
|
||||||
# reports data
|
# app runtime data (feeds, caches, scan state - regenerated each run).
|
||||||
reports/copyparty/
|
# All of this must stay UNTRACKED so ``git pull`` never conflicts with files a
|
||||||
reports/mikrotik/
|
# scan has written since the last pull (see .github/instructions/DEPLOYMENT.md).
|
||||||
reports/npm/
|
# The single allowed tracked seed is the read-only PaloAlto scanner-ips.txt
|
||||||
|
# (never rewritten at runtime); the Cloudflare ips-*.txt are runtime-refreshed
|
||||||
|
# downloads, so they are NOT tracked and are re-downloaded if absent.
|
||||||
|
app/data/*
|
||||||
|
!app/data/paloalto/scanner-ips.txt
|
||||||
|
|
||||||
|
# SQLite database (canonical store) + WAL/SHM sidecars
|
||||||
|
app/data/*.db
|
||||||
|
app/data/*.db-wal
|
||||||
|
app/data/*.db-shm
|
||||||
|
|
||||||
|
# generated reports
|
||||||
|
app/reports/
|
||||||
@@ -5,21 +5,16 @@ Local-development intrusion detection / log analysis for two log sources:
|
|||||||
| Script | Source | Detection rule |
|
| Script | Source | Detection rule |
|
||||||
| --------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
| --------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
| `app.py` | unified entry point | runs both analyzers + optional firewall sync |
|
| `app.py` | unified entry point | runs both analyzers + optional firewall sync |
|
||||||
| `app/scan.py` | consolidated scanner | runs both analyzers, merges blocklists, syncs firewall |
|
| `app/scan/` | consolidated scanner | runs both analyzers, merges blocklists, syncs firewall |
|
||||||
| `app/copyparty.py` | [copyparty](https://github.com/9001/copyparty) server logs | denied-only IPs + copyparty auto-bans |
|
| `app/copyparty.py` | [copyparty](https://github.com/9001/copyparty) server logs | denied-only IPs + copyparty auto-bans |
|
||||||
| `app/npm.py` | [Nginx Proxy Manager](https://nginxproxymanager.com) proxy logs | high error-rate IPs (scanners) |
|
| `app/npm.py` | [Nginx Proxy Manager](https://nginxproxymanager.com) proxy logs | high error-rate IPs (scanners) |
|
||||||
| `app/utils.py` | re-export layer | thin barrel — imports from sub-modules below |
|
| `app/utils/` | shared utilities | constants, I/O helpers, IP utils, models, reporting, whitelists, WHOIS |
|
||||||
| `app/models.py` | data model | `Request`, `IPStats`, `aggregate()` |
|
| `app/web/` | Flask web UI | app factory, routes, background scan runner |
|
||||||
| `app/ip_utils.py` | IP helpers | `parse_ip`, `whitelist_of`, `is_whitelisted`, `sort_ips`, `split_versions` |
|
|
||||||
| `app/flags.py` | detection rules | `flag_scanners`, `flag_denied_only`, `flag_malicious_patterns`, `by_total` |
|
|
||||||
| `app/analyzer.py` | pipeline | `Analyzer` — discover → load → analyze → report → write |
|
| `app/analyzer.py` | pipeline | `Analyzer` — discover → load → analyze → report → write |
|
||||||
| `app/whitelists.py` | whitelist module | provider CIDR constants, local/remote load, CIDR extraction |
|
| `app/flags.py` | detection rules | `flag_scanners`, `flag_denied_only`, `flag_malicious_patterns`, `by_total` |
|
||||||
| `app/bad_patterns.py` | pattern detection | malicious URL/file regex classification |
|
| `app/bad_patterns.py` | pattern detection | malicious URL/file regex classification |
|
||||||
| `app/whois.py` | WHOIS query | `query_whois_subnet`, `consolidate_by_subnet` |
|
|
||||||
| `app/mikrotik.py` | RouterOS export | `mikrotik_script`, `write_mikrotik`, `split_cidr_versions` |
|
| `app/mikrotik.py` | RouterOS export | `mikrotik_script`, `write_mikrotik`, `split_cidr_versions` |
|
||||||
| `app/firewall.py` | RouterOS SSH client | `MikrotikFirewall` — connect, sync blocklist, audit state |
|
| `app/firewall.py` | RouterOS SSH client | `MikrotikFirewall` — connect, sync blocklist, audit state |
|
||||||
| `app/files.py` | I/O helpers | `read_lines`, `find_logs`, `write_csv`, `write_json`, etc. |
|
|
||||||
| `app/reporting.py` | text rendering | `Column`, `render_table`, `render_kv`, `timestamp_name` |
|
|
||||||
|
|
||||||
Both per-source scripts parse logs, aggregate per-IP behaviour, split
|
Both per-source scripts parse logs, aggregate per-IP behaviour, split
|
||||||
IPv4/IPv6, skip whitelisted provider ranges, detect malicious path patterns,
|
IPv4/IPv6, skip whitelisted provider ranges, detect malicious path patterns,
|
||||||
@@ -40,34 +35,45 @@ intrusion-detection/
|
|||||||
├── app.py unified entry point
|
├── app.py unified entry point
|
||||||
├── app/ application package
|
├── app/ application package
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── constants.py all config constants (paths, env vars, thresholds)
|
|
||||||
│ ├── scan.py consolidated scanner
|
|
||||||
│ ├── copyparty.py copyparty log adapter (~150 lines)
|
|
||||||
│ ├── npm.py NPM proxy log adapter (~150 lines)
|
|
||||||
│ ├── firewall.py MikroTik RouterOS SSH client
|
|
||||||
│ ├── utils.py thin re-export barrel (~90 lines)
|
|
||||||
│ ├── models.py Request, IPStats, aggregate()
|
|
||||||
│ ├── ip_utils.py IP parsing, whitelist lookups, sorting
|
|
||||||
│ ├── flags.py flag_scanners, flag_denied_only, flag_malicious_patterns
|
|
||||||
│ ├── analyzer.py Analyzer pipeline base class
|
│ ├── analyzer.py Analyzer pipeline base class
|
||||||
│ ├── whitelists.py provider CIDR constants, local/remote load
|
|
||||||
│ ├── bad_patterns.py malicious URL/file regex patterns
|
│ ├── bad_patterns.py malicious URL/file regex patterns
|
||||||
│ ├── whois.py query_whois_subnet, consolidate_by_subnet
|
│ ├── copyparty.py copyparty log adapter (~150 lines)
|
||||||
|
│ ├── firewall.py MikroTik RouterOS SSH client
|
||||||
|
│ ├── flags.py flag_scanners, flag_denied_only, flag_malicious_patterns
|
||||||
│ ├── mikrotik.py RouterOS .rsc script generation
|
│ ├── mikrotik.py RouterOS .rsc script generation
|
||||||
│ ├── files.py read_lines, find_logs, write_csv/json/lines
|
│ ├── npm.py NPM proxy log adapter (~150 lines)
|
||||||
│ ├── reporting.py Column, render_table, render_kv, timestamp_name
|
│ ├── web/ Flask web UI
|
||||||
│ ├── web.py Flask web UI (app factory, JSON API, pages)
|
│ │ ├── __init__.py app factory + helpers + serve()
|
||||||
│ ├── web_scan.py background scan runner + in-memory job store
|
│ │ ├── routes.py all HTTP route definitions
|
||||||
│ ├── templates/ Jinja2 page templates (dashboard, scan, reports, …)
|
│ │ └── web_scan.py background scan runner + in-memory job store
|
||||||
│ └── static/ style.css + app.js (dark-theme UI)
|
│ ├── scan/ scan pipeline
|
||||||
|
│ │ ├── __init__.py consolidated scanner (run_scan, ScanParams, ScanResult)
|
||||||
|
│ │ └── 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
|
||||||
|
│ │ ├── constants.py all config constants (paths, env vars, thresholds)
|
||||||
|
│ │ ├── files.py read_lines, find_logs, write_csv/json/lines
|
||||||
|
│ │ ├── ip_utils.py IP parsing, whitelist lookups, sorting
|
||||||
|
│ │ ├── models.py Request, IPStats, aggregate()
|
||||||
|
│ │ ├── reporting.py Column, render_table, render_kv, timestamp_name
|
||||||
|
│ │ ├── utils.py shared CLI arg parser
|
||||||
|
│ │ ├── whitelists.py provider CIDR constants, local/remote load
|
||||||
|
│ │ ├── whois.py query_whois_subnet, consolidate_by_subnet
|
||||||
|
│ │ ├── blocklist_history.py git-backed cumulative blocklist
|
||||||
|
│ │ └── retention.py report file retention & pruning
|
||||||
|
│ ├── static/ style.css + app.js (dark-theme UI)
|
||||||
|
│ └── templates/ Jinja2 page templates (dashboard, scan, reports, …)
|
||||||
├── data/ whitelist source files (cached locally)
|
├── data/ whitelist source files (cached locally)
|
||||||
│ ├── openfactory/
|
│ ├── init7/ (ranges built into app/utils/whitelists.py — no geofeed)
|
||||||
│ │ └── geofeed.csv
|
|
||||||
│ ├── 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/
|
||||||
@@ -80,22 +86,24 @@ matcher, a line parser, a flagging rule, and column definitions.
|
|||||||
|
|
||||||
Core responsibilities by module:
|
Core responsibilities by module:
|
||||||
|
|
||||||
| Module | Owns |
|
| Module | Owns |
|
||||||
| --------------------- | -------------------------------------------------------------------------------- |
|
| -------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `app/constants.py` | all config constants — paths, env vars, network/scan thresholds |
|
| `app/utils/constants.py` | all config constants — paths, env vars, network/scan thresholds |
|
||||||
| `app/models.py` | `Request`, `IPStats`, `aggregate()` |
|
| `app/utils/models.py` | `Request`, `IPStats`, `aggregate()` |
|
||||||
| `app/ip_utils.py` | `parse_ip`, `whitelist_of`, `is_whitelisted`, `sort_ips`, `split_versions` |
|
| `app/utils/ip_utils.py` | `parse_ip`, `whitelist_of`, `is_whitelisted`, `sort_ips`, `split_versions` |
|
||||||
| `app/flags.py` | `flag_scanners`, `flag_denied_only`, `flag_malicious_patterns`, `by_total` |
|
| `app/flags.py` | `flag_scanners`, `flag_denied_only`, `flag_malicious_patterns`, `by_total` |
|
||||||
| `app/analyzer.py` | `Analyzer` — discover → load → analyze → report → write |
|
| `app/analyzer.py` | `Analyzer` — discover → load → analyze → report → write |
|
||||||
| `app/whitelists.py` | provider CIDR constants, local-file / remote / built-in loading |
|
| `app/utils/whitelists.py` | provider CIDR constants, local-file / remote / built-in loading |
|
||||||
| `app/bad_patterns.py` | malicious URL/file regex classification |
|
| `app/bad_patterns.py` | malicious URL/file regex classification |
|
||||||
| `app/whois.py` | `query_whois_subnet`, `consolidate_by_subnet` |
|
| `app/utils/whois.py` | `query_whois_subnet`, `consolidate_by_subnet` |
|
||||||
| `app/mikrotik.py` | `mikrotik_script`, `write_mikrotik`, `split_cidr_versions` |
|
| `app/mikrotik.py` | `mikrotik_script`, `write_mikrotik`, `split_cidr_versions` |
|
||||||
| `app/firewall.py` | `MikrotikFirewall` — SSH connect, sync blocklist, audit state |
|
| `app/firewall.py` | `MikrotikFirewall` — SSH connect, sync blocklist, audit state |
|
||||||
| `app/web.py` | Flask app factory, JSON API routes, page rendering |
|
| `app/web/` | Flask app factory, JSON API routes, page rendering, background scan runner |
|
||||||
| `app/web_scan.py` | background scan runner, in-memory job store, single-scan busy lock |
|
| `app/scan/` | consolidated scan pipeline, incremental scan state |
|
||||||
| `app/files.py` | `read_lines`, `find_logs`, `validate_dir`, `ensure_dirs`, `write_csv/json/lines` |
|
| `app/utils/files.py` | `read_lines`, `find_logs`, `validate_dir`, `ensure_dirs`, `write_csv/json/lines` |
|
||||||
| `app/reporting.py` | `Column`, `render_table`, `render_kv`, `timestamp_name` |
|
| `app/utils/reporting.py` | `Column`, `render_table`, `render_kv`, `timestamp_name` |
|
||||||
|
| `app/utils/blocklist_history.py` | git-backed cumulative blocklist reconstruction |
|
||||||
|
| `app/utils/retention.py` | report file retention & pruning |
|
||||||
|
|
||||||
Console output and saved reports are the **same string** (`Analyzer.text_report()`).
|
Console output and saved reports are the **same string** (`Analyzer.text_report()`).
|
||||||
|
|
||||||
@@ -131,13 +139,26 @@ production can set the same variables via the OS and skip `.env` entirely.
|
|||||||
| `ROUTER_USER` | `admin` | MikroTik connection |
|
| `ROUTER_USER` | `admin` | MikroTik connection |
|
||||||
| `ROUTER_PASSWORD` | _(set in .env)_ | MikroTik connection |
|
| `ROUTER_PASSWORD` | _(set in .env)_ | MikroTik connection |
|
||||||
| `BLOCKLIST_NAME` | `Blocked` | MikroTik address list |
|
| `BLOCKLIST_NAME` | `Blocked` | MikroTik address list |
|
||||||
| `WHITELIST_NAMES` | `Cloudflare,Openfactory` | MikroTik whitelists |
|
| `WHITELIST_NAMES` | `Cloudflare,Init7` | MikroTik whitelists |
|
||||||
| `WEB_HOST` | `127.0.0.1` | web UI bind host |
|
| `WEB_HOST` | `127.0.0.1` | web UI bind host |
|
||||||
| `WEB_PORT` | `54666` | web UI port |
|
| `WEB_PORT` | `54666` | web UI port |
|
||||||
|
|
||||||
See `.env.example` for the complete list of supported variables (WHOIS
|
See `.env.example` for the complete list of supported variables (WHOIS
|
||||||
tuning, scan thresholds, whitelist source URLs, and more).
|
tuning, scan thresholds, whitelist source URLs, and more).
|
||||||
|
|
||||||
|
### Network context (2026-09-08)
|
||||||
|
|
||||||
|
Connectivity provider switched Openfactory → Init7 on 2026-09-08:
|
||||||
|
|
||||||
|
- WAN IPv4: `77.74.80.159/32` (init7 `77.74.80.0/24`, "Hybrid7-NET").
|
||||||
|
- IPv6: delegated `/48` `2001:1620:549f::/48` (init7 allocation `2001:1620::/30`).
|
||||||
|
- Upstream DNS: `77.109.128.2`, `213.144.129.20`.
|
||||||
|
|
||||||
|
The `Openfactory` whitelist (old AS58299 geofeed) is obsolete and must be
|
||||||
|
replaced with an `Init7` whitelist — see
|
||||||
|
[`.github/instructions/TODO.md`](.github/instructions/TODO.md) for the
|
||||||
|
switchover plan and the verified init7 CIDR ranges.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -151,6 +172,11 @@ python app.py scan --quiet
|
|||||||
python app.py scan --update-firewall --dry-run
|
python app.py scan --update-firewall --dry-run
|
||||||
python app.py scan --update-firewall
|
python app.py scan --update-firewall
|
||||||
|
|
||||||
|
# detailed log-analysis report from the SQLite store (needs scans run first)
|
||||||
|
python app.py analyze # top IPs + cross-source + blocklist
|
||||||
|
python app.py analyze --ip 1.2.3.4 # drill into one IP's request history
|
||||||
|
python app.py analyze --json # machine-readable output
|
||||||
|
|
||||||
# individual scanners (still runnable directly)
|
# individual scanners (still runnable directly)
|
||||||
python -m app.copyparty
|
python -m app.copyparty
|
||||||
python -m app.npm
|
python -m app.npm
|
||||||
@@ -184,6 +210,49 @@ python -m app.copyparty --top 100
|
|||||||
python app.py scan --quiet --update-firewall --dry-run
|
python app.py scan --quiet --update-firewall --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Blocklist behavior (cumulative / grow-only)
|
||||||
|
|
||||||
|
The MikroTik `Blocked` address list is **cumulative**: it only grows over
|
||||||
|
time and is never trimmed automatically.
|
||||||
|
|
||||||
|
- An IP (or subnet) that the system has ever blocked stays blocked — even if
|
||||||
|
it stops appearing in recent logs (for example after it leaves the NPM
|
||||||
|
~30-day log-retention window, or once WHOIS subnet consolidation groups it
|
||||||
|
into a larger range).
|
||||||
|
- The **source of truth** for "all blocked IPs" is the **SQLite blocklist
|
||||||
|
ledger** (`app.db` → `blocklist_entries` / `blocklist_events`), not git
|
||||||
|
history. Each scan syncs the desired blocklist into the ledger; the router's
|
||||||
|
cumulative state is derived from it. `app/utils/blocklist_history.py` is a
|
||||||
|
read-only fallback only.
|
||||||
|
- The sync only ever **adds** entries. The only automatic removals are router
|
||||||
|
entries that were never blocked by the system — i.e. absent from the ledger
|
||||||
|
(manual / rogue entries).
|
||||||
|
- To explicitly unblock an IP, remove it manually via the web UI
|
||||||
|
(`/blocklist` → remove) or directly on the router; the automated sync will
|
||||||
|
not re-add entries that are not part of a scan.
|
||||||
|
|
||||||
|
> **Deployment note:** runtime data (blocklists, reports, the DB) is **never
|
||||||
|
> committed to git**. Keeping it untracked is what makes `git pull` updates
|
||||||
|
> conflict-free — see [`DEPLOYMENT.md`](.github/instructions/DEPLOYMENT.md)
|
||||||
|
> and the `deploy/` scripts.
|
||||||
|
|
||||||
|
Each consolidated scan with `--update-firewall` also writes a timestamped
|
||||||
|
change report to `reports/app/YYYY-MM-DD-hh-mm-ss.json`: the number and list
|
||||||
|
of entries added / removed (complete-set delta), the full cumulative blocked
|
||||||
|
list, and the router sync changes. It also persists a firewall snapshot into
|
||||||
|
the `firewall_snapshots` SQLite tables for long-term audit (F001).
|
||||||
|
|
||||||
|
### Report retention
|
||||||
|
|
||||||
|
Generated files under `reports/` are pruned automatically after each
|
||||||
|
consolidated scan: every subdirectory keeps at most
|
||||||
|
`REPORT_RETENTION_MAX_FILES` files (default 10), all newer than
|
||||||
|
`REPORT_RETENTION_DAYS` days (default 30). The blocklist files
|
||||||
|
(`reports/app/data/blocklist_ipv4.txt` / `blocklist_ipv6.txt`) are always
|
||||||
|
protected and never pruned. Tune via `REPORT_RETENTION_DAYS` /
|
||||||
|
`REPORT_RETENTION_MAX_FILES`; `--dry-run` previews the cleanup without
|
||||||
|
deleting.
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
`python app.py` starts a **localhost-only** Flask web UI (default
|
`python app.py` starts a **localhost-only** Flask web UI (default
|
||||||
@@ -201,6 +270,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**
|
||||||
@@ -209,6 +280,35 @@ from the UI — applying changes is an explicit action. The main JSON endpoints
|
|||||||
`/api/firewall/*`, `/api/whitelist`) are also usable directly from a REST
|
`/api/firewall/*`, `/api/whitelist`) are also usable directly from a REST
|
||||||
client.
|
client.
|
||||||
|
|
||||||
|
## Deployment & updating (`git pull`)
|
||||||
|
|
||||||
|
The app runs scans continuously (web UI + cron + CLI), and all of it is
|
||||||
|
intended to be updated on the server with a plain `git pull`. To make that
|
||||||
|
**safe** — i.e. never conflict with a file a scan wrote since the last pull —
|
||||||
|
runtime data is strictly **never committed to git**:
|
||||||
|
|
||||||
|
- `app/data/*`, `app/reports/*` and the SQLite DB (`app/data/ids.db`) are
|
||||||
|
gitignored. The only tracked file under `data/` is the read-only
|
||||||
|
`app/data/paloalto/scanner-ips.txt` seed.
|
||||||
|
- The Cloudflare `ips-*.txt` whitelist seeds are **not** tracked: they are
|
||||||
|
runtime-refreshed downloads and are re-fetched if absent (built-in ranges are
|
||||||
|
the fallback).
|
||||||
|
- History that must survive — the cumulative blocklist and firewall audit —
|
||||||
|
lives in the SQLite store, not in git revisions.
|
||||||
|
|
||||||
|
Update a deployed instance with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`DEPLOYMENT.md`](.github/instructions/DEPLOYMENT.md) for the full plan,
|
||||||
|
and `deploy/` for the scripts (`deploy.sh`, `restart-service.sh`,
|
||||||
|
`check_runtime_git.sh`, `ids.service` systemd template). The wrapper
|
||||||
|
fast-forwards only (`git pull --ff-only`), refuses to run on an unclean tree,
|
||||||
|
reinstalls deps if `requirements.txt` changed, applies DB migrations
|
||||||
|
(`python -m app.db --migrate`), and restarts the `ids` unit.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
Each script writes to its own report directory. `app.py` also writes
|
Each script writes to its own report directory. `app.py` also writes
|
||||||
@@ -259,9 +359,11 @@ reports/
|
|||||||
```
|
```
|
||||||
|
|
||||||
The current router state (address lists + firewall rules) is also mirrored to
|
The current router state (address lists + firewall rules) is also mirrored to
|
||||||
`data/mikrotik/` on every `--update-firewall` run. Those files are overwritten
|
`data/mikrotik/` on every `--update-firewall` run as a scratch pointer to the
|
||||||
each run and are **git-visible**, so `git diff` shows how the firewall rules
|
latest snapshot. Those files are overwritten each run and are **not** tracked;
|
||||||
and address lists change over time.
|
the long-term audit trail is the SQLite `firewall_snapshots` tables plus the
|
||||||
|
timestamped reports under `reports/mikrotik/`. `git pull` is therefore never
|
||||||
|
blocked by firewall-state files.
|
||||||
|
|
||||||
## Detection
|
## Detection
|
||||||
|
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# Report Inventory
|
||||||
|
|
||||||
|
This inventory records the current report surface before moving report reads
|
||||||
|
or writes into SQLite. Generated paths are rooted at `REPORT_DIR` (normally
|
||||||
|
`reports/`). The SQLite tables listed below already exist in schema v1 unless
|
||||||
|
noted otherwise.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
| Area | Producer and formats | Current role and consumers | Classification |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `copyparty/` | `Analyzer.write_report()` creates timestamped `.txt`; `write_data()` creates per-IP CSV/JSON, denied-path CSV, flagged/banned IP TXT, `requests.csv`, `authed_users.json`, and `mikrotik-block.rsc`. | Reports API serves top-level text reports. `requests.csv` is imported by `app.db.backfill`; per-run data supports inspection and legacy workflows. Retention prunes generated files. | Request rows and per-scan IP stats are canonical in `requests` and `ip_stats`; rendered summaries and exports are derived. |
|
||||||
|
| `npm/` | Same analyzer outputs, plus user-agent and host summary CSV files. | Same consumers as copyparty. `requests.csv` is a legacy backfill input; retention prunes generated files. | Request rows and per-scan IP stats are canonical in `requests` and `ip_stats`; summaries and exports are derived. |
|
||||||
|
| `app/data/` | Consolidated scan writes `blocklist_ipv4.txt` and `blocklist_ipv6.txt`. | Blocklist UI/API reads these files; scan history reconstruction and firewall sync use them as current/compatibility inputs. Retention explicitly protects them. | Operational compatibility exports. Canonical blocked targets and events belong in `blocklist_entries` and `blocklist_events`; keep files until every consumer uses the DB and git-history contract is replaced. |
|
||||||
|
| `app/` timestamped JSON | `write_blocklist_report()` emits scan/firewall change reports when the router is reachable. | Human-readable audit trail containing run delta, cumulative targets, router changes, dry-run state, and errors; retained under report retention. | Derived audit artifact. Preserve structured fields and source scan association in the DB before considering file retirement. |
|
||||||
|
| `mikrotik/` | `MikrotikFirewall.save_state_report()` writes timestamped JSON snapshots and report data; latest state is also written to `data/mikrotik/*.json`. | Firewall UI/API displays current state and snapshot history. `app.db.backfill` currently imports only the latest `data/mikrotik` snapshot; runtime snapshot persistence uses `firewall_snapshots`, `firewall_address_list_entries`, and `firewall_rule_entries`. | Firewall rows and snapshots are canonical in the DB once persisted. Keep JSON exports during migration and retain any git-diff inputs required by deployment/audit. |
|
||||||
|
|
||||||
|
## Consumers
|
||||||
|
|
||||||
|
- `GET /api/reports/<source>` lists top-level files in the selected report
|
||||||
|
directory; `GET /api/reports/<source>/<path>` reads a file below it.
|
||||||
|
- The reports page consumes those endpoints. Scan status and dashboard helpers
|
||||||
|
select the newest source and MikroTik report files for display.
|
||||||
|
- `/api/blocklist` reads the consolidated IPv4/IPv6 text files. Scan history
|
||||||
|
reconstruction and firewall sync also consume those files alongside the
|
||||||
|
SQLite ledger and git history.
|
||||||
|
- RouterOS `.rsc` exports and firewall synchronization consume generated
|
||||||
|
targets; these are operational outputs, not disposable presentation files.
|
||||||
|
- `app.db.backfill` imports per-source `requests.csv`, blocklist history, and
|
||||||
|
the latest common MikroTik JSON snapshot. It does not currently import all
|
||||||
|
timestamped reports or all historical MikroTik snapshots.
|
||||||
|
- Report retention removes old generated artifacts after consolidated scans,
|
||||||
|
subject to configured age/file-count limits; the consolidated blocklist
|
||||||
|
files are protected.
|
||||||
|
|
||||||
|
## Restructuring Implications
|
||||||
|
|
||||||
|
- Keep `requests`, `ip_stats`, blocklist tables, and firewall snapshot tables
|
||||||
|
as the queryable source of truth for facts they already represent. Avoid
|
||||||
|
copying those facts into a generic report blob.
|
||||||
|
- A future report-artifact record should store metadata and structured fields
|
||||||
|
that have no canonical home yet. Rendered text/CSV/JSON should be generated
|
||||||
|
from those records or remain an explicit export.
|
||||||
|
- Preserve source paths, generated timestamps, scan IDs, and hashes during
|
||||||
|
import so DB records can be traced back to original files.
|
||||||
|
- Do not retire blocklist exports, RouterOS scripts, or source logs until
|
||||||
|
their consumers and audit requirements have explicit replacements.
|
||||||
|
|
||||||
|
## Database and API Design
|
||||||
|
|
||||||
|
### Canonical Data
|
||||||
|
|
||||||
|
Continue treating normalized domain tables as canonical for facts they
|
||||||
|
already represent:
|
||||||
|
|
||||||
|
- `requests` for parsed request events.
|
||||||
|
- `ip_stats` for per-IP/per-scan aggregates.
|
||||||
|
- `scans` and `scans_sources` for run status, parameters, and source counts.
|
||||||
|
- `blocklist_entries`, `blocklist_reasons`, and `blocklist_events` for block
|
||||||
|
decisions and history.
|
||||||
|
- `firewall_snapshots`, `firewall_address_list_entries`, and
|
||||||
|
`firewall_rule_entries` for router state.
|
||||||
|
|
||||||
|
Add a `report_artifacts` table only for report-specific summaries that cannot
|
||||||
|
be reconstructed from those canonical rows. Proposed columns:
|
||||||
|
|
||||||
|
| Column | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Integer primary key. |
|
||||||
|
| `source` | `copyparty`, `npm`, `app`, or `mikrotik`. Keep as text because `log_sources` currently covers only request sources. |
|
||||||
|
| `scan_id` | Nullable link to the generating scan; legacy imports may not have a known scan. |
|
||||||
|
| `report_type` | Stable type such as `analyzer_summary`, `blocklist_change`, or `firewall_snapshot_summary`. |
|
||||||
|
| `generated_at` | Original generation timestamp when known; otherwise import time plus an explicit unknown/original-time flag in metadata. |
|
||||||
|
| `schema_version` | Version of the structured payload shape. |
|
||||||
|
| `payload_json` | Structured report-specific summary, not a duplicate of requests or raw logs. |
|
||||||
|
| `original_path` | Relative source path for provenance and legacy lookup. |
|
||||||
|
| `content_sha256` | Hash of imported source content for integrity and idempotent import. |
|
||||||
|
| `created_at` | Database import/creation timestamp. |
|
||||||
|
|
||||||
|
Use a unique index on `(original_path, content_sha256)` for idempotent file
|
||||||
|
imports. Keep payload schemas type-specific and versioned. Do not copy
|
||||||
|
`requests.csv`, `ips.json`, firewall rows, or blocklists wholesale into
|
||||||
|
`payload_json`; query their canonical tables and generate exports from those
|
||||||
|
tables. If a rendered text report is retained as a database artifact, store
|
||||||
|
its report-specific structured inputs and render text on demand rather than
|
||||||
|
using opaque text as the only source of truth.
|
||||||
|
|
||||||
|
### API Transition Contract
|
||||||
|
|
||||||
|
- Add `GET /api/reports` with optional `source`, `type`, `since`, `until`,
|
||||||
|
`limit`, and opaque `cursor` filters. Return a stable list of artifact
|
||||||
|
metadata: `id`, `source`, `scan_id`, `type`, `generated_at`, and available
|
||||||
|
export formats. Enforce a bounded default and maximum page size.
|
||||||
|
- Add `GET /api/report-artifacts/<artifact_id>` for the structured payload and
|
||||||
|
provenance metadata. Use a distinct path because `/api/reports/<source>`
|
||||||
|
already owns the one-segment route shape.
|
||||||
|
- Add `GET /api/report-artifacts/<artifact_id>/export` for supported report types and formats
|
||||||
|
(initially JSON and, where useful, CSV/TXT). Generate exports from canonical
|
||||||
|
tables and versioned artifact payloads; do not accept filesystem paths from
|
||||||
|
a database-backed export request.
|
||||||
|
- Preserve the current source/path routes as a read-only compatibility
|
||||||
|
fallback while files remain. The reports page should consume DB metadata
|
||||||
|
first, then offer legacy files when no imported artifact exists. Return
|
||||||
|
stable JSON errors for unknown IDs, unsupported formats, and unavailable
|
||||||
|
DB state.
|
||||||
|
- Keep ordering deterministic (`generated_at DESC`, then `id DESC`), validate
|
||||||
|
filter values, and use cursor pagination for large histories. Do not expose
|
||||||
|
absolute server paths in list responses.
|
||||||
|
|
||||||
|
### Sensitive and Large Content
|
||||||
|
|
||||||
|
Do not include raw request lines or full request histories in report payloads;
|
||||||
|
those belong in the dedicated log-storage design and require separate access
|
||||||
|
and retention controls. Keep high-volume CSV exports generated on demand. Treat
|
||||||
|
IP addresses, paths/query strings, user names, user agents, and firewall state
|
||||||
|
as sensitive operational data: the existing web server must remain
|
||||||
|
localhost-only, payloads should include only fields needed by the report, and
|
||||||
|
new endpoints must not expose raw log data by default. Backups and exported
|
||||||
|
files inherit the same access and retention requirements as the database.
|
||||||
|
|
||||||
|
## Migration Implementation Status
|
||||||
|
|
||||||
|
- Schema v2 adds `report_artifacts` with source, scan link, report type,
|
||||||
|
generation time, payload version, original relative path, and SHA-256 hash.
|
||||||
|
- `python -m app.db.backfill --dry-run --reports-only` inventories supported
|
||||||
|
report files by source and format. The importer is idempotent, preserves
|
||||||
|
malformed JSON as text with a parse error, reports unreadable/oversized
|
||||||
|
files, and never modifies source files. It skips exports owned by canonical
|
||||||
|
tables (for example `requests.csv` and current blocklists).
|
||||||
|
- New analyzer text summaries and consolidated blocklist change reports are
|
||||||
|
written to SQLite and their existing files. Firewall snapshots continue to
|
||||||
|
be persisted in the normalized firewall tables.
|
||||||
|
- The Reports page lists database artifacts first, supports cursor pagination
|
||||||
|
and JSON/CSV/TXT downloads, and retains the existing file-backed routes as a
|
||||||
|
fallback for unmigrated files.
|
||||||
|
- Validation covers idempotency, malformed/oversized inputs, source/format
|
||||||
|
counts, backup/restore, dual-write payloads, and API fallback. A workspace
|
||||||
|
dry-run found 76 files: 60 eligible, 16 canonical exports skipped, and none
|
||||||
|
unmappable at the time of the check.
|
||||||
|
- No report files have been retired. Keep this gate closed until every listed
|
||||||
|
consumer has moved or has an explicit compatibility requirement, and a
|
||||||
|
production backup/restore and parity review has been approved.
|
||||||
@@ -1,55 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Intrusion Detection System — unified entry point.
|
"""Intrusion Detection System — unified entry point.
|
||||||
|
|
||||||
By default this starts the localhost-only Flask web UI. Pass ``scan`` to
|
By default this starts the localhost-only Flask web UI. Pass ``scan`` to
|
||||||
run the consolidated CLI scan instead.
|
run the consolidated CLI scan instead.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python app.py # start the web UI (default)
|
python app.py # start the web UI (default)
|
||||||
python app.py --web-port 54666 # web UI with a custom port
|
python app.py --web-port 54666 # web UI with a custom port
|
||||||
python app.py scan [options] # run the consolidated CLI scan
|
python app.py scan [options] # run the consolidated CLI scan
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from app.constants import WEB_HOST, WEB_PORT
|
from app.utils.constants import WEB_HOST, WEB_PORT
|
||||||
|
|
||||||
|
|
||||||
def _web_parser() -> argparse.ArgumentParser:
|
def _web_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="app.py",
|
prog="app.py",
|
||||||
description="Intrusion Detection System: web UI (default) or 'scan' subcommand.",
|
description="Intrusion Detection System: web UI (default) or 'scan' subcommand.",
|
||||||
)
|
)
|
||||||
parser.add_argument("--web-host", default=WEB_HOST,
|
parser.add_argument("--web-host", default=WEB_HOST,
|
||||||
help=f"web UI bind host (default: {WEB_HOST})")
|
help=f"web UI bind host (default: {WEB_HOST})")
|
||||||
parser.add_argument("--web-port", type=int, default=WEB_PORT,
|
parser.add_argument("--web-port", type=int, default=WEB_PORT,
|
||||||
help=f"web UI port (default: {WEB_PORT})")
|
help=f"web UI port (default: {WEB_PORT})")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _run_web(host: str, port: int) -> None:
|
def _run_web(host: str, port: int) -> None:
|
||||||
from app.web import serve
|
from app.web import serve
|
||||||
serve(host=host, port=port)
|
serve(host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
def _run_scan(argv: list[str]) -> None:
|
def _run_scan(argv: list[str]) -> None:
|
||||||
from app.scan import main as scan_main
|
from app.scan import main as scan_main
|
||||||
scan_main(argv)
|
scan_main(argv)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def _run_analyze(argv: list[str]) -> None:
|
||||||
# `python app.py scan ...` runs the consolidated CLI scan.
|
from app.analyze import main as analyze_main
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "scan":
|
analyze_main(argv)
|
||||||
_run_scan(sys.argv[2:])
|
|
||||||
return
|
|
||||||
|
def main() -> None:
|
||||||
# Default: start the web UI (parse_known_args tolerates stray flags).
|
# `python app.py scan ...` runs the consolidated CLI scan.
|
||||||
args, _ = _web_parser().parse_known_args()
|
if len(sys.argv) > 1 and sys.argv[1] == "scan":
|
||||||
_run_web(args.web_host, args.web_port)
|
_run_scan(sys.argv[2:])
|
||||||
|
return
|
||||||
|
|
||||||
if __name__ == "__main__":
|
# `python app.py analyze ...` prints a detailed report from the SQLite store.
|
||||||
main()
|
if len(sys.argv) > 1 and sys.argv[1] == "analyze":
|
||||||
|
_run_analyze(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()
|
||||||
+9
-1
@@ -1 +1,9 @@
|
|||||||
# Intrusion Detection System — app package
|
# Intrusion Detection System — app package
|
||||||
|
#
|
||||||
|
# Package structure:
|
||||||
|
# app/ core domain (analyzers, firewall, detection rules)
|
||||||
|
# app/web/ Flask web UI (app factory, routes, background scan runner)
|
||||||
|
# app/scan/ consolidated scan pipeline + incremental scan state
|
||||||
|
# app/utils/ shared utilities (constants, I/O, models, whitelists, etc.)
|
||||||
|
# app/static/ static assets (CSS, JS)
|
||||||
|
# app/templates/ Jinja2 templates
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI detailed log-analysis report (L002).
|
||||||
|
|
||||||
|
``python app.py analyze [--ip IP] [--since ISO] [--top N] [--json]`` reads the
|
||||||
|
persisted SQLite store (request history, per-IP aggregates, blocklist ledger)
|
||||||
|
and prints a detailed, interactive analysis. It complements the web UI's
|
||||||
|
``/analysis`` page for use on a headless server or in scripts.
|
||||||
|
|
||||||
|
No scan is run here: this only queries what ``scan`` has already persisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(epoch: float | None, default: str = "-") -> str:
|
||||||
|
if not epoch:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(epoch).isoformat(timespec="seconds")
|
||||||
|
except (ValueError, OverflowError, OSError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="python app.py analyze",
|
||||||
|
description="Detailed log-analysis report from the SQLite store.")
|
||||||
|
p.add_argument("--ip", metavar="IP", default=None,
|
||||||
|
help="focus the report on a single IP (drill-down)")
|
||||||
|
p.add_argument("--since", metavar="ISO", default=None,
|
||||||
|
help="only analyse requests at/after this ISO timestamp")
|
||||||
|
p.add_argument("--top", type=int, default=20,
|
||||||
|
help="rows in the top-IP / threat tables (default: 20)")
|
||||||
|
p.add_argument("--json", action="store_true",
|
||||||
|
help="emit raw JSON instead of a human-readable report")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _report(conn, opts) -> dict:
|
||||||
|
from .db import repositories as repo
|
||||||
|
|
||||||
|
data: dict = {}
|
||||||
|
data["scans"] = repo.latest_scans(conn, limit=10)
|
||||||
|
data["totals"] = {
|
||||||
|
"requests": repo.count_requests(conn),
|
||||||
|
"scans": repo.count_scans(conn),
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ip:
|
||||||
|
data["ip"] = {
|
||||||
|
"ip": opts.ip,
|
||||||
|
"timeline": repo.ip_timeline(conn, ip=opts.ip),
|
||||||
|
"active_blocklist": [e for e in repo.active_blocklist(conn)
|
||||||
|
if e["cidr"].startswith(opts.ip)],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
data["top_ips"] = repo.top_ips(conn, since=opts.since, limit=opts.top)
|
||||||
|
data["cross_source"] = repo.correlate_across_sources(
|
||||||
|
conn, limit=opts.top)
|
||||||
|
data["blocklist"] = repo.active_blocklist(conn)
|
||||||
|
data["recent_errors"] = repo.query_requests(
|
||||||
|
conn, since=opts.since, limit=opts.top)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _render_text(data: dict, opts) -> str:
|
||||||
|
lines = ["Detailed log analysis", "=" * 40, ""]
|
||||||
|
totals = data["totals"]
|
||||||
|
lines.append(f"SQLite store: {totals['requests']} requests across "
|
||||||
|
f"{totals['scans']} scan(s)")
|
||||||
|
if data["scans"]:
|
||||||
|
lines.append("Latest scans:")
|
||||||
|
for s in data["scans"]:
|
||||||
|
lines.append(f" #{s['id']:<4} {s['kind']:<8} {s['status']:<7} "
|
||||||
|
f"{s.get('started') or s.get('started_at') or '-'}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if "ip" in data:
|
||||||
|
ip = data["ip"]
|
||||||
|
lines.append(f"== Request history for {ip['ip']} ==")
|
||||||
|
bl = ip["active_blocklist"]
|
||||||
|
if bl:
|
||||||
|
lines.append(f" (source: active blocklist entries "
|
||||||
|
f"{', '.join(e['cidr'] for e in bl)})")
|
||||||
|
lines.append(f"{'time':<24} {'src':<9} {'method':<8} {'status':<6} path")
|
||||||
|
for r in ip["timeline"][:opts.top]:
|
||||||
|
lines.append(
|
||||||
|
f"{r.get('ts') or '-':<24} {str(r.get('source')):<9} "
|
||||||
|
f"{str(r.get('method') or '-'):<8} "
|
||||||
|
f"{str(r.get('status') or '-'):<6} {r.get('path') or '-'}")
|
||||||
|
else:
|
||||||
|
lines.append("== Top IPs ==")
|
||||||
|
lines.append(
|
||||||
|
f"{'ip':<18} {'ver':<3} {'reqs':<6} {'err':<5} {'mal':<5} "
|
||||||
|
f"{'first':<22} last")
|
||||||
|
for r in data["top_ips"]:
|
||||||
|
lines.append(
|
||||||
|
f"{str(r['ip']):<18} {r.get('version') or '-':<3} "
|
||||||
|
f"{r['total']:<6} {r['errors']:<5} {r['malicious_hits']:<5} "
|
||||||
|
f"{_fmt_ts(r['first_epoch']):<22} {_fmt_ts(r['last_epoch'])}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("== Cross-source IPs ==")
|
||||||
|
if data["cross_source"]:
|
||||||
|
for r in data["cross_source"]:
|
||||||
|
lines.append(
|
||||||
|
f" {r['ip']:<18} sources={r['sources']} "
|
||||||
|
f"({r['source_list']}) total={r['total']}")
|
||||||
|
else:
|
||||||
|
lines.append(" (none — no IP seen in more than one log source)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("== Active blocklist (ledger) ==")
|
||||||
|
if data["blocklist"]:
|
||||||
|
for e in data["blocklist"]:
|
||||||
|
lines.append(f" {e['cidr']:<22} added={e['added_at']}")
|
||||||
|
else:
|
||||||
|
lines.append(" (empty)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
opts = _build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
from .db import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
data = _report(conn, opts)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"error: could not read SQLite store ({exc}); "
|
||||||
|
f"run a scan first so data exists.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if opts.json:
|
||||||
|
print(json.dumps(data, indent=2, sort_keys=True, default=str))
|
||||||
|
else:
|
||||||
|
print(_render_text(data, opts))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+378
-290
@@ -1,290 +1,378 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Analysis pipeline for the intrusion-detection system.
|
"""Analysis pipeline for the intrusion-detection system.
|
||||||
|
|
||||||
`Analyzer` is the log-source-agnostic base class. Subclasses implement
|
`Analyzer` is the log-source-agnostic base class. Subclasses implement
|
||||||
`parse_line()` and `flag()`.
|
`parse_line()` and `flag()`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import hashlib
|
||||||
import re
|
import os
|
||||||
from collections import Counter
|
import re
|
||||||
from datetime import datetime
|
from collections import Counter
|
||||||
from typing import Any, Sequence
|
from datetime import datetime
|
||||||
|
from typing import Any, Sequence
|
||||||
from .files import ensure_dirs, find_logs, read_lines, read_lines_from, validate_dir, write_csv, write_json, write_lines
|
|
||||||
from .flags import by_total, flag_malicious_patterns
|
from .utils.files import ensure_dirs, find_logs, read_lines, read_lines_from, validate_dir, write_csv, write_json, write_lines
|
||||||
from .ip_utils import is_whitelisted, split_versions
|
from .flags import by_total, flag_malicious_patterns
|
||||||
from .mikrotik import write_mikrotik
|
from .utils.ip_utils import is_whitelisted, split_versions
|
||||||
from .models import IPStats, Request, aggregate
|
from .mikrotik import write_mikrotik
|
||||||
from .reporting import Column, columns_header, columns_row, render_kv, render_table, timestamp_name
|
from .utils.models import IPStats, Request, aggregate
|
||||||
from .constants import DEFAULT_TOP, DEFAULT_TOP_PATHS
|
from .utils.reporting import Column, columns_header, columns_row, render_kv, render_table, timestamp_name
|
||||||
from . import scan_state
|
from .utils.constants import DEFAULT_TOP, DEFAULT_TOP_PATHS
|
||||||
from .whitelists import refresh_whitelists
|
from .scan import scan_state
|
||||||
|
from .utils.whitelists import refresh_whitelists
|
||||||
|
|
||||||
class Analyzer:
|
|
||||||
"""Log-source-agnostic analysis pipeline.
|
class Analyzer:
|
||||||
|
"""Log-source-agnostic analysis pipeline.
|
||||||
Subclasses must set the class attributes below and implement
|
|
||||||
`parse_line()` and `flag()`.
|
Subclasses must set the class attributes below and implement
|
||||||
"""
|
`parse_line()` and `flag()`.
|
||||||
|
"""
|
||||||
name = "ids" # short id, used in file names / tags
|
|
||||||
title = "IDS log analysis report"
|
name = "ids" # short id, used in file names / tags
|
||||||
log_pattern: re.Pattern | None = None
|
title = "IDS log analysis report"
|
||||||
log_suffixes: tuple[str, ...] = ()
|
log_pattern: re.Pattern | None = None
|
||||||
exclude_prefixes: tuple[str, ...] = ()
|
log_suffixes: tuple[str, ...] = ()
|
||||||
ip_columns: Sequence[Column] = ()
|
exclude_prefixes: tuple[str, ...] = ()
|
||||||
request_columns: Sequence[Column] = ()
|
ip_columns: Sequence[Column] = ()
|
||||||
paths_file = "error_paths.csv"
|
request_columns: Sequence[Column] = ()
|
||||||
paths_label = "error paths"
|
paths_file = "error_paths.csv"
|
||||||
flag_files = ("flagged_ips.txt", "flagged_ipv6.txt")
|
paths_label = "error paths"
|
||||||
flag_label = "Flagged IPs"
|
flag_files = ("flagged_ips.txt", "flagged_ipv6.txt")
|
||||||
flag_note = ""
|
flag_label = "Flagged IPs"
|
||||||
|
flag_note = ""
|
||||||
def __init__(self, log_path: str, report_dir: str, top: int = DEFAULT_TOP,
|
|
||||||
quiet: bool = False, write_rsc: bool = True,
|
def __init__(self, log_path: str, report_dir: str, top: int = DEFAULT_TOP,
|
||||||
full: bool = False):
|
quiet: bool = False, write_rsc: bool = True,
|
||||||
self.log_path = log_path
|
full: bool = False):
|
||||||
self.report_dir = report_dir
|
self.log_path = log_path
|
||||||
self.data_dir = os.path.join(report_dir, "data")
|
self.report_dir = report_dir
|
||||||
self.top = top
|
self.data_dir = os.path.join(report_dir, "data")
|
||||||
self.quiet = quiet
|
self.top = top
|
||||||
self.write_rsc = write_rsc
|
self.quiet = quiet
|
||||||
self.full = full
|
self.write_rsc = write_rsc
|
||||||
self.files: list[str] = []
|
self.full = full
|
||||||
self.requests: list[Request] = []
|
self.files: list[str] = []
|
||||||
self.stats: dict[str, IPStats] = {}
|
self.requests: list[Request] = []
|
||||||
self.flagged: dict[str, IPStats] = {}
|
self.stats: dict[str, IPStats] = {}
|
||||||
self.per_file: dict[str, int] = {}
|
self.flagged: dict[str, IPStats] = {}
|
||||||
|
self.per_file: dict[str, int] = {}
|
||||||
# --- hooks -------------------------------------------------------------
|
|
||||||
|
# --- sqlite persistence ------------------------------------------------
|
||||||
def parse_line(self, line: str) -> Request | None:
|
|
||||||
raise NotImplementedError
|
def persist_to_db(self, scan_id: int | None = None) -> None:
|
||||||
|
"""Ingest this analyzer's requests + per-IP stats into SQLite.
|
||||||
def flag(self, stats: dict[str, IPStats]) -> dict[str, IPStats]:
|
|
||||||
raise NotImplementedError
|
Lazily imported to avoid cycles. When ``scan_id`` is None a standalone
|
||||||
|
scan record is created for this analyzer so individual
|
||||||
def flag_comment(self, s: IPStats) -> str:
|
(non-consolidated) runs are still tracked. Never raises: a DB failure
|
||||||
return (f"errors={s.errors} total={s.total} ratio={s.error_ratio} "
|
logs a warning but does not break the scan.
|
||||||
f"first={s.first} last={s.last}")
|
"""
|
||||||
|
if not self.requests and not self.stats:
|
||||||
def block_candidates(self) -> set[str]:
|
return
|
||||||
malicious = flag_malicious_patterns(self.stats)
|
try:
|
||||||
return set(self.flagged) | set(malicious)
|
from .db import get_connection
|
||||||
|
from .db import repositories as repo
|
||||||
def extra_summary_rows(self) -> list[tuple[str, Any]]:
|
from .utils.ip_utils import ip_version as _ip_ver
|
||||||
return []
|
conn = get_connection()
|
||||||
|
if scan_id is None:
|
||||||
def write_extra(self, data_dir: str) -> list[str]:
|
scan_id = repo.start_scan(conn, self.name,
|
||||||
return []
|
params={"full": self.full})
|
||||||
|
source = self.name
|
||||||
# --- pipeline ----------------------------------------------------------
|
rows: list[dict] = []
|
||||||
|
for r in self.requests:
|
||||||
def discover(self) -> list[str]:
|
rows.append({
|
||||||
self.files = find_logs(self.log_path, self.log_pattern,
|
"source": source, "scan_id": scan_id,
|
||||||
self.log_suffixes, self.exclude_prefixes)
|
"log_file": ((r.extra or {}).get("log_file")
|
||||||
if not self.files:
|
or (self.files[0] if self.files else None)),
|
||||||
raise FileNotFoundError(f"no log files found in: {self.log_path}")
|
"ts": r.ts, "ip": r.ip,
|
||||||
return self.files
|
"version": _ip_ver(r.ip) or 0,
|
||||||
|
"method": r.method, "path": r.path,
|
||||||
def load(self) -> list[Request]:
|
"status": r.status,
|
||||||
"""Read log entries, resuming from the last processed offset.
|
"upstream_status": r.upstream_status,
|
||||||
|
"host": r.host, "user": r.user, "ua": r.ua,
|
||||||
Non-gzip logs are read incrementally (only new bytes since the last
|
"referer": r.referer, "denied": r.denied,
|
||||||
scan) unless ``full`` is set; gzip archives are always fully read
|
"banned": r.banned, "is_error": r.is_error,
|
||||||
because they cannot be seeked.
|
"extra": r.extra,
|
||||||
"""
|
})
|
||||||
state: dict[str, int] = {}
|
repo.insert_requests(conn, rows)
|
||||||
if not self.full:
|
repo.insert_ip_stats(conn, scan_id,
|
||||||
state = scan_state.load_state()
|
list(self.stats.values()),
|
||||||
# Prune entries whose log file no longer exists (safe across
|
set(self.block_candidates()))
|
||||||
# analyzers: only removes dead keys, keeps live ones).
|
repo.set_scan_source_counts(
|
||||||
state = {k: v for k, v in state.items() if os.path.exists(k)}
|
conn, scan_id, source, len(self.requests), len(self.files))
|
||||||
for name in self.files:
|
repo.finish_scan(conn, scan_id, status="done",
|
||||||
full_path = os.path.join(self.log_path, name)
|
log_files=len(self.files),
|
||||||
count = 0
|
request_count=len(self.requests))
|
||||||
if self.full or full_path.endswith(".gz"):
|
except Exception as exc: # never break the scan because of DB issues
|
||||||
for line in read_lines(full_path):
|
if not self.quiet:
|
||||||
req = self.parse_line(line)
|
print(f"WARN: sqlite ingest skipped ({exc})")
|
||||||
if req is not None:
|
# --- hooks -------------------------------------------------------------
|
||||||
self.requests.append(req)
|
|
||||||
count += 1
|
def parse_line(self, line: str) -> Request | None:
|
||||||
else:
|
raise NotImplementedError
|
||||||
try:
|
|
||||||
size = os.path.getsize(full_path)
|
def attr_source(self, req: Request, log_file: str) -> None:
|
||||||
except OSError:
|
"""Record the originating log file onto a parsed request."""
|
||||||
size = 0
|
if req.extra is None:
|
||||||
recorded = state.get(full_path, 0)
|
req.extra = {}
|
||||||
# Reset to the start if the file shrank or was truncated.
|
req.extra["log_file"] = req.extra.get("log_file", log_file)
|
||||||
start = recorded if 0 < recorded <= size else 0
|
|
||||||
last_resume = start
|
def flag(self, stats: dict[str, IPStats]) -> dict[str, IPStats]:
|
||||||
for line, resume in read_lines_from(full_path, start):
|
raise NotImplementedError
|
||||||
req = self.parse_line(line)
|
|
||||||
if req is not None:
|
def flag_comment(self, s: IPStats) -> str:
|
||||||
self.requests.append(req)
|
return (f"errors={s.errors} total={s.total} ratio={s.error_ratio} "
|
||||||
count += 1
|
f"first={s.first} last={s.last}")
|
||||||
last_resume = resume
|
|
||||||
state[full_path] = last_resume
|
def block_candidates(self) -> set[str]:
|
||||||
self.per_file[name] = count
|
malicious = flag_malicious_patterns(self.stats)
|
||||||
if not self.full:
|
return set(self.flagged) | set(malicious)
|
||||||
scan_state.save_state(state)
|
|
||||||
return self.requests
|
def extra_summary_rows(self) -> list[tuple[str, Any]]:
|
||||||
|
return []
|
||||||
def analyze(self) -> dict[str, IPStats]:
|
|
||||||
self.stats = aggregate(self.requests)
|
def write_extra(self, data_dir: str) -> list[str]:
|
||||||
self.flagged = self.flag(self.stats)
|
return []
|
||||||
return self.stats
|
|
||||||
|
# --- pipeline ----------------------------------------------------------
|
||||||
# --- derived data ------------------------------------------------------
|
|
||||||
|
def discover(self) -> list[str]:
|
||||||
def error_paths(self) -> Counter:
|
self.files = find_logs(self.log_path, self.log_pattern,
|
||||||
return Counter(r.path for r in self.requests if r.is_error and r.path)
|
self.log_suffixes, self.exclude_prefixes)
|
||||||
|
if not self.files:
|
||||||
def flagged_split(self) -> tuple[list[IPStats], list[IPStats]]:
|
raise FileNotFoundError(f"no log files found in: {self.log_path}")
|
||||||
v4 = [s for s in self.flagged.values() if s.version == 4]
|
return self.files
|
||||||
v6 = [s for s in self.flagged.values() if s.version == 6]
|
|
||||||
def key(s): return (-s.errors, s.ip)
|
def load(self) -> list[Request]:
|
||||||
return sorted(v4, key=key), sorted(v6, key=key)
|
"""Read log entries, resuming from the last processed offset.
|
||||||
|
|
||||||
def summary_rows(self) -> list[tuple[str, Any]]:
|
Non-gzip logs are read incrementally (only new bytes since the last
|
||||||
v4, v6 = split_versions(self.stats)
|
scan) unless ``full`` is set; gzip archives are always fully read
|
||||||
rows = [
|
because they cannot be seeked.
|
||||||
("log path", self.log_path),
|
"""
|
||||||
("log files", len(self.files)),
|
state: dict[str, int] = {}
|
||||||
("total requests", len(self.requests)),
|
if not self.full:
|
||||||
("unique IPs",
|
state = scan_state.load_state()
|
||||||
f"{len(self.stats)} (IPv4 {len(v4)} / IPv6 {len(v6)})"),
|
# Prune entries whose log file no longer exists (safe across
|
||||||
("error requests", sum(1 for r in self.requests if r.is_error)),
|
# analyzers: only removes dead keys, keeps live ones).
|
||||||
(f"unique {self.paths_label}", len(self.error_paths())),
|
state = {k: v for k, v in state.items() if os.path.exists(k)}
|
||||||
]
|
for name in self.files:
|
||||||
rows += self.extra_summary_rows()
|
full_path = os.path.join(self.log_path, name)
|
||||||
rows += [
|
count = 0
|
||||||
(self.flag_label.lower(), len(self.flagged)),
|
if self.full or full_path.endswith(".gz"):
|
||||||
("whitelisted IPs seen",
|
for line in read_lines(full_path):
|
||||||
sum(1 for ip in self.stats if is_whitelisted(ip))),
|
req = self.parse_line(line)
|
||||||
("block candidates", len(self.block_candidates())),
|
if req is not None:
|
||||||
]
|
self.attr_source(req, name)
|
||||||
return rows
|
self.requests.append(req)
|
||||||
|
count += 1
|
||||||
# --- reporting ---------------------------------------------------------
|
else:
|
||||||
|
try:
|
||||||
def text_report(self) -> str:
|
size = os.path.getsize(full_path)
|
||||||
out = [self.title,
|
except OSError:
|
||||||
f"generated: {datetime.now().isoformat(timespec='seconds')}",
|
size = 0
|
||||||
"",
|
recorded = state.get(full_path, 0)
|
||||||
render_kv(self.summary_rows()), ""]
|
# Reset to the start if the file shrank or was truncated.
|
||||||
|
start = recorded if 0 < recorded <= size else 0
|
||||||
out.append(f"== top {self.top} IPs ==")
|
last_resume = start
|
||||||
rows = [columns_row(self.ip_columns, s)
|
for line, resume in read_lines_from(full_path, start):
|
||||||
for s in by_total(self.stats)[:self.top]]
|
req = self.parse_line(line)
|
||||||
out.append(render_table(columns_header(self.ip_columns), rows))
|
if req is not None:
|
||||||
if len(self.stats) > self.top:
|
self.attr_source(req, name)
|
||||||
out.append(f"... {len(self.stats) - self.top} more IPs omitted")
|
self.requests.append(req)
|
||||||
out.append("")
|
count += 1
|
||||||
|
last_resume = resume
|
||||||
out.append(f"== top {DEFAULT_TOP_PATHS} {self.paths_label} ==")
|
state[full_path] = last_resume
|
||||||
for path, n in self.error_paths().most_common(DEFAULT_TOP_PATHS):
|
self.per_file[name] = count
|
||||||
out.append(f" {n:>5} {path}")
|
if not self.full:
|
||||||
out.append("")
|
scan_state.save_state(state)
|
||||||
|
return self.requests
|
||||||
v4, v6 = self.flagged_split()
|
|
||||||
out.append(f"== {self.flag_label} ({len(self.flagged)}) ==")
|
def analyze(self) -> dict[str, IPStats]:
|
||||||
if self.flag_note:
|
self.stats = aggregate(self.requests)
|
||||||
out.append(self.flag_note)
|
self.flagged = self.flag(self.stats)
|
||||||
for label, group in (("IPv4", v4), ("IPv6", v6)):
|
return self.stats
|
||||||
if not group:
|
|
||||||
continue
|
# --- derived data ------------------------------------------------------
|
||||||
out.append(f"-- {label} ({len(group)}) --")
|
|
||||||
rows = [columns_row(self.ip_columns, s) for s in group]
|
def error_paths(self) -> Counter:
|
||||||
out.append(render_table(columns_header(self.ip_columns), rows))
|
return Counter(r.path for r in self.requests if r.is_error and r.path)
|
||||||
out.append("")
|
|
||||||
return "\n".join(out)
|
def flagged_split(self) -> tuple[list[IPStats], list[IPStats]]:
|
||||||
|
v4 = [s for s in self.flagged.values() if s.version == 4]
|
||||||
# --- writing -----------------------------------------------------------
|
v6 = [s for s in self.flagged.values() if s.version == 6]
|
||||||
|
def key(s): return (-s.errors, s.ip)
|
||||||
def write_data(self) -> list[str]:
|
return sorted(v4, key=key), sorted(v6, key=key)
|
||||||
ensure_dirs(self.report_dir, self.data_dir)
|
|
||||||
written: list[str] = []
|
def summary_rows(self) -> list[tuple[str, Any]]:
|
||||||
d = self.data_dir
|
v4, v6 = split_versions(self.stats)
|
||||||
header = columns_header(self.ip_columns)
|
rows = [
|
||||||
ordered = by_total(self.stats)
|
("log path", self.log_path),
|
||||||
|
("log files", len(self.files)),
|
||||||
for version, fname in ((4, "ips_ipv4.csv"), (6, "ips_ipv6.csv")):
|
("total requests", len(self.requests)),
|
||||||
rows = [columns_row(self.ip_columns, s)
|
("unique IPs",
|
||||||
for s in ordered if s.version == version]
|
f"{len(self.stats)} (IPv4 {len(v4)} / IPv6 {len(v6)})"),
|
||||||
written.append(write_csv(os.path.join(d, fname), header, rows))
|
("error requests", sum(1 for r in self.requests if r.is_error)),
|
||||||
|
(f"unique {self.paths_label}", len(self.error_paths())),
|
||||||
written.append(write_json(
|
]
|
||||||
os.path.join(d, "ips.json"),
|
rows += self.extra_summary_rows()
|
||||||
{s.ip: s.to_dict() for s in ordered}))
|
rows += [
|
||||||
|
(self.flag_label.lower(), len(self.flagged)),
|
||||||
written.append(write_csv(
|
("whitelisted IPs seen",
|
||||||
os.path.join(d, self.paths_file), ["path", "count"],
|
sum(1 for ip in self.stats if is_whitelisted(ip))),
|
||||||
self.error_paths().most_common()))
|
("block candidates", len(self.block_candidates())),
|
||||||
|
]
|
||||||
v4, v6 = self.flagged_split()
|
return rows
|
||||||
for group, fname in zip((v4, v6), self.flag_files):
|
|
||||||
written.append(write_lines(
|
# --- reporting ---------------------------------------------------------
|
||||||
os.path.join(d, fname),
|
|
||||||
[f"{s.ip} # {self.flag_comment(s)}" for s in group]))
|
def text_report(self) -> str:
|
||||||
|
out = [self.title,
|
||||||
blocked_v4, blocked_v6 = split_versions(
|
f"generated: {datetime.now().isoformat(timespec='seconds')}",
|
||||||
ip for ip in self.block_candidates() if not is_whitelisted(ip))
|
"",
|
||||||
written.append(write_lines(
|
render_kv(self.summary_rows()), ""]
|
||||||
os.path.join(d, "blocked_ipv4.txt"), blocked_v4))
|
|
||||||
written.append(write_lines(
|
out.append(f"== top {self.top} IPs ==")
|
||||||
os.path.join(d, "blocked_ipv6.txt"), blocked_v6))
|
rows = [columns_row(self.ip_columns, s)
|
||||||
|
for s in by_total(self.stats)[:self.top]]
|
||||||
written.append(write_csv(
|
out.append(render_table(columns_header(self.ip_columns), rows))
|
||||||
os.path.join(d, "requests.csv"),
|
if len(self.stats) > self.top:
|
||||||
columns_header(self.request_columns),
|
out.append(f"... {len(self.stats) - self.top} more IPs omitted")
|
||||||
(columns_row(self.request_columns, r) for r in self.requests)))
|
out.append("")
|
||||||
|
|
||||||
written += self.write_extra(d)
|
out.append(f"== top {DEFAULT_TOP_PATHS} {self.paths_label} ==")
|
||||||
return written
|
for path, n in self.error_paths().most_common(DEFAULT_TOP_PATHS):
|
||||||
|
out.append(f" {n:>5} {path}")
|
||||||
def write_report(self) -> str:
|
out.append("")
|
||||||
ensure_dirs(self.report_dir)
|
|
||||||
path = os.path.join(self.report_dir, f"{timestamp_name()}.txt")
|
v4, v6 = self.flagged_split()
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
out.append(f"== {self.flag_label} ({len(self.flagged)}) ==")
|
||||||
f.write(self.text_report())
|
if self.flag_note:
|
||||||
return path
|
out.append(self.flag_note)
|
||||||
|
for label, group in (("IPv4", v4), ("IPv6", v6)):
|
||||||
def write_rsc_file(self) -> tuple[str, int, int]:
|
if not group:
|
||||||
ensure_dirs(self.data_dir)
|
continue
|
||||||
return write_mikrotik(
|
out.append(f"-- {label} ({len(group)}) --")
|
||||||
os.path.join(self.data_dir, "mikrotik-block.rsc"),
|
rows = [columns_row(self.ip_columns, s) for s in group]
|
||||||
f"{self.name}-ids", self.block_candidates())
|
out.append(render_table(columns_header(self.ip_columns), rows))
|
||||||
|
out.append("")
|
||||||
# --- entrypoint --------------------------------------------------------
|
return "\n".join(out)
|
||||||
|
|
||||||
def run(self) -> dict[str, Any]:
|
# --- writing -----------------------------------------------------------
|
||||||
validate_dir(self.log_path)
|
|
||||||
ensure_dirs(self.report_dir, self.data_dir)
|
def write_data(self) -> list[str]:
|
||||||
refresh_whitelists()
|
ensure_dirs(self.report_dir, self.data_dir)
|
||||||
self.discover()
|
written: list[str] = []
|
||||||
self.load()
|
d = self.data_dir
|
||||||
self.analyze()
|
header = columns_header(self.ip_columns)
|
||||||
|
ordered = by_total(self.stats)
|
||||||
if not self.quiet:
|
|
||||||
print(self.text_report())
|
for version, fname in ((4, "ips_ipv4.csv"), (6, "ips_ipv6.csv")):
|
||||||
|
rows = [columns_row(self.ip_columns, s)
|
||||||
self.write_data()
|
for s in ordered if s.version == version]
|
||||||
report_path = self.write_report()
|
written.append(write_csv(os.path.join(d, fname), header, rows))
|
||||||
print(f"data files: {self.data_dir}")
|
|
||||||
print(f"summary report: {report_path}")
|
written.append(write_json(
|
||||||
|
os.path.join(d, "ips.json"),
|
||||||
result: dict[str, Any] = {"report": report_path,
|
{s.ip: s.to_dict() for s in ordered}))
|
||||||
"data_dir": self.data_dir}
|
|
||||||
if self.write_rsc:
|
written.append(write_csv(
|
||||||
rsc, n_blocked, n_whitelisted = self.write_rsc_file()
|
os.path.join(d, self.paths_file), ["path", "count"],
|
||||||
print(f"mikrotik script: {rsc}")
|
self.error_paths().most_common()))
|
||||||
print(f" {n_blocked} IPs blocked, "
|
|
||||||
f"{n_whitelisted} CIDR ranges whitelisted")
|
v4, v6 = self.flagged_split()
|
||||||
result.update(rsc=rsc, blocked=n_blocked,
|
for group, fname in zip((v4, v6), self.flag_files):
|
||||||
whitelisted=n_whitelisted)
|
written.append(write_lines(
|
||||||
return result
|
os.path.join(d, fname),
|
||||||
|
[f"{s.ip} # {self.flag_comment(s)}" for s in group]))
|
||||||
|
|
||||||
|
blocked_v4, blocked_v6 = split_versions(
|
||||||
|
ip for ip in self.block_candidates() if not is_whitelisted(ip))
|
||||||
|
written.append(write_lines(
|
||||||
|
os.path.join(d, "blocked_ipv4.txt"), blocked_v4))
|
||||||
|
written.append(write_lines(
|
||||||
|
os.path.join(d, "blocked_ipv6.txt"), blocked_v6))
|
||||||
|
|
||||||
|
written.append(write_csv(
|
||||||
|
os.path.join(d, "requests.csv"),
|
||||||
|
columns_header(self.request_columns),
|
||||||
|
(columns_row(self.request_columns, r) for r in self.requests)))
|
||||||
|
|
||||||
|
written += self.write_extra(d)
|
||||||
|
return written
|
||||||
|
|
||||||
|
def write_report(self) -> str:
|
||||||
|
ensure_dirs(self.report_dir)
|
||||||
|
path = os.path.join(self.report_dir, f"{timestamp_name()}.txt")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(self.text_report())
|
||||||
|
return path
|
||||||
|
|
||||||
|
def write_rsc_file(self) -> tuple[str, int, int]:
|
||||||
|
ensure_dirs(self.data_dir)
|
||||||
|
return write_mikrotik(
|
||||||
|
os.path.join(self.data_dir, "mikrotik-block.rsc"),
|
||||||
|
f"{self.name}-ids", self.block_candidates())
|
||||||
|
|
||||||
|
# --- entrypoint --------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self, db_scan_id: int | None = None) -> dict[str, Any]:
|
||||||
|
validate_dir(self.log_path)
|
||||||
|
ensure_dirs(self.report_dir, self.data_dir)
|
||||||
|
refresh_whitelists()
|
||||||
|
self.discover()
|
||||||
|
self.load()
|
||||||
|
self.analyze()
|
||||||
|
|
||||||
|
if not self.quiet:
|
||||||
|
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()
|
||||||
|
report_path = self.write_report()
|
||||||
|
self.persist_report_artifact(report_path, db_scan_id)
|
||||||
|
print(f"data files: {self.data_dir}")
|
||||||
|
print(f"summary report: {report_path}")
|
||||||
|
|
||||||
|
result: dict[str, Any] = {"report": report_path,
|
||||||
|
"data_dir": self.data_dir}
|
||||||
|
if self.write_rsc:
|
||||||
|
rsc, n_blocked, n_whitelisted = self.write_rsc_file()
|
||||||
|
print(f"mikrotik script: {rsc}")
|
||||||
|
print(f" {n_blocked} IPs blocked, "
|
||||||
|
f"{n_whitelisted} CIDR ranges whitelisted")
|
||||||
|
result.update(rsc=rsc, blocked=n_blocked,
|
||||||
|
whitelisted=n_whitelisted)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def persist_report_artifact(self, path: str,
|
||||||
|
scan_id: int | None = None) -> None:
|
||||||
|
"""Store the rendered summary in SQLite while keeping its file export."""
|
||||||
|
try:
|
||||||
|
from .db import get_connection
|
||||||
|
from .db import repositories as repo
|
||||||
|
with open(path, "rb") as file:
|
||||||
|
content = file.read()
|
||||||
|
stat = os.stat(path)
|
||||||
|
relative = os.path.relpath(path, self.report_dir).replace(os.sep, "/")
|
||||||
|
repo.insert_report_artifact(
|
||||||
|
get_connection(), source=self.name,
|
||||||
|
report_type="analyzer_summary",
|
||||||
|
generated_at=datetime.fromtimestamp(
|
||||||
|
stat.st_mtime).astimezone().isoformat(timespec="seconds"),
|
||||||
|
payload={"format": "text",
|
||||||
|
"content": content.decode("utf-8", errors="replace")},
|
||||||
|
original_path=f"{self.name}/{relative}",
|
||||||
|
content_sha256=hashlib.sha256(content).hexdigest(),
|
||||||
|
scan_id=scan_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if not self.quiet:
|
||||||
|
print(f"WARN: report artifact DB write skipped ({exc})")
|
||||||
+190
-190
@@ -1,190 +1,190 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Malicious-pattern detection rules for the intrusion-detection system.
|
"""Malicious-pattern detection rules for the intrusion-detection system.
|
||||||
|
|
||||||
Defines compiled regex patterns and classifiers that identify
|
Defines compiled regex patterns and classifiers that identify
|
||||||
probing/scanning URLs (e.g. env files, wp-admin, webshells) and
|
probing/scanning URLs (e.g. env files, wp-admin, webshells) and
|
||||||
suspicious file names. Imported by analyzers to flag IPs hitting
|
suspicious file names. Imported by analyzers to flag IPs hitting
|
||||||
these patterns as blocklist candidates.
|
these patterns as blocklist candidates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import ClassVar, Pattern
|
from typing import ClassVar, Pattern
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pattern categories
|
# Pattern categories
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BadPattern:
|
class BadPattern:
|
||||||
"""A single detection rule.
|
"""A single detection rule.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
name: Short identifier (e.g. "env_file", "wp_admin").
|
name: Short identifier (e.g. "env_file", "wp_admin").
|
||||||
label: Human-readable description for reports.
|
label: Human-readable description for reports.
|
||||||
regex: Compiled regex matched against the request path.
|
regex: Compiled regex matched against the request path.
|
||||||
severity: 1 = moderate, 2 = high, 3 = critical (auto-block).
|
severity: 1 = moderate, 2 = high, 3 = critical (auto-block).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
label: str
|
label: str
|
||||||
regex: Pattern[str]
|
regex: Pattern[str]
|
||||||
severity: int = 2
|
severity: int = 2
|
||||||
|
|
||||||
def matches(self, path: str) -> bool:
|
def matches(self, path: str) -> bool:
|
||||||
return bool(self.regex.search(path))
|
return bool(self.regex.search(path))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Compiled pattern bank
|
# Compiled pattern bank
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# -- env / config file probes -------------------------------------------------
|
# -- env / config file probes -------------------------------------------------
|
||||||
|
|
||||||
ENV_FILE = BadPattern(
|
ENV_FILE = BadPattern(
|
||||||
"env_file",
|
"env_file",
|
||||||
"env/config file probe (.env, .aws/credentials, config.json)",
|
"env/config file probe (.env, .aws/credentials, config.json)",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/\.(?:env|config)(?:\.\w+)*|"
|
r"/\.(?:env|config)(?:\.\w+)*|"
|
||||||
r"/\.aws/credentials|"
|
r"/\.aws/credentials|"
|
||||||
r"/config\.json",
|
r"/config\.json",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=3,
|
severity=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- version control probes ---------------------------------------------------
|
# -- version control probes ---------------------------------------------------
|
||||||
|
|
||||||
VCS_PROBE = BadPattern(
|
VCS_PROBE = BadPattern(
|
||||||
"vcs_probe",
|
"vcs_probe",
|
||||||
"version-control probe (.git, .svn)",
|
"version-control probe (.git, .svn)",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/\.(?:git|svn)(?:/|$)",
|
r"/\.(?:git|svn)(?:/|$)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=3,
|
severity=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- WordPress / CMS probes ---------------------------------------------------
|
# -- WordPress / CMS probes ---------------------------------------------------
|
||||||
|
|
||||||
WP_PROBE = BadPattern(
|
WP_PROBE = BadPattern(
|
||||||
"wp_probe",
|
"wp_probe",
|
||||||
"WordPress/CMS probe (wp-admin, wp-login, wp-content, wp-includes)",
|
"WordPress/CMS probe (wp-admin, wp-login, wp-content, wp-includes)",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/wp-(?:admin|login|content|includes|load|cron|xmlrpc)(?:/|\.php)",
|
r"/wp-(?:admin|login|content|includes|load|cron|xmlrpc)(?:/|\.php)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=2,
|
severity=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- well-known / common probe paths ------------------------------------------
|
# -- well-known / common probe paths ------------------------------------------
|
||||||
|
|
||||||
WELL_KNOWN_PROBE = BadPattern(
|
WELL_KNOWN_PROBE = BadPattern(
|
||||||
"well_known_probe",
|
"well_known_probe",
|
||||||
".well-known probe",
|
".well-known probe",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/\.well-known/",
|
r"/\.well-known/",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=1,
|
severity=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- webshell / backdoor probes -----------------------------------------------
|
# -- webshell / backdoor probes -----------------------------------------------
|
||||||
|
|
||||||
WEBSHELL_PROBE = BadPattern(
|
WEBSHELL_PROBE = BadPattern(
|
||||||
"webshell_probe",
|
"webshell_probe",
|
||||||
"webshell/backdoor probe (known shell filenames, phpinfo, file managers)",
|
"webshell/backdoor probe (known shell filenames, phpinfo, file managers)",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/(?:"
|
r"/(?:"
|
||||||
r"phpinfo|info|shell|cmd|upload|"
|
r"phpinfo|info|shell|cmd|upload|"
|
||||||
r"wp_filemanager|filemanager|"
|
r"wp_filemanager|filemanager|"
|
||||||
r"xstelth|adminfuns|hellopress|"
|
r"xstelth|adminfuns|hellopress|"
|
||||||
r"fpwch|crgio|coffexium|"
|
r"fpwch|crgio|coffexium|"
|
||||||
r"ccou|biufile|"
|
r"ccou|biufile|"
|
||||||
r"ms-edit|classwithtostring|"
|
r"ms-edit|classwithtostring|"
|
||||||
r"this_is_a_new_hello_world|"
|
r"this_is_a_new_hello_world|"
|
||||||
r"chosen|ops|simple|inputs"
|
r"chosen|ops|simple|inputs"
|
||||||
r")\.php",
|
r")\.php",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=3,
|
severity=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- suspicious generic PHP file requests -------------------------------------
|
# -- suspicious generic PHP file requests -------------------------------------
|
||||||
|
|
||||||
GENERIC_PHP_PROBE = BadPattern(
|
GENERIC_PHP_PROBE = BadPattern(
|
||||||
"generic_php_probe",
|
"generic_php_probe",
|
||||||
"generic/random PHP file probe (single-char, numeric names)",
|
"generic/random PHP file probe (single-char, numeric names)",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/(?:"
|
r"/(?:"
|
||||||
r"[a-z]\.php|" # single letter: a.php, k.php, x.php
|
r"[a-z]\.php|" # single letter: a.php, k.php, x.php
|
||||||
r"\d{1,3}\.php|" # numeric: 1.php, 222.php, 155.php
|
r"\d{1,3}\.php|" # numeric: 1.php, 222.php, 155.php
|
||||||
r"[a-z]{2}\.php|" # two-letter: aa.php, dx.php
|
r"[a-z]{2}\.php|" # two-letter: aa.php, dx.php
|
||||||
r"a\d\.php|" # a1.php
|
r"a\d\.php|" # a1.php
|
||||||
r"\w+\.php" # generic: anything.php
|
r"\w+\.php" # generic: anything.php
|
||||||
r")",
|
r")",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=1,
|
severity=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- sensitive file probes ----------------------------------------------------
|
# -- sensitive file probes ----------------------------------------------------
|
||||||
|
|
||||||
SENSITIVE_FILE = BadPattern(
|
SENSITIVE_FILE = BadPattern(
|
||||||
"sensitive_file",
|
"sensitive_file",
|
||||||
"sensitive file probe (id_rsa, id_dsa, .pem, .key)",
|
"sensitive file probe (id_rsa, id_dsa, .pem, .key)",
|
||||||
re.compile(
|
re.compile(
|
||||||
r"/(?:id_rsa|id_dsa|id_ecdsa|id_ed25519|.*\.(?:pem|key|ppk|pfx|p12))",
|
r"/(?:id_rsa|id_dsa|id_ecdsa|id_ed25519|.*\.(?:pem|key|ppk|pfx|p12))",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
severity=3,
|
severity=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- suspicious user-agent (optional, path-based only for now) -----------------
|
# -- suspicious user-agent (optional, path-based only for now) -----------------
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# All active patterns (ordered by severity, highest first)
|
# All active patterns (ordered by severity, highest first)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ALL_PATTERNS: list[BadPattern] = [
|
ALL_PATTERNS: list[BadPattern] = [
|
||||||
ENV_FILE,
|
ENV_FILE,
|
||||||
VCS_PROBE,
|
VCS_PROBE,
|
||||||
SENSITIVE_FILE,
|
SENSITIVE_FILE,
|
||||||
WEBSHELL_PROBE,
|
WEBSHELL_PROBE,
|
||||||
WP_PROBE,
|
WP_PROBE,
|
||||||
GENERIC_PHP_PROBE,
|
GENERIC_PHP_PROBE,
|
||||||
WELL_KNOWN_PROBE,
|
WELL_KNOWN_PROBE,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Quick lookup by name
|
# Quick lookup by name
|
||||||
PATTERNS_BY_NAME: dict[str, BadPattern] = {p.name: p for p in ALL_PATTERNS}
|
PATTERNS_BY_NAME: dict[str, BadPattern] = {p.name: p for p in ALL_PATTERNS}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Classification API
|
# Classification API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def classify_path(path: str) -> list[BadPattern]:
|
def classify_path(path: str) -> list[BadPattern]:
|
||||||
"""Return all patterns that match a given request path."""
|
"""Return all patterns that match a given request path."""
|
||||||
return [p for p in ALL_PATTERNS if p.matches(path)]
|
return [p for p in ALL_PATTERNS if p.matches(path)]
|
||||||
|
|
||||||
|
|
||||||
def is_malicious(path: str) -> bool:
|
def is_malicious(path: str) -> bool:
|
||||||
"""Return True if any pattern matches the path."""
|
"""Return True if any pattern matches the path."""
|
||||||
return any(p.matches(path) for p in ALL_PATTERNS)
|
return any(p.matches(path) for p in ALL_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
def malicious_severity(path: str) -> int:
|
def malicious_severity(path: str) -> int:
|
||||||
"""Return the highest severity among matching patterns, or 0 if none."""
|
"""Return the highest severity among matching patterns, or 0 if none."""
|
||||||
matched = classify_path(path)
|
matched = classify_path(path)
|
||||||
return max((p.severity for p in matched), default=0)
|
return max((p.severity for p in matched), default=0)
|
||||||
|
|
||||||
|
|
||||||
def pattern_names(path: str) -> list[str]:
|
def pattern_names(path: str) -> list[str]:
|
||||||
"""Return names of all patterns matching the path."""
|
"""Return names of all patterns matching the path."""
|
||||||
return [p.name for p in classify_path(path)]
|
return [p.name for p in classify_path(path)]
|
||||||
+182
-182
@@ -1,182 +1,182 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""copyparty access-log analyzer.
|
"""copyparty access-log analyzer.
|
||||||
|
|
||||||
Parses copyparty server logs, aggregates per-IP behaviour and flags
|
Parses copyparty server logs, aggregates per-IP behaviour and flags
|
||||||
"denied-only" clients: IPs that never authenticated and only ever hit
|
"denied-only" clients: IPs that never authenticated and only ever hit
|
||||||
`@* has no access to '...'`. Those plus copyparty's own auto-bans become
|
`@* has no access to '...'`. Those plus copyparty's own auto-bans become
|
||||||
the MikroTik block list.
|
the MikroTik block list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .analyzer import Analyzer
|
from .analyzer import Analyzer
|
||||||
from .constants import (
|
from .utils.constants import (
|
||||||
COPYPARTY_LOG_PATH,
|
COPYPARTY_LOG_PATH,
|
||||||
COPYPARTY_REPORT_DIR,
|
COPYPARTY_REPORT_DIR,
|
||||||
DEFAULT_MIN_DENIED,
|
DEFAULT_MIN_DENIED,
|
||||||
)
|
)
|
||||||
from .ip_utils import split_versions
|
from .utils.ip_utils import split_versions
|
||||||
from .files import write_json, write_lines
|
from .utils.files import write_json, write_lines
|
||||||
from .flags import flag_denied_only
|
from .flags import flag_denied_only
|
||||||
from .models import IPStats, Request
|
from .utils.models import IPStats, Request
|
||||||
from .utils import arg_parser
|
from .utils.utils import arg_parser
|
||||||
|
|
||||||
# "HH:MM:SS.mmm <ip> <port> LEVEL: message"
|
# "HH:MM:SS.mmm <ip> <port> LEVEL: message"
|
||||||
# System lines ("hsrv", "root", ...) have no port and are skipped.
|
# System lines ("hsrv", "root", ...) have no port and are skipped.
|
||||||
LINE_RE = re.compile(
|
LINE_RE = re.compile(
|
||||||
r"^(\d{2}:\d{2}:\d{2}\.\d{3})\s+" # timestamp
|
r"^(\d{2}:\d{2}:\d{2}\.\d{3})\s+" # timestamp
|
||||||
r"(\S+)\s+" # ip
|
r"(\S+)\s+" # ip
|
||||||
r"(\d+)\s+" # port
|
r"(\d+)\s+" # port
|
||||||
r"(LOG|WARN|CRIT|BTW|INFO|ERR|DEBUG):\s" # level
|
r"(LOG|WARN|CRIT|BTW|INFO|ERR|DEBUG):\s" # level
|
||||||
r"(.*)$" # message
|
r"(.*)$" # message
|
||||||
)
|
)
|
||||||
|
|
||||||
METHOD_RE = re.compile(
|
METHOD_RE = re.compile(
|
||||||
r"^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|PROPFIND|PROPPATCH|MKCOL|"
|
r"^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|PROPFIND|PROPPATCH|MKCOL|"
|
||||||
r"MOVE|COPY|LOCK|UNLOCK)\s+(\S+)(?:\s+@(\S+))?")
|
r"MOVE|COPY|LOCK|UNLOCK)\s+(\S+)(?:\s+@(\S+))?")
|
||||||
DENY_RE = re.compile(r"^@(\S+) has no access to '([^']+)'")
|
DENY_RE = re.compile(r"^@(\S+) has no access to '([^']+)'")
|
||||||
CLIENT_BAN_RE = re.compile(r"client banned")
|
CLIENT_BAN_RE = re.compile(r"client banned")
|
||||||
BAN_SECS_RE = re.compile(r"banned for (\d+) sec")
|
BAN_SECS_RE = re.compile(r"banned for (\d+) sec")
|
||||||
|
|
||||||
|
|
||||||
class CopypartyAnalyzer(Analyzer):
|
class CopypartyAnalyzer(Analyzer):
|
||||||
name = "copyparty"
|
name = "copyparty"
|
||||||
title = "copyparty log analysis report"
|
title = "copyparty log analysis report"
|
||||||
log_pattern = re.compile(r"\d{4}-\d{2}-\d{2}\.txt(\.\d+)?$")
|
log_pattern = re.compile(r"\d{4}-\d{2}-\d{2}\.txt(\.\d+)?$")
|
||||||
paths_file = "denied_paths.csv"
|
paths_file = "denied_paths.csv"
|
||||||
paths_label = "denied paths"
|
paths_label = "denied paths"
|
||||||
flag_files = ("denied_ips.txt", "denied_ipv6.txt")
|
flag_files = ("denied_ips.txt", "denied_ipv6.txt")
|
||||||
flag_label = "Denied-only IPs"
|
flag_label = "Denied-only IPs"
|
||||||
flag_note = "never authenticated -- ban candidates"
|
flag_note = "never authenticated -- ban candidates"
|
||||||
|
|
||||||
ip_columns = (
|
ip_columns = (
|
||||||
("ip", lambda s: s.ip),
|
("ip", lambda s: s.ip),
|
||||||
("total", lambda s: s.total),
|
("total", lambda s: s.total),
|
||||||
("denied", lambda s: s.denied),
|
("denied", lambda s: s.denied),
|
||||||
("banned", lambda s: s.banned),
|
("banned", lambda s: s.banned),
|
||||||
("first", lambda s: s.first),
|
("first", lambda s: s.first),
|
||||||
("last", lambda s: s.last),
|
("last", lambda s: s.last),
|
||||||
("users", lambda s: s.users_str),
|
("users", lambda s: s.users_str),
|
||||||
("whitelist", lambda s: s.whitelist),
|
("whitelist", lambda s: s.whitelist),
|
||||||
)
|
)
|
||||||
|
|
||||||
request_columns = (
|
request_columns = (
|
||||||
("time", lambda r: r.ts),
|
("time", lambda r: r.ts),
|
||||||
("ip", lambda r: r.ip),
|
("ip", lambda r: r.ip),
|
||||||
("port", lambda r: r.extra.get("port")),
|
("port", lambda r: r.extra.get("port")),
|
||||||
("level", lambda r: r.extra.get("level")),
|
("level", lambda r: r.extra.get("level")),
|
||||||
("method", lambda r: r.method),
|
("method", lambda r: r.method),
|
||||||
("path", lambda r: r.path),
|
("path", lambda r: r.path),
|
||||||
("user", lambda r: r.user),
|
("user", lambda r: r.user),
|
||||||
("denied", lambda r: r.denied),
|
("denied", lambda r: r.denied),
|
||||||
("banned", lambda r: r.banned),
|
("banned", lambda r: r.banned),
|
||||||
("ban_secs", lambda r: r.extra.get("ban_secs")),
|
("ban_secs", lambda r: r.extra.get("ban_secs")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, min_denied: int = DEFAULT_MIN_DENIED, **kwargs):
|
def __init__(self, *args, min_denied: int = DEFAULT_MIN_DENIED, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.min_denied = min_denied
|
self.min_denied = min_denied
|
||||||
|
|
||||||
# --- parsing -----------------------------------------------------------
|
# --- parsing -----------------------------------------------------------
|
||||||
|
|
||||||
def parse_line(self, line: str) -> Request | None:
|
def parse_line(self, line: str) -> Request | None:
|
||||||
if not line:
|
if not line:
|
||||||
return None
|
return None
|
||||||
m = LINE_RE.match(line)
|
m = LINE_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
ts, ip, port, level, msg = m.groups()
|
ts, ip, port, level, msg = m.groups()
|
||||||
req = Request(ts=ts, ip=ip, user=None,
|
req = Request(ts=ts, ip=ip, user=None,
|
||||||
extra={"port": int(port), "level": level})
|
extra={"port": int(port), "level": level})
|
||||||
|
|
||||||
mm = METHOD_RE.match(msg)
|
mm = METHOD_RE.match(msg)
|
||||||
if mm:
|
if mm:
|
||||||
req.method, req.path, user = mm.groups()
|
req.method, req.path, user = mm.groups()
|
||||||
req.user = user or "*"
|
req.user = user or "*"
|
||||||
return req
|
return req
|
||||||
|
|
||||||
dm = DENY_RE.match(msg)
|
dm = DENY_RE.match(msg)
|
||||||
if dm:
|
if dm:
|
||||||
req.user, req.path = dm.groups()
|
req.user, req.path = dm.groups()
|
||||||
req.denied = True
|
req.denied = True
|
||||||
return req
|
return req
|
||||||
|
|
||||||
if CLIENT_BAN_RE.search(msg):
|
if CLIENT_BAN_RE.search(msg):
|
||||||
req.banned = True
|
req.banned = True
|
||||||
bs = BAN_SECS_RE.search(msg)
|
bs = BAN_SECS_RE.search(msg)
|
||||||
if bs:
|
if bs:
|
||||||
req.extra["ban_secs"] = int(bs.group(1))
|
req.extra["ban_secs"] = int(bs.group(1))
|
||||||
return req
|
return req
|
||||||
|
|
||||||
bs = BAN_SECS_RE.search(msg)
|
bs = BAN_SECS_RE.search(msg)
|
||||||
if bs:
|
if bs:
|
||||||
req.banned = True
|
req.banned = True
|
||||||
req.extra["ban_secs"] = int(bs.group(1))
|
req.extra["ban_secs"] = int(bs.group(1))
|
||||||
return req
|
return req
|
||||||
|
|
||||||
# --- flagging ----------------------------------------------------------
|
# --- flagging ----------------------------------------------------------
|
||||||
|
|
||||||
def flag(self, stats):
|
def flag(self, stats):
|
||||||
return flag_denied_only(stats, min_denied=self.min_denied)
|
return flag_denied_only(stats, min_denied=self.min_denied)
|
||||||
|
|
||||||
def flag_comment(self, s):
|
def flag_comment(self, s):
|
||||||
return (f"denied={s.denied} total={s.total} "
|
return (f"denied={s.denied} total={s.total} "
|
||||||
f"first={s.first} last={s.last}")
|
f"first={s.first} last={s.last}")
|
||||||
|
|
||||||
def block_candidates(self) -> set[str]:
|
def block_candidates(self) -> set[str]:
|
||||||
banned = {ip for ip, s in self.stats.items() if s.banned}
|
banned = {ip for ip, s in self.stats.items() if s.banned}
|
||||||
return set(self.flagged) | banned
|
return set(self.flagged) | banned
|
||||||
|
|
||||||
# --- extras ------------------------------------------------------------
|
# --- extras ------------------------------------------------------------
|
||||||
|
|
||||||
def banned_ips(self) -> dict[str, IPStats]:
|
def banned_ips(self) -> dict[str, IPStats]:
|
||||||
return {ip: s for ip, s in self.stats.items() if s.banned}
|
return {ip: s for ip, s in self.stats.items() if s.banned}
|
||||||
|
|
||||||
def authed_users(self) -> dict[str, int]:
|
def authed_users(self) -> dict[str, int]:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for r in self.requests:
|
for r in self.requests:
|
||||||
if r.user and r.user != "*":
|
if r.user and r.user != "*":
|
||||||
counts[r.user] = counts.get(r.user, 0) + 1
|
counts[r.user] = counts.get(r.user, 0) + 1
|
||||||
return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
|
return dict(sorted(counts.items(), key=lambda kv: -kv[1]))
|
||||||
|
|
||||||
def extra_summary_rows(self):
|
def extra_summary_rows(self):
|
||||||
return [("denied requests",
|
return [("denied requests",
|
||||||
sum(1 for r in self.requests if r.denied)),
|
sum(1 for r in self.requests if r.denied)),
|
||||||
("banned IPs", len(self.banned_ips())),
|
("banned IPs", len(self.banned_ips())),
|
||||||
("authenticated users", self.authed_users() or "{}")]
|
("authenticated users", self.authed_users() or "{}")]
|
||||||
|
|
||||||
def write_extra(self, data_dir: str) -> list[str]:
|
def write_extra(self, data_dir: str) -> list[str]:
|
||||||
v4, v6 = split_versions(self.banned_ips())
|
v4, v6 = split_versions(self.banned_ips())
|
||||||
return [
|
return [
|
||||||
write_lines(os.path.join(data_dir, "banned_ips.txt"), v4),
|
write_lines(os.path.join(data_dir, "banned_ips.txt"), v4),
|
||||||
write_lines(os.path.join(data_dir, "banned_ipv6.txt"), v6),
|
write_lines(os.path.join(data_dir, "banned_ipv6.txt"), v6),
|
||||||
write_json(os.path.join(data_dir, "authed_users.json"),
|
write_json(os.path.join(data_dir, "authed_users.json"),
|
||||||
self.authed_users()),
|
self.authed_users()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = arg_parser(
|
parser = arg_parser(
|
||||||
"analyze copyparty logs, flag denied-only IPs, export MikroTik rules",
|
"analyze copyparty logs, flag denied-only IPs, export MikroTik rules",
|
||||||
COPYPARTY_LOG_PATH, COPYPARTY_REPORT_DIR)
|
COPYPARTY_LOG_PATH, COPYPARTY_REPORT_DIR)
|
||||||
parser.add_argument("--min-denied", type=int, default=DEFAULT_MIN_DENIED,
|
parser.add_argument("--min-denied", type=int, default=DEFAULT_MIN_DENIED,
|
||||||
help=f"minimum denied hits before flagging (default: {DEFAULT_MIN_DENIED})")
|
help=f"minimum denied hits before flagging (default: {DEFAULT_MIN_DENIED})")
|
||||||
parser.add_argument("--full", action="store_true",
|
parser.add_argument("--full", action="store_true",
|
||||||
help="rescan all logs from the start (ignore incremental state)")
|
help="rescan all logs from the start (ignore incremental state)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
CopypartyAnalyzer(
|
CopypartyAnalyzer(
|
||||||
log_path=args.log_path,
|
log_path=args.log_path,
|
||||||
report_dir=args.report_dir,
|
report_dir=args.report_dir,
|
||||||
top=args.top,
|
top=args.top,
|
||||||
quiet=args.quiet,
|
quiet=args.quiet,
|
||||||
full=args.full,
|
full=args.full,
|
||||||
min_denied=args.min_denied,
|
min_denied=args.min_denied,
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
35.203.210.0/23
|
35.203.210.0/23
|
||||||
144.86.173.0/24
|
144.86.173.0/24
|
||||||
147.185.132.0/23
|
147.185.132.0/23
|
||||||
162.216.149.0/24
|
162.216.149.0/24
|
||||||
162.216.150.0/24
|
162.216.150.0/24
|
||||||
172.105.147.0/24
|
172.105.147.0/24
|
||||||
198.235.24.0/24
|
198.235.24.0/24
|
||||||
205.210.31.0/24
|
205.210.31.0/24
|
||||||
216.25.88.0/21
|
216.25.88.0/21
|
||||||
2604:a940:300:5b6:0:0:0:0/64
|
2604:a940:300:5b6:0:0:0:0/64
|
||||||
2604:a940:301:225:0:0:0:0/64
|
2604:a940:301:225:0:0:0:0/64
|
||||||
2604:a940:302:118:0:0:0:0/64
|
2604:a940:302:118:0:0:0:0/64
|
||||||
@@ -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,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI entry point for the SQLite store.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m app.db --migrate applies pending migrations (idempotent)
|
||||||
|
python -m app.db --info prints schema version + table counts, exits
|
||||||
|
python -m app.db --path prints the resolved DB path, exits
|
||||||
|
|
||||||
|
Used by ``deploy.sh`` (D004) to ensure the DB is up to date after a ``git pull``
|
||||||
|
without needing the web server up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from ..utils.constants import DATA_DB_PATH
|
||||||
|
from .schema import SCHEMA_VERSION, apply_migrations
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m app.db",
|
||||||
|
description="Intrusion-detection SQLite store utilities.")
|
||||||
|
parser.add_argument("--migrate", action="store_true",
|
||||||
|
help="apply pending schema migrations (idempotent)")
|
||||||
|
parser.add_argument("--info", action="store_true",
|
||||||
|
help="print schema version + table/row counts")
|
||||||
|
parser.add_argument("--path", action="store_true",
|
||||||
|
help="print the resolved DB path and exit")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.path:
|
||||||
|
print(DATA_DB_PATH)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
from . import get_connection
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
current = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
|
||||||
|
if args.info:
|
||||||
|
counts = {}
|
||||||
|
tables = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||||
|
"AND name NOT LIKE 'sqlite_%' ORDER BY name").fetchall()
|
||||||
|
for t in tables:
|
||||||
|
name = t["name"]
|
||||||
|
try:
|
||||||
|
counts[name] = conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM \"{name}\"").fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
counts[name] = None
|
||||||
|
print(f"db_path: {DATA_DB_PATH}")
|
||||||
|
print(f"version: {current} (latest {SCHEMA_VERSION})")
|
||||||
|
print("tables:")
|
||||||
|
for name, n in counts.items():
|
||||||
|
print(f" {name:<32} {n} rows")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.migrate or current < SCHEMA_VERSION:
|
||||||
|
before = current
|
||||||
|
apply_migrations(conn)
|
||||||
|
print(f"schema version {before} -> "
|
||||||
|
f"{conn.execute('PRAGMA user_version').fetchone()[0]}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"schema is already up to date (version {current})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""R001 — one-time backfill of legacy runtime data into the SQLite store.
|
||||||
|
|
||||||
|
Imports existing JSON/CSV/git-history data so the DB becomes the authoritative
|
||||||
|
source without losing anything already accumulated on a deployed instance:
|
||||||
|
|
||||||
|
- cumulative blocklist from the git-history reconstruction (old source of
|
||||||
|
truth) -> ``blocklist_entries`` (grow-only ledger)
|
||||||
|
- firewall snapshots from ``data/mikrotik/*.json`` -> ``firewall_snapshots``
|
||||||
|
- per-source requests from ``reports/<source>/data/requests.csv`` (best effort)
|
||||||
|
|
||||||
|
Idempotent: skips rows already present. Safe to run repeatedly.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m app.db.backfill [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..utils.constants import (
|
||||||
|
APP_REPORT_DIR,
|
||||||
|
APP_REPORT_DATA_DIR,
|
||||||
|
DATA_DIR,
|
||||||
|
MIKROTIK_DATA_DIR,
|
||||||
|
MIKROTIK_REPORT_DIR,
|
||||||
|
NPM_REPORT_DIR,
|
||||||
|
COPYPARTY_REPORT_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_REPORT_ARTIFACT_BYTES = 10 * 1024 * 1024
|
||||||
|
CANONICAL_REPORT_EXPORTS = {
|
||||||
|
"requests.csv", "ips.json", "ips_ipv4.csv", "ips_ipv6.csv",
|
||||||
|
"blocked_ipv4.txt", "blocked_ipv6.txt", "address_list_ipv4.json",
|
||||||
|
"address_list_ipv6.json", "firewall_ipv4.json", "firewall_ipv6.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cidr_lines(path: str) -> set[str]:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return {line.strip() for line in f if line.strip()}
|
||||||
|
except OSError:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_blocklist(conn, dry_run: bool = False) -> dict:
|
||||||
|
"""Import the legacy cumulative blocklist into the ledger."""
|
||||||
|
from . import repositories as repo
|
||||||
|
from ..utils.blocklist_history import (
|
||||||
|
committed_lines, current_file_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")
|
||||||
|
v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")
|
||||||
|
# Legacy source of truth: union of all git revisions + working file +
|
||||||
|
# (there is no earlier scan target to add here).
|
||||||
|
complete_v4 = committed_lines(v4_path) | current_file_lines(v4_path)
|
||||||
|
complete_v6 = committed_lines(v6_path) | current_file_lines(v6_path)
|
||||||
|
cidrs = sorted(complete_v4 | complete_v6)
|
||||||
|
if not cidrs:
|
||||||
|
return {"blocklist": {"cidrs": 0, "added": 0}}
|
||||||
|
if dry_run:
|
||||||
|
return {"blocklist": {"cidrs": len(cidrs), "added": None, "dry_run": True}}
|
||||||
|
result = repo.record_blocklist(conn, cidrs, added_by="backfill")
|
||||||
|
return {"blocklist": result}
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_firewall(conn, dry_run: bool = False) -> dict:
|
||||||
|
"""Import the latest firewall snapshot from ``data/mikrotik/*.json``."""
|
||||||
|
from . import repositories as repo
|
||||||
|
|
||||||
|
def _load(name: str) -> list:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(MIKROTIK_DATA_DIR, name), "r",
|
||||||
|
encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
addr_v4 = _load("address_list_ipv4.json")
|
||||||
|
addr_v6 = _load("address_list_ipv6.json")
|
||||||
|
fw_v4 = _load("firewall_ipv4.json")
|
||||||
|
fw_v6 = _load("firewall_ipv6.json")
|
||||||
|
if not (addr_v4 or addr_v6 or fw_v4 or fw_v6):
|
||||||
|
return {"firewall": {"dry_run": dry_run, "snapshot": None}}
|
||||||
|
# Idempotent: if a firewall snapshot already exists (previous backfill),
|
||||||
|
# don't create another copy of the same latest state.
|
||||||
|
n = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM firewall_snapshots").fetchone()[0]
|
||||||
|
if n:
|
||||||
|
return {"firewall": {"dry_run": dry_run, "snapshot": "exists",
|
||||||
|
"skipped": True}}
|
||||||
|
if dry_run:
|
||||||
|
return {"firewall": {"dry_run": True, "snapshot": None}}
|
||||||
|
sid = repo.save_firewall_snapshot(
|
||||||
|
conn, address_lists_v4=addr_v4, address_lists_v6=addr_v6,
|
||||||
|
rules_v4=fw_v4, rules_v6=fw_v6)
|
||||||
|
return {"firewall": {"snapshot": sid}}
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_requests(conn, dry_run: bool = False) -> dict:
|
||||||
|
"""Best-effort import of per-source ``requests.csv`` files.
|
||||||
|
|
||||||
|
Only ingests rows that don't already exist (keyed on source+log_file+ts+ip).
|
||||||
|
Legacy CSV columns vary by source; we keep a conservative mapping and never
|
||||||
|
fail the whole backfill over a single bad file.
|
||||||
|
"""
|
||||||
|
from . import repositories as repo
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT source, log_file, ts, ip FROM requests"):
|
||||||
|
seen.add((r["source"], r["log_file"], r["ts"], r["ip"]))
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
(os.path.join(COPYPARTY_REPORT_DIR, "data", "requests.csv"), "copyparty"),
|
||||||
|
(os.path.join(NPM_REPORT_DIR, "data", "requests.csv"), "npm"),
|
||||||
|
]
|
||||||
|
total = 0
|
||||||
|
for path, source in candidates:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
rows = []
|
||||||
|
for row in reader:
|
||||||
|
ip = (row.get("ip") or "").strip()
|
||||||
|
ts = row.get("ts") or row.get("timestamp") or ""
|
||||||
|
log_file = row.get("log_file") or row.get("file") or ""
|
||||||
|
if not ip:
|
||||||
|
continue
|
||||||
|
key = (source, log_file, ts, ip)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append({
|
||||||
|
"source": source, "scan_id": None, "log_file": log_file,
|
||||||
|
"ts": ts, "ip": ip,
|
||||||
|
"version": 4 if ":" not in ip else 6,
|
||||||
|
"method": row.get("method"), "path": row.get("path"),
|
||||||
|
"status": _int_or_none(row.get("status")),
|
||||||
|
"upstream_status": _int_or_none(
|
||||||
|
row.get("upstream_status")),
|
||||||
|
"host": row.get("host"), "user": row.get("user"),
|
||||||
|
"ua": row.get("ua") or row.get("user_agent"),
|
||||||
|
"referer": row.get("referer"),
|
||||||
|
"denied": _bool_str(row.get("denied")),
|
||||||
|
"banned": _bool_str(row.get("banned")),
|
||||||
|
"is_error": _bool_str(row.get("is_error")),
|
||||||
|
"extra": None,
|
||||||
|
})
|
||||||
|
total += 1
|
||||||
|
if not dry_run and total:
|
||||||
|
repo.insert_requests(conn, rows)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" WARN: requests.csv backfill {source}: {exc}")
|
||||||
|
return {"requests": {"imported": total, "dry_run": dry_run}}
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_report_artifacts(conn, dry_run: bool = False,
|
||||||
|
roots: dict[str, str] | None = None) -> dict:
|
||||||
|
"""Import supported report files as versioned, hash-addressed artifacts.
|
||||||
|
|
||||||
|
Canonical event exports and current firewall/blocklist data are skipped;
|
||||||
|
those belong in their normalized tables. Original files are never changed
|
||||||
|
or removed. Oversized or unreadable files are reported for manual review.
|
||||||
|
"""
|
||||||
|
from . import repositories as repo
|
||||||
|
|
||||||
|
roots = roots or {
|
||||||
|
"copyparty": COPYPARTY_REPORT_DIR,
|
||||||
|
"npm": NPM_REPORT_DIR,
|
||||||
|
"app": APP_REPORT_DIR,
|
||||||
|
"mikrotik": MIKROTIK_REPORT_DIR,
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"discovered": 0, "imported": 0, "would_import": 0,
|
||||||
|
"already_imported": 0, "skipped_canonical": [], "unmapped": [],
|
||||||
|
"by_source": {}, "by_format": {}, "dry_run": dry_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
def count(bucket: str, key: str, field: str) -> None:
|
||||||
|
stats = result[bucket].setdefault(key, {})
|
||||||
|
stats[field] = stats.get(field, 0) + 1
|
||||||
|
|
||||||
|
for source, root in roots.items():
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
continue
|
||||||
|
for directory, _, filenames in os.walk(root):
|
||||||
|
for filename in sorted(filenames):
|
||||||
|
path = os.path.join(directory, filename)
|
||||||
|
suffix = os.path.splitext(filename)[1].lower()
|
||||||
|
if suffix not in (".txt", ".json", ".csv", ".rsc"):
|
||||||
|
continue
|
||||||
|
relative = os.path.relpath(path, root).replace(os.sep, "/")
|
||||||
|
result["discovered"] += 1
|
||||||
|
report_format = suffix.lstrip(".")
|
||||||
|
count("by_source", source, "discovered")
|
||||||
|
count("by_format", report_format, "discovered")
|
||||||
|
if filename.lower() in CANONICAL_REPORT_EXPORTS:
|
||||||
|
result["skipped_canonical"].append(
|
||||||
|
{"source": source, "path": relative})
|
||||||
|
count("by_source", source, "skipped_canonical")
|
||||||
|
count("by_format", report_format, "skipped_canonical")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
if size > MAX_REPORT_ARTIFACT_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"file exceeds {MAX_REPORT_ARTIFACT_BYTES} bytes")
|
||||||
|
with open(path, "rb") as file:
|
||||||
|
raw = file.read()
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
payload = _report_payload(suffix, text)
|
||||||
|
digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
modified = datetime.fromtimestamp(
|
||||||
|
os.path.getmtime(path), timezone.utc)
|
||||||
|
generated_at = _report_generated_at(filename, modified)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
result["unmapped"].append(
|
||||||
|
{"source": source, "path": relative,
|
||||||
|
"reason": str(exc)})
|
||||||
|
count("by_source", source, "unmapped")
|
||||||
|
count("by_format", report_format, "unmapped")
|
||||||
|
continue
|
||||||
|
|
||||||
|
original_path = f"{source}/{relative}"
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM report_artifacts "
|
||||||
|
"WHERE original_path=? AND content_sha256=?",
|
||||||
|
(original_path, digest),
|
||||||
|
).fetchone()
|
||||||
|
if exists:
|
||||||
|
result["already_imported"] += 1
|
||||||
|
count("by_source", source, "already_imported")
|
||||||
|
count("by_format", report_format, "already_imported")
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
result["would_import"] += 1
|
||||||
|
count("by_source", source, "would_import")
|
||||||
|
count("by_format", report_format, "would_import")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo.insert_report_artifact(
|
||||||
|
conn, source=source,
|
||||||
|
report_type=_report_type(suffix),
|
||||||
|
generated_at=generated_at, payload=payload,
|
||||||
|
original_path=original_path,
|
||||||
|
content_sha256=digest,
|
||||||
|
)
|
||||||
|
result["imported"] += 1
|
||||||
|
count("by_source", source, "imported")
|
||||||
|
count("by_format", report_format, "imported")
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
result["unmapped"].append(
|
||||||
|
{"source": source, "path": relative,
|
||||||
|
"reason": str(exc)})
|
||||||
|
count("by_source", source, "unmapped")
|
||||||
|
count("by_format", report_format, "unmapped")
|
||||||
|
return {"reports": result}
|
||||||
|
|
||||||
|
|
||||||
|
def _report_payload(suffix: str, text: str) -> dict:
|
||||||
|
if suffix == ".json":
|
||||||
|
try:
|
||||||
|
return {"format": "json", "data": json.loads(text)}
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"format": "json", "content": text,
|
||||||
|
"parse_error": str(exc)}
|
||||||
|
if suffix == ".csv":
|
||||||
|
try:
|
||||||
|
reader = csv.DictReader(text.splitlines())
|
||||||
|
return {"format": "csv", "columns": reader.fieldnames or [],
|
||||||
|
"rows": list(reader)}
|
||||||
|
except csv.Error as exc:
|
||||||
|
return {"format": "csv", "content": text,
|
||||||
|
"parse_error": str(exc)}
|
||||||
|
return {"format": "text", "content": text}
|
||||||
|
|
||||||
|
|
||||||
|
def _report_type(suffix: str) -> str:
|
||||||
|
return {".json": "json_report", ".csv": "csv_report",
|
||||||
|
".rsc": "routeros_script"}.get(suffix, "text_report")
|
||||||
|
|
||||||
|
|
||||||
|
def _report_generated_at(filename: str, modified: datetime) -> str:
|
||||||
|
match = re.search(
|
||||||
|
r"(?P<date>\d{4}-\d{2}-\d{2})(?:[-_T](?P<hour>\d{2})[-_:]"
|
||||||
|
r"(?P<minute>\d{2})[-_:](?P<second>\d{2}))?", filename)
|
||||||
|
if not match:
|
||||||
|
return modified.isoformat(timespec="seconds")
|
||||||
|
values = match.groupdict()
|
||||||
|
hour = int(values["hour"] or 0)
|
||||||
|
minute = int(values["minute"] or 0)
|
||||||
|
second = int(values["second"] or 0)
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(values["date"], "%Y-%m-%d").replace(
|
||||||
|
hour=hour, minute=minute, second=second, tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return modified.isoformat(timespec="seconds")
|
||||||
|
return parsed.isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(v):
|
||||||
|
try:
|
||||||
|
if v in (None, "", "-"):
|
||||||
|
return None
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_str(v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return int(v.strip().lower() in ("1", "true", "yes", "t"))
|
||||||
|
return int(bool(v))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
p = argparse.ArgumentParser(prog="python -m app.db.backfill",
|
||||||
|
description="Backfill legacy runtime data into SQLite.")
|
||||||
|
p.add_argument("--dry-run", action="store_true",
|
||||||
|
help="report what would be imported without writing")
|
||||||
|
p.add_argument("--reports-only", action="store_true",
|
||||||
|
help="import legacy report artifacts only")
|
||||||
|
opts = p.parse_args(argv)
|
||||||
|
|
||||||
|
from . import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
print(f"== backfill (dry_run={opts.dry_run}) ==")
|
||||||
|
if opts.reports_only:
|
||||||
|
print("reports :", backfill_report_artifacts(
|
||||||
|
conn, dry_run=opts.dry_run))
|
||||||
|
print("== done ==")
|
||||||
|
return
|
||||||
|
print("blocklist:", backfill_blocklist(conn, dry_run=opts.dry_run))
|
||||||
|
print("firewall :", backfill_firewall(conn, dry_run=opts.dry_run))
|
||||||
|
print("requests :", backfill_requests(conn, dry_run=opts.dry_run))
|
||||||
|
print("reports :", backfill_report_artifacts(conn, dry_run=opts.dry_run))
|
||||||
|
print("== done ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
#!/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
|
||||||
|
import base64
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import ipaddress
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Report artifacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def insert_report_artifact(conn: sqlite3.Connection, *, source: str,
|
||||||
|
report_type: str, generated_at: str,
|
||||||
|
payload: Any, original_path: str,
|
||||||
|
content_sha256: str, scan_id: int | None = None,
|
||||||
|
schema_version: int = 1) -> int:
|
||||||
|
"""Insert a structured report artifact, idempotent by path and hash."""
|
||||||
|
with transaction(conn):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO report_artifacts "
|
||||||
|
"(source, scan_id, report_type, generated_at, schema_version, "
|
||||||
|
"payload_json, original_path, content_sha256, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(source, scan_id, report_type, generated_at, schema_version,
|
||||||
|
json.dumps(payload, ensure_ascii=False), original_path,
|
||||||
|
content_sha256, utcnow()),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM report_artifacts "
|
||||||
|
"WHERE original_path=? AND content_sha256=?",
|
||||||
|
(original_path, content_sha256),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def list_report_artifacts(conn: sqlite3.Connection, *,
|
||||||
|
source: str | None = None,
|
||||||
|
report_type: str | None = None,
|
||||||
|
since: str | None = None,
|
||||||
|
until: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
cursor: str | None = None) -> dict[str, Any]:
|
||||||
|
"""List report metadata using bounded keyset pagination."""
|
||||||
|
limit = min(max(int(limit), 1), 200)
|
||||||
|
clauses: list[str] = []
|
||||||
|
args: list[Any] = []
|
||||||
|
if source:
|
||||||
|
clauses.append("source=?")
|
||||||
|
args.append(source)
|
||||||
|
if report_type:
|
||||||
|
clauses.append("report_type=?")
|
||||||
|
args.append(report_type)
|
||||||
|
if since:
|
||||||
|
clauses.append("generated_at>=?")
|
||||||
|
args.append(since)
|
||||||
|
if until:
|
||||||
|
clauses.append("generated_at<=?")
|
||||||
|
args.append(until)
|
||||||
|
if cursor:
|
||||||
|
generated_at, artifact_id = _decode_report_cursor(cursor)
|
||||||
|
clauses.append("(generated_at<? OR (generated_at=? AND id<?))")
|
||||||
|
args.extend((generated_at, generated_at, artifact_id))
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
"SELECT id, source, scan_id, report_type, generated_at, "
|
||||||
|
"schema_version, original_path, content_sha256 "
|
||||||
|
"FROM report_artifacts"
|
||||||
|
)
|
||||||
|
if clauses:
|
||||||
|
sql += " WHERE " + " AND ".join(clauses)
|
||||||
|
sql += " ORDER BY generated_at DESC, id DESC LIMIT ?"
|
||||||
|
rows = [dict(row) for row in conn.execute(sql, (*args, limit + 1))]
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
next_cursor = (
|
||||||
|
_encode_report_cursor(rows[-1]["generated_at"], rows[-1]["id"])
|
||||||
|
if has_more and rows else None
|
||||||
|
)
|
||||||
|
return {"items": rows, "next_cursor": next_cursor}
|
||||||
|
|
||||||
|
|
||||||
|
def get_report_artifact(conn: sqlite3.Connection,
|
||||||
|
artifact_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Fetch one artifact and decode its structured payload."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, source, scan_id, report_type, generated_at, "
|
||||||
|
"schema_version, payload_json, original_path, content_sha256, created_at "
|
||||||
|
"FROM report_artifacts WHERE id=?", (artifact_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
artifact = dict(row)
|
||||||
|
artifact["payload"] = json.loads(artifact.pop("payload_json"))
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_report_cursor(generated_at: str, artifact_id: int) -> str:
|
||||||
|
raw = f"{generated_at}\n{artifact_id}".encode("utf-8")
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_report_cursor(cursor: str) -> tuple[str, int]:
|
||||||
|
try:
|
||||||
|
padded = cursor + "=" * (-len(cursor) % 4)
|
||||||
|
generated_at, artifact_id = base64.urlsafe_b64decode(
|
||||||
|
padded.encode("ascii")).decode("utf-8").split("\n", 1)
|
||||||
|
return generated_at, int(artifact_id)
|
||||||
|
except (ValueError, UnicodeError) as exc:
|
||||||
|
raise ValueError("invalid report cursor") from exc
|
||||||
|
|
||||||
|
|
||||||
|
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 record_blocklist(conn: sqlite3.Connection, cidrs: Sequence[str], *,
|
||||||
|
added_by: str | None = None, reasons=None) -> dict[str, Any]:
|
||||||
|
"""Grow-only record: add any ``cidrs`` not already active to the ledger.
|
||||||
|
|
||||||
|
Unlike :func:`sync_blocklist`, this **never removes** entries. It mirrors
|
||||||
|
the router's cumulative model: once an IP/subnet has ever been blocked it
|
||||||
|
stays active even after it leaves the scan window (e.g. NPM ~30-day log
|
||||||
|
retention), exactly like the old git-history backfill. Returns the added
|
||||||
|
delta and the resulting active count.
|
||||||
|
"""
|
||||||
|
cidr_set = set(cidrs)
|
||||||
|
before = {r["cidr"] for r in active_blocklist(conn)}
|
||||||
|
to_add = sorted(cidr_set - before)
|
||||||
|
now = utcnow()
|
||||||
|
with transaction(conn):
|
||||||
|
for cidr in to_add:
|
||||||
|
_block_entry_inner(conn, cidr, added_by=added_by, last_seen=now,
|
||||||
|
reasons=reasons)
|
||||||
|
# Refresh last_seen on entries that are still desired so the ledger
|
||||||
|
# reflects recency for existing rows too.
|
||||||
|
conn.executemany(
|
||||||
|
"UPDATE blocklist_entries SET last_seen=? WHERE cidr=? AND status='active'",
|
||||||
|
[(now, cidr) for cidr in cidr_set.intersection(before)])
|
||||||
|
return {"added": to_add, "active": len(before) + len(to_add)}
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_single_ip_blocks(conn: sqlite3.Connection, *,
|
||||||
|
older_than_days: int = 30,
|
||||||
|
actor: str | None = "retention") -> list[str]:
|
||||||
|
"""Remove active single-host entries older than ``older_than_days``.
|
||||||
|
|
||||||
|
Keeps CIDR ranges/subnets intact. Host entries are detected via
|
||||||
|
``ipaddress.ip_network(..., strict=False)`` and only removed when the
|
||||||
|
network is exactly one address (/32 for IPv4, /128 for IPv6).
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - \
|
||||||
|
timedelta(days=max(0, older_than_days))
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT cidr, added_at, last_seen FROM blocklist_entries "
|
||||||
|
"WHERE status='active'").fetchall()
|
||||||
|
|
||||||
|
to_remove: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
cidr = str(row["cidr"])
|
||||||
|
if not _is_single_host_cidr(cidr):
|
||||||
|
continue
|
||||||
|
seen = _parse_iso_ts(
|
||||||
|
row["last_seen"]) or _parse_iso_ts(row["added_at"])
|
||||||
|
if seen is None:
|
||||||
|
continue
|
||||||
|
if seen < cutoff:
|
||||||
|
to_remove.append(cidr)
|
||||||
|
|
||||||
|
if not to_remove:
|
||||||
|
return []
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
with transaction(conn):
|
||||||
|
conn.executemany(
|
||||||
|
"UPDATE blocklist_entries SET status='removed', removed_at=? "
|
||||||
|
"WHERE cidr=? AND status='active'",
|
||||||
|
[(now, cidr) for cidr in to_remove],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO blocklist_events (action, cidr, actor, fired_at, detail) "
|
||||||
|
"VALUES ('remove', ?, ?, ?, ?)",
|
||||||
|
[(cidr, actor, now, f"single-ip-retention>{older_than_days}d")
|
||||||
|
for cidr in to_remove],
|
||||||
|
)
|
||||||
|
return sorted(to_remove)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_blocklist(conn: sqlite3.Connection, desired_cidrs: Sequence[str], *,
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _is_single_host_cidr(cidr: str) -> bool:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
return net.num_addresses == 1
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_ts(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Whitelists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def recent_firewall_snapshots(conn: sqlite3.Connection,
|
||||||
|
limit: int = 10) -> list[dict[str, Any]]:
|
||||||
|
"""Most recent firewall snapshots with entry counts (F001)."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT fs.id, fs.captured_at, fs.kind, fs.trigger, "
|
||||||
|
"(SELECT COUNT(*) FROM firewall_address_list_entries e "
|
||||||
|
" WHERE e.snapshot_id = fs.id) AS addr_entries, "
|
||||||
|
"(SELECT COUNT(*) FROM firewall_rule_entries r "
|
||||||
|
" WHERE r.snapshot_id = fs.id) AS rule_entries "
|
||||||
|
"FROM firewall_snapshots fs ORDER BY fs.id DESC LIMIT ?",
|
||||||
|
(limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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,284 @@
|
|||||||
|
#!/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 = 2
|
||||||
|
|
||||||
|
#: 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
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
],
|
||||||
|
|
||||||
|
2: [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS report_artifacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
scan_id INTEGER,
|
||||||
|
report_type TEXT NOT NULL,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
content_sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (original_path, content_sha256)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_report_artifacts_source_time "
|
||||||
|
"ON report_artifacts (source, generated_at DESC, id DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_report_artifacts_type_time "
|
||||||
|
"ON report_artifacts (report_type, generated_at DESC, id DESC)",
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
+388
-349
@@ -1,349 +1,388 @@
|
|||||||
"""
|
"""
|
||||||
firewall.py -
|
firewall.py -
|
||||||
Module for interacting with the Mikrotik routeros shell to manage firewall rules and IP address lists.
|
Module for interacting with the Mikrotik routeros shell to manage firewall rules and IP address lists.
|
||||||
Provides functions to read the current firewall configuration, add and remove IPs from the blocklist.
|
Provides functions to read the current firewall configuration, add and remove IPs from the blocklist.
|
||||||
Reads the latest report from the IDS scan, reads the current firewall configuration, and updates the router's blocklist accordingly.
|
Reads the latest report from the IDS scan, reads the current firewall configuration, and updates the router's blocklist accordingly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import paramiko
|
import paramiko
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from .constants import (
|
from .utils.constants import (
|
||||||
BLOCKLIST_NAME,
|
BLOCKLIST_NAME,
|
||||||
MIKROTIK_DATA_DIR,
|
MIKROTIK_DATA_DIR,
|
||||||
REPORT_DIR,
|
REPORT_DIR,
|
||||||
ROUTER_IP,
|
ROUTER_IP,
|
||||||
ROUTER_PASSWORD,
|
ROUTER_PASSWORD,
|
||||||
ROUTER_USER,
|
ROUTER_USER,
|
||||||
SSH_PORT,
|
SSH_PORT,
|
||||||
SSH_TIMEOUT,
|
SSH_TIMEOUT,
|
||||||
WHITELIST_NAMES,
|
WHITELIST_NAMES,
|
||||||
)
|
)
|
||||||
from .files import write_lines
|
from .utils.files import write_lines
|
||||||
from .ip_utils import is_whitelisted
|
from .utils.ip_utils import is_whitelisted
|
||||||
|
from .utils.blocklist_history import compute_changes, cumulative_blocklist
|
||||||
|
|
||||||
class MikrotikFirewall:
|
|
||||||
"""
|
class MikrotikFirewall:
|
||||||
Class to manage Mikrotik firewall rules and IP address lists.
|
"""
|
||||||
Provides methods to read the current blocklist, add/remove IPs, and update the blocklist based on IDS scan reports.
|
Class to manage Mikrotik firewall rules and IP address lists.
|
||||||
"""
|
Provides methods to read the current blocklist, add/remove IPs, and update the blocklist based on IDS scan reports.
|
||||||
|
"""
|
||||||
def __init__(self, router_ip: str = ROUTER_IP, username: str = ROUTER_USER,
|
|
||||||
password: str = ROUTER_PASSWORD):
|
def __init__(self, router_ip: str = ROUTER_IP, username: str = ROUTER_USER,
|
||||||
self.router_ip = router_ip
|
password: str = ROUTER_PASSWORD):
|
||||||
self.username = username
|
self.router_ip = router_ip
|
||||||
self.password = password
|
self.username = username
|
||||||
self.client: paramiko.SSHClient | None = None
|
self.password = password
|
||||||
|
self.client: paramiko.SSHClient | None = None
|
||||||
def connect(self) -> None:
|
|
||||||
"""Establish an SSH connection to the Mikrotik router."""
|
def connect(self) -> None:
|
||||||
client = paramiko.SSHClient()
|
"""Establish an SSH connection to the Mikrotik router."""
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
client = paramiko.SSHClient()
|
||||||
try:
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
# RouterOS 7.20.x exposes an RSA host key and classic SSH ciphers.
|
try:
|
||||||
client.connect(
|
# RouterOS 7.20.x exposes an RSA host key and classic SSH ciphers.
|
||||||
self.router_ip,
|
client.connect(
|
||||||
port=SSH_PORT,
|
self.router_ip,
|
||||||
username=self.username,
|
port=SSH_PORT,
|
||||||
password=self.password,
|
username=self.username,
|
||||||
timeout=SSH_TIMEOUT,
|
password=self.password,
|
||||||
banner_timeout=SSH_TIMEOUT,
|
timeout=SSH_TIMEOUT,
|
||||||
auth_timeout=SSH_TIMEOUT,
|
banner_timeout=SSH_TIMEOUT,
|
||||||
allow_agent=False,
|
auth_timeout=SSH_TIMEOUT,
|
||||||
look_for_keys=False,
|
allow_agent=False,
|
||||||
compress=False,
|
look_for_keys=False,
|
||||||
disabled_algorithms={"pubkeys": ["rsa-sha2-512"]},
|
compress=False,
|
||||||
)
|
disabled_algorithms={"pubkeys": ["rsa-sha2-512"]},
|
||||||
except Exception:
|
)
|
||||||
client.close()
|
except Exception:
|
||||||
raise
|
client.close()
|
||||||
self.client = client
|
raise
|
||||||
|
self.client = client
|
||||||
def disconnect(self) -> None:
|
|
||||||
"""Close the SSH connection."""
|
def disconnect(self) -> None:
|
||||||
if self.client:
|
"""Close the SSH connection."""
|
||||||
self.client.close()
|
if self.client:
|
||||||
self.client = None
|
self.client.close()
|
||||||
|
self.client = None
|
||||||
def execute_command(self, command: str) -> str:
|
|
||||||
"""Execute a command on the Mikrotik router via SSH and return the output."""
|
def execute_command(self, command: str) -> str:
|
||||||
if not self.client:
|
"""Execute a command on the Mikrotik router via SSH and return the output."""
|
||||||
raise RuntimeError("SSH client is not connected.")
|
if not self.client:
|
||||||
stdin, stdout, stderr = self.client.exec_command(command)
|
raise RuntimeError("SSH client is not connected.")
|
||||||
output = stdout.read().decode("utf-8")
|
stdin, stdout, stderr = self.client.exec_command(command)
|
||||||
error = stderr.read().decode("utf-8")
|
output = stdout.read().decode("utf-8")
|
||||||
if error:
|
error = stderr.read().decode("utf-8")
|
||||||
raise RuntimeError(f"Error executing command '{command}': {error}")
|
if error:
|
||||||
return output
|
raise RuntimeError(f"Error executing command '{command}': {error}")
|
||||||
|
return output
|
||||||
def get_address_list_ipv4(self, name: str) -> set[str]:
|
|
||||||
"""Retrieve the current IPv4 address list from the Mikrotik router."""
|
def get_address_list_ipv4(self, name: str) -> set[str]:
|
||||||
output = self.execute_command("/ip firewall address-list print")
|
"""Retrieve the current IPv4 address list from the Mikrotik router."""
|
||||||
ips = set()
|
output = self.execute_command("/ip firewall address-list print")
|
||||||
for line in output.splitlines():
|
ips = set()
|
||||||
parts = line.split()
|
for line in output.splitlines():
|
||||||
if len(parts) >= 3 and parts[1] == "list" and parts[2] == name:
|
parts = line.split()
|
||||||
ip = parts[0]
|
if len(parts) >= 3 and parts[1] == "list" and parts[2] == name:
|
||||||
ips.add(ip)
|
ip = parts[0]
|
||||||
return ips
|
ips.add(ip)
|
||||||
|
return ips
|
||||||
def get_address_list_ipv6(self, name: str) -> set[str]:
|
|
||||||
"""Retrieve the current IPv6 address list from the Mikrotik router."""
|
def get_address_list_ipv6(self, name: str) -> set[str]:
|
||||||
output = self.execute_command("/ipv6 firewall address-list print")
|
"""Retrieve the current IPv6 address list from the Mikrotik router."""
|
||||||
ips = set()
|
output = self.execute_command("/ipv6 firewall address-list print")
|
||||||
for line in output.splitlines():
|
ips = set()
|
||||||
parts = line.split()
|
for line in output.splitlines():
|
||||||
if len(parts) >= 3 and parts[1] == "list" and parts[2] == name:
|
parts = line.split()
|
||||||
ip = parts[0]
|
if len(parts) >= 3 and parts[1] == "list" and parts[2] == name:
|
||||||
ips.add(ip)
|
ip = parts[0]
|
||||||
return ips
|
ips.add(ip)
|
||||||
|
return ips
|
||||||
@staticmethod
|
|
||||||
def _parse_terse_output(output: str) -> list[dict[str, str]]:
|
@staticmethod
|
||||||
"""Parse RouterOS `print terse` records into key/value dictionaries."""
|
def _parse_terse_output(output: str) -> list[dict[str, str]]:
|
||||||
records = []
|
"""Parse RouterOS `print terse` records into key/value dictionaries."""
|
||||||
for line in output.splitlines():
|
records = []
|
||||||
fields = {}
|
for line in output.splitlines():
|
||||||
for field in shlex.split(line):
|
fields = {}
|
||||||
key, separator, value = field.partition("=")
|
for field in shlex.split(line):
|
||||||
if separator:
|
key, separator, value = field.partition("=")
|
||||||
fields[key] = value
|
if separator:
|
||||||
if fields:
|
fields[key] = value
|
||||||
records.append(fields)
|
if fields:
|
||||||
return records
|
records.append(fields)
|
||||||
|
return records
|
||||||
def get_address_lists(self, ipv6: bool = False) -> list[dict[str, str]]:
|
|
||||||
"""Retrieve all address-list entries in structured form."""
|
def get_address_lists(self, ipv6: bool = False) -> list[dict[str, str]]:
|
||||||
path = "/ipv6" if ipv6 else "/ip"
|
"""Retrieve all address-list entries in structured form."""
|
||||||
output = self.execute_command(
|
path = "/ipv6" if ipv6 else "/ip"
|
||||||
f"{path} firewall address-list print terse")
|
output = self.execute_command(
|
||||||
return self._parse_terse_output(output)
|
f"{path} firewall address-list print terse")
|
||||||
|
return self._parse_terse_output(output)
|
||||||
def get_firewall_rules(self, ipv6: bool = False) -> list[dict[str, str]]:
|
|
||||||
"""Retrieve all firewall filter rules in structured form."""
|
def get_firewall_rules(self, ipv6: bool = False) -> list[dict[str, str]]:
|
||||||
path = "/ipv6" if ipv6 else "/ip"
|
"""Retrieve all firewall filter rules in structured form."""
|
||||||
output = self.execute_command(
|
path = "/ipv6" if ipv6 else "/ip"
|
||||||
f"{path} firewall filter print terse")
|
output = self.execute_command(
|
||||||
return self._parse_terse_output(output)
|
f"{path} firewall filter print terse")
|
||||||
|
return self._parse_terse_output(output)
|
||||||
@staticmethod
|
|
||||||
def _write_json(path: str, obj) -> None:
|
@staticmethod
|
||||||
with open(path, "w", encoding="utf-8") as file:
|
def _write_json(path: str, obj) -> None:
|
||||||
json.dump(obj, file, indent=2, sort_keys=True)
|
with open(path, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(obj, file, indent=2, sort_keys=True)
|
||||||
def save_state_report(self, report_dir: str = REPORT_DIR,
|
|
||||||
changes: dict | None = None) -> str:
|
def save_state_report(self, report_dir: str = REPORT_DIR,
|
||||||
"""Save current address lists and firewall rules for auditing.
|
changes: dict | None = None) -> str:
|
||||||
|
"""Save current address lists and firewall rules for auditing.
|
||||||
Writes structured snapshots to ``<report_dir>/mikrotik/data/`` and to
|
|
||||||
the common ``data/mikrotik/`` directory (overwritten every run so git
|
Writes structured snapshots to ``<report_dir>/mikrotik/data/`` and to
|
||||||
can diff changes to the firewall state over time), plus a timestamped
|
the common ``data/mikrotik/`` directory (scratch mirror for debugging;
|
||||||
JSON report under ``<report_dir>/mikrotik/``.
|
**not** a git audit trail), plus a timestamped JSON report under
|
||||||
"""
|
``<report_dir>/mikrotik/``. The authoritative long-term audit trail is
|
||||||
state = {
|
the SQLite ``firewall_snapshots`` tables (see ``app.db``), which are
|
||||||
"router": self.router_ip,
|
appended here regardless of the file writes.
|
||||||
"collected_at": datetime.now().astimezone().isoformat(),
|
"""
|
||||||
"address_lists": {
|
state = {
|
||||||
"ipv4": self.get_address_lists(),
|
"router": self.router_ip,
|
||||||
"ipv6": self.get_address_lists(ipv6=True),
|
"collected_at": datetime.now().astimezone().isoformat(),
|
||||||
},
|
"address_lists": {
|
||||||
"firewall_rules": {
|
"ipv4": self.get_address_lists(),
|
||||||
"ipv4": self.get_firewall_rules(),
|
"ipv6": self.get_address_lists(ipv6=True),
|
||||||
"ipv6": self.get_firewall_rules(ipv6=True),
|
},
|
||||||
},
|
"firewall_rules": {
|
||||||
}
|
"ipv4": self.get_firewall_rules(),
|
||||||
if changes is not None:
|
"ipv6": self.get_firewall_rules(ipv6=True),
|
||||||
state["changes"] = changes
|
},
|
||||||
|
}
|
||||||
report_data_dir = os.path.join(report_dir, "mikrotik", "data")
|
if changes is not None:
|
||||||
os.makedirs(report_data_dir, exist_ok=True)
|
state["changes"] = changes
|
||||||
os.makedirs(MIKROTIK_DATA_DIR, exist_ok=True)
|
|
||||||
for version in ("ipv4", "ipv6"):
|
report_data_dir = os.path.join(report_dir, "mikrotik", "data")
|
||||||
for data_dir in (report_data_dir, MIKROTIK_DATA_DIR):
|
os.makedirs(report_data_dir, exist_ok=True)
|
||||||
self._write_json(
|
os.makedirs(MIKROTIK_DATA_DIR, exist_ok=True)
|
||||||
os.path.join(data_dir, f"address_list_{version}.json"),
|
for version in ("ipv4", "ipv6"):
|
||||||
state["address_lists"][version])
|
for data_dir in (report_data_dir, MIKROTIK_DATA_DIR):
|
||||||
self._write_json(
|
self._write_json(
|
||||||
os.path.join(data_dir, f"firewall_{version}.json"),
|
os.path.join(data_dir, f"address_list_{version}.json"),
|
||||||
state["firewall_rules"][version])
|
state["address_lists"][version])
|
||||||
|
self._write_json(
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
os.path.join(data_dir, f"firewall_{version}.json"),
|
||||||
report_path = os.path.join(report_dir, "mikrotik", f"{timestamp}.json")
|
state["firewall_rules"][version])
|
||||||
self._write_json(report_path, state)
|
|
||||||
return report_path
|
# Persist a SQLite snapshot (authoritative audit trail, independent of
|
||||||
|
# git). Best-effort: never breaks a router sync if the DB is down.
|
||||||
def sync_blocklist(self, blocked_ipv4: Iterable[str],
|
self._persist_snapshot_to_db(state)
|
||||||
blocked_ipv6: Iterable[str],
|
|
||||||
dry_run: bool = False,
|
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
||||||
report_dir: str = REPORT_DIR) -> dict[str, list[str]]:
|
report_path = os.path.join(report_dir, "mikrotik", f"{timestamp}.json")
|
||||||
"""Synchronize the Blocked list with scan targets and audit the result."""
|
self._write_json(report_path, state)
|
||||||
desired_ipv4 = set(blocked_ipv4)
|
return report_path
|
||||||
desired_ipv6 = set(blocked_ipv6)
|
|
||||||
current_ipv4 = self.get_address_list_ipv4(BLOCKLIST_NAME)
|
@staticmethod
|
||||||
current_ipv6 = self.get_address_list_ipv6(BLOCKLIST_NAME)
|
def _persist_snapshot_to_db(state: dict) -> None:
|
||||||
whitelisted_ipv4 = set()
|
"""Write the firewall snapshot into the SQLite store (best-effort)."""
|
||||||
whitelisted_ipv6 = set()
|
try:
|
||||||
for name in WHITELIST_NAMES:
|
from .db import get_connection
|
||||||
whitelisted_ipv4.update(self.get_address_list_ipv4(name))
|
from .db import repositories as repo
|
||||||
whitelisted_ipv6.update(self.get_address_list_ipv6(name))
|
repo.save_firewall_snapshot(
|
||||||
|
get_connection(),
|
||||||
changes = {
|
address_lists_v4=state["address_lists"]["ipv4"],
|
||||||
"added_ipv4": sorted(desired_ipv4 - current_ipv4 - whitelisted_ipv4),
|
address_lists_v6=state["address_lists"]["ipv6"],
|
||||||
"added_ipv6": sorted(desired_ipv6 - current_ipv6 - whitelisted_ipv6),
|
rules_v4=state["firewall_rules"]["ipv4"],
|
||||||
"removed_ipv4": sorted(current_ipv4 - desired_ipv4),
|
rules_v6=state["firewall_rules"]["ipv6"],
|
||||||
"removed_ipv6": sorted(current_ipv6 - desired_ipv6),
|
)
|
||||||
"dry_run": dry_run,
|
except Exception as exc:
|
||||||
}
|
print(f"WARN: could not persist firewall snapshot to db ({exc})")
|
||||||
if not dry_run:
|
|
||||||
for ip in changes["removed_ipv4"]:
|
def sync_blocklist(self, blocked_ipv4: Iterable[str],
|
||||||
self.execute_command(
|
blocked_ipv6: Iterable[str],
|
||||||
f"/ip firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
dry_run: bool = False,
|
||||||
for ip in changes["removed_ipv6"]:
|
report_dir: str = REPORT_DIR) -> dict[str, list[str]]:
|
||||||
self.execute_command(
|
"""Synchronize the Blocked list cumulatively and audit the result.
|
||||||
f"/ipv6 firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
|
||||||
self.write_address_list_ipv4(BLOCKLIST_NAME, changes["added_ipv4"])
|
The blocklist is grow-only: targets are added when present in the
|
||||||
self.write_address_list_ipv6(BLOCKLIST_NAME, changes["added_ipv6"])
|
complete cumulative set (git history of the blocklist files + the
|
||||||
self.save_state_report(report_dir, changes)
|
current working files + this scan's targets) and never removed
|
||||||
return changes
|
automatically. Only router entries absent from the complete set — i.e.
|
||||||
|
never blocked by the system — are pruned. The history now lives in the
|
||||||
def write_address_list_ipv4(self, name: str, ips: Iterable[str]) -> None:
|
SQLite blocklist ledger (``app.db`` ``blocklist_entries``); git history
|
||||||
"""Write a list of IPv4 addresses to the Mikrotik router's address list."""
|
remains a read-only fallback. See ``app.blocklist_history``.
|
||||||
for ip in ips:
|
"""
|
||||||
if is_whitelisted(ip):
|
|
||||||
continue # Skip whitelisted IPs
|
desired_ipv4 = set(blocked_ipv4)
|
||||||
self.execute_command(
|
desired_ipv6 = set(blocked_ipv6)
|
||||||
f"/ip firewall address-list add list={name} address={ip}")
|
current_ipv4 = self.get_address_list_ipv4(BLOCKLIST_NAME)
|
||||||
|
current_ipv6 = self.get_address_list_ipv6(BLOCKLIST_NAME)
|
||||||
def write_address_list_ipv6(self, name: str, ips: Iterable[str]) -> None:
|
whitelisted_ipv4 = set()
|
||||||
"""Write a list of IPv6 addresses to the Mikrotik router's address list."""
|
whitelisted_ipv6 = set()
|
||||||
for ip in ips:
|
for name in WHITELIST_NAMES:
|
||||||
if is_whitelisted(ip):
|
whitelisted_ipv4.update(self.get_address_list_ipv4(name))
|
||||||
continue # Skip whitelisted IPs
|
whitelisted_ipv6.update(self.get_address_list_ipv6(name))
|
||||||
self.execute_command(
|
|
||||||
f"/ipv6 firewall address-list add list={name} address={ip}")
|
v4_path = os.path.join(
|
||||||
|
report_dir, "app", "data", "blocklist_ipv4.txt")
|
||||||
|
v6_path = os.path.join(
|
||||||
def write_blocklist_files(blocked_ipv4: Iterable[str],
|
report_dir, "app", "data", "blocklist_ipv6.txt")
|
||||||
blocked_ipv6: Iterable[str],
|
complete_v4, complete_v6 = cumulative_blocklist(
|
||||||
report_dir: str = REPORT_DIR) -> tuple[str, str]:
|
v4_path, v6_path, desired_ipv4, desired_ipv6)
|
||||||
"""Write the final consolidated blocklist to ``<report_dir>/app/data/``.
|
|
||||||
|
changes = compute_changes(
|
||||||
Returns the paths of the IPv4 and IPv6 blocklist files
|
current_ipv4, current_ipv6,
|
||||||
(``blocklist_ipv4.txt`` / ``blocklist_ipv6.txt``).
|
complete_v4, complete_v6,
|
||||||
"""
|
whitelisted_ipv4, whitelisted_ipv6)
|
||||||
data_dir = os.path.join(report_dir, "app", "data")
|
changes["dry_run"] = dry_run
|
||||||
os.makedirs(data_dir, exist_ok=True)
|
|
||||||
v4_path = os.path.join(data_dir, "blocklist_ipv4.txt")
|
if not dry_run:
|
||||||
v6_path = os.path.join(data_dir, "blocklist_ipv6.txt")
|
for ip in changes["removed_ipv4"]:
|
||||||
write_lines(v4_path, sorted(blocked_ipv4))
|
self.execute_command(
|
||||||
write_lines(v6_path, sorted(blocked_ipv6))
|
f"/ip firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
||||||
return v4_path, v6_path
|
for ip in changes["removed_ipv6"]:
|
||||||
|
self.execute_command(
|
||||||
|
f"/ipv6 firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
||||||
def get_scan_report(report_dir: str = REPORT_DIR) -> dict[str, list[str]]:
|
self.write_address_list_ipv4(BLOCKLIST_NAME, changes["added_ipv4"])
|
||||||
"""Read the latest scan report `mikrotik-block.rsc` from the report directory and return the list of blocked IPs."""
|
self.write_address_list_ipv6(BLOCKLIST_NAME, changes["added_ipv6"])
|
||||||
report_path = os.path.join(report_dir, "mikrotik-block.rsc")
|
self.save_state_report(report_dir, changes)
|
||||||
if not os.path.exists(report_path):
|
return changes
|
||||||
raise FileNotFoundError(f"Scan report not found: {report_path}")
|
|
||||||
|
def write_address_list_ipv4(self, name: str, ips: Iterable[str]) -> None:
|
||||||
# add sections, they start with `/ip firewall address-list` or `/ipv6 firewall address-list` and end with a blank line or EOF
|
"""Write a list of IPv4 addresses to the Mikrotik router's address list."""
|
||||||
sections = {
|
for ip in ips:
|
||||||
"/ip firewall address-list": [],
|
if is_whitelisted(ip):
|
||||||
"/ipv6 firewall address-list": [],
|
continue # Skip whitelisted IPs
|
||||||
}
|
self.execute_command(
|
||||||
# add list=Cloudflare or list=Openfactory to whitelist sections, list=Blocked to blocked section
|
f"/ip firewall address-list add list={name} address={ip}")
|
||||||
with open(report_path, "r", encoding="utf-8") as f:
|
|
||||||
current_section = None
|
def write_address_list_ipv6(self, name: str, ips: Iterable[str]) -> None:
|
||||||
for line in f:
|
"""Write a list of IPv6 addresses to the Mikrotik router's address list."""
|
||||||
line = line.strip()
|
for ip in ips:
|
||||||
if not line:
|
if is_whitelisted(ip):
|
||||||
current_section = None
|
continue # Skip whitelisted IPs
|
||||||
continue
|
self.execute_command(
|
||||||
if line.startswith("/ip firewall address-list"):
|
f"/ipv6 firewall address-list add list={name} address={ip}")
|
||||||
current_section = "/ip firewall address-list"
|
|
||||||
continue
|
|
||||||
if line.startswith("/ipv6 firewall address-list"):
|
def write_blocklist_files(blocked_ipv4: Iterable[str],
|
||||||
current_section = "/ipv6 firewall address-list"
|
blocked_ipv6: Iterable[str],
|
||||||
continue
|
report_dir: str = REPORT_DIR) -> tuple[str, str]:
|
||||||
if current_section:
|
"""Write the final consolidated blocklist to ``<report_dir>/app/data/``.
|
||||||
sections[current_section].append(line)
|
|
||||||
|
Returns the paths of the IPv4 and IPv6 blocklist files
|
||||||
return sections
|
(``blocklist_ipv4.txt`` / ``blocklist_ipv6.txt``).
|
||||||
|
"""
|
||||||
|
data_dir = os.path.join(report_dir, "app", "data")
|
||||||
def main():
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
"""Legacy entry point for applying the latest generated RouterOS report."""
|
v4_path = os.path.join(data_dir, "blocklist_ipv4.txt")
|
||||||
# Read the latest scan report
|
v6_path = os.path.join(data_dir, "blocklist_ipv6.txt")
|
||||||
scan_report = get_scan_report(REPORT_DIR)
|
write_lines(v4_path, sorted(blocked_ipv4))
|
||||||
|
write_lines(v6_path, sorted(blocked_ipv6))
|
||||||
# Connect to the Mikrotik router
|
return v4_path, v6_path
|
||||||
firewall = MikrotikFirewall()
|
|
||||||
firewall.connect()
|
|
||||||
|
def get_scan_report(report_dir: str = REPORT_DIR) -> dict[str, list[str]]:
|
||||||
# read current blocked IPs from the router
|
"""Read the latest scan report `mikrotik-block.rsc` from the report directory and return the list of blocked IPs."""
|
||||||
current_blocked_ipv4 = firewall.get_address_list_ipv4(BLOCKLIST_NAME)
|
report_path = os.path.join(report_dir, "mikrotik-block.rsc")
|
||||||
current_blocked_ipv6 = firewall.get_address_list_ipv6(BLOCKLIST_NAME)
|
if not os.path.exists(report_path):
|
||||||
|
raise FileNotFoundError(f"Scan report not found: {report_path}")
|
||||||
# read whitelisted IPs from the router
|
|
||||||
whitelisted_ipv4 = set()
|
# add sections, they start with `/ip firewall address-list` or `/ipv6 firewall address-list` and end with a blank line or EOF
|
||||||
whitelisted_ipv6 = set()
|
sections = {
|
||||||
for wl_name in WHITELIST_NAMES:
|
"/ip firewall address-list": [],
|
||||||
whitelisted_ipv4.update(firewall.get_address_list_ipv4(wl_name))
|
"/ipv6 firewall address-list": [],
|
||||||
whitelisted_ipv6.update(firewall.get_address_list_ipv6(wl_name))
|
}
|
||||||
|
# add list=Cloudflare or list=Init7 to whitelist sections, list=Blocked to blocked section
|
||||||
# read blocked IPs from the scan report
|
with open(report_path, "r", encoding="utf-8") as f:
|
||||||
blocked_ipv4 = set()
|
current_section = None
|
||||||
blocked_ipv6 = set()
|
for line in f:
|
||||||
for line in scan_report["/ip firewall address-list"]:
|
line = line.strip()
|
||||||
if "list=Blocked" in line:
|
if not line:
|
||||||
parts = line.split()
|
current_section = None
|
||||||
for part in parts:
|
continue
|
||||||
if part.startswith("address="):
|
if line.startswith("/ip firewall address-list"):
|
||||||
ip = part.split("=")[1]
|
current_section = "/ip firewall address-list"
|
||||||
blocked_ipv4.add(ip)
|
continue
|
||||||
for line in scan_report["/ipv6 firewall address-list"]:
|
if line.startswith("/ipv6 firewall address-list"):
|
||||||
if "list=Blocked" in line:
|
current_section = "/ipv6 firewall address-list"
|
||||||
parts = line.split()
|
continue
|
||||||
for part in parts:
|
if current_section:
|
||||||
if part.startswith("address="):
|
sections[current_section].append(line)
|
||||||
ip = part.split("=")[1]
|
|
||||||
blocked_ipv6.add(ip)
|
return sections
|
||||||
|
|
||||||
# Determine which IPs to add and remove
|
|
||||||
to_add_ipv4 = blocked_ipv4 - current_blocked_ipv4 - whitelisted_ipv4
|
def main():
|
||||||
to_add_ipv6 = blocked_ipv6 - current_blocked_ipv6 - whitelisted_ipv6
|
"""Legacy entry point for applying the latest generated RouterOS report."""
|
||||||
to_remove_ipv4 = current_blocked_ipv4 - blocked_ipv4
|
# Read the latest scan report
|
||||||
to_remove_ipv6 = current_blocked_ipv6 - blocked_ipv6
|
scan_report = get_scan_report(REPORT_DIR)
|
||||||
|
|
||||||
# Remove old blocked IPs
|
# Connect to the Mikrotik router
|
||||||
for ip in to_remove_ipv4:
|
firewall = MikrotikFirewall()
|
||||||
firewall.execute_command(
|
firewall.connect()
|
||||||
f"/ip firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
|
||||||
for ip in to_remove_ipv6:
|
# read current blocked IPs from the router
|
||||||
firewall.execute_command(
|
current_blocked_ipv4 = firewall.get_address_list_ipv4(BLOCKLIST_NAME)
|
||||||
f"/ipv6 firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
current_blocked_ipv6 = firewall.get_address_list_ipv6(BLOCKLIST_NAME)
|
||||||
|
|
||||||
# Add new blocked IPs
|
# read whitelisted IPs from the router
|
||||||
firewall.write_address_list_ipv4(BLOCKLIST_NAME, to_add_ipv4)
|
whitelisted_ipv4 = set()
|
||||||
firewall.write_address_list_ipv6(BLOCKLIST_NAME, to_add_ipv6)
|
whitelisted_ipv6 = set()
|
||||||
|
for wl_name in WHITELIST_NAMES:
|
||||||
firewall.save_state_report(REPORT_DIR)
|
whitelisted_ipv4.update(firewall.get_address_list_ipv4(wl_name))
|
||||||
|
whitelisted_ipv6.update(firewall.get_address_list_ipv6(wl_name))
|
||||||
# Disconnect from the router
|
|
||||||
firewall.disconnect()
|
# read blocked IPs from the scan report
|
||||||
|
blocked_ipv4 = set()
|
||||||
# Print summary
|
blocked_ipv6 = set()
|
||||||
print(
|
for line in scan_report["/ip firewall address-list"]:
|
||||||
f"Removed {len(to_remove_ipv4)} IPv4 and {len(to_remove_ipv6)} IPv6 addresses from the blocklist.")
|
if "list=Blocked" in line:
|
||||||
print(
|
parts = line.split()
|
||||||
f"Added {len(to_add_ipv4)} IPv4 and {len(to_add_ipv6)} IPv6 addresses to the blocklist.")
|
for part in parts:
|
||||||
|
if part.startswith("address="):
|
||||||
|
ip = part.split("=")[1]
|
||||||
if __name__ == "__main__":
|
blocked_ipv4.add(ip)
|
||||||
main()
|
for line in scan_report["/ipv6 firewall address-list"]:
|
||||||
|
if "list=Blocked" in line:
|
||||||
|
parts = line.split()
|
||||||
|
for part in parts:
|
||||||
|
if part.startswith("address="):
|
||||||
|
ip = part.split("=")[1]
|
||||||
|
blocked_ipv6.add(ip)
|
||||||
|
|
||||||
|
# Determine which IPs to add and remove
|
||||||
|
to_add_ipv4 = blocked_ipv4 - current_blocked_ipv4 - whitelisted_ipv4
|
||||||
|
to_add_ipv6 = blocked_ipv6 - current_blocked_ipv6 - whitelisted_ipv6
|
||||||
|
to_remove_ipv4 = current_blocked_ipv4 - blocked_ipv4
|
||||||
|
to_remove_ipv6 = current_blocked_ipv6 - blocked_ipv6
|
||||||
|
|
||||||
|
# Remove old blocked IPs
|
||||||
|
for ip in to_remove_ipv4:
|
||||||
|
firewall.execute_command(
|
||||||
|
f"/ip firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
||||||
|
for ip in to_remove_ipv6:
|
||||||
|
firewall.execute_command(
|
||||||
|
f"/ipv6 firewall address-list remove [find list={BLOCKLIST_NAME} address={ip}]")
|
||||||
|
|
||||||
|
# Add new blocked IPs
|
||||||
|
firewall.write_address_list_ipv4(BLOCKLIST_NAME, to_add_ipv4)
|
||||||
|
firewall.write_address_list_ipv6(BLOCKLIST_NAME, to_add_ipv6)
|
||||||
|
|
||||||
|
firewall.save_state_report(REPORT_DIR)
|
||||||
|
|
||||||
|
# Disconnect from the router
|
||||||
|
firewall.disconnect()
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print(
|
||||||
|
f"Removed {len(to_remove_ipv4)} IPv4 and {len(to_remove_ipv6)} IPv6 addresses from the blocklist.")
|
||||||
|
print(
|
||||||
|
f"Added {len(to_add_ipv4)} IPv4 and {len(to_add_ipv6)} IPv6 addresses to the blocklist.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+47
-47
@@ -1,47 +1,47 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Flagging rules for the intrusion-detection system.
|
"""Flagging rules for the intrusion-detection system.
|
||||||
|
|
||||||
Detection functions that identify hostile IPs based on aggregated stats.
|
Detection functions that identify hostile IPs based on aggregated stats.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from . import bad_patterns
|
from . import bad_patterns
|
||||||
from .constants import DEFAULT_ERROR_RATIO, DEFAULT_MIN_DENIED, DEFAULT_MIN_ERRORS
|
from .utils.constants import DEFAULT_ERROR_RATIO, DEFAULT_MIN_DENIED, DEFAULT_MIN_ERRORS
|
||||||
from .models import IPStats
|
from .utils.models import IPStats
|
||||||
|
|
||||||
|
|
||||||
def flag_scanners(stats: dict[str, IPStats], min_errors: int = DEFAULT_MIN_ERRORS,
|
def flag_scanners(stats: dict[str, IPStats], min_errors: int = DEFAULT_MIN_ERRORS,
|
||||||
min_ratio: float = DEFAULT_ERROR_RATIO) -> dict[str, IPStats]:
|
min_ratio: float = DEFAULT_ERROR_RATIO) -> dict[str, IPStats]:
|
||||||
"""IPs with many errors AND a high error ratio."""
|
"""IPs with many errors AND a high error ratio."""
|
||||||
return {
|
return {
|
||||||
ip: s for ip, s in stats.items()
|
ip: s for ip, s in stats.items()
|
||||||
if s.errors >= min_errors and s.error_ratio >= min_ratio
|
if s.errors >= min_errors and s.error_ratio >= min_ratio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def flag_denied_only(stats: dict[str, IPStats],
|
def flag_denied_only(stats: dict[str, IPStats],
|
||||||
min_denied: int = DEFAULT_MIN_DENIED) -> dict[str, IPStats]:
|
min_denied: int = DEFAULT_MIN_DENIED) -> dict[str, IPStats]:
|
||||||
"""IPs that only ever hit denied paths and never authenticated."""
|
"""IPs that only ever hit denied paths and never authenticated."""
|
||||||
return {
|
return {
|
||||||
ip: s for ip, s in stats.items()
|
ip: s for ip, s in stats.items()
|
||||||
if s.denied >= min_denied and not s.authenticated
|
if s.denied >= min_denied and not s.authenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def flag_malicious_patterns(stats: dict[str, IPStats],
|
def flag_malicious_patterns(stats: dict[str, IPStats],
|
||||||
min_severity: int = 2,
|
min_severity: int = 2,
|
||||||
min_hits: int = 1) -> dict[str, IPStats]:
|
min_hits: int = 1) -> dict[str, IPStats]:
|
||||||
"""IPs with hits against malicious path patterns at or above severity threshold."""
|
"""IPs with hits against malicious path patterns at or above severity threshold."""
|
||||||
return {
|
return {
|
||||||
ip: s for ip, s in stats.items()
|
ip: s for ip, s in stats.items()
|
||||||
if s.malicious_hits >= min_hits and any(
|
if s.malicious_hits >= min_hits and any(
|
||||||
bad_patterns.PATTERNS_BY_NAME[name].severity >= min_severity
|
bad_patterns.PATTERNS_BY_NAME[name].severity >= min_severity
|
||||||
for name in s.malicious_patterns
|
for name in s.malicious_patterns
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def by_total(stats: dict[str, IPStats]) -> list[IPStats]:
|
def by_total(stats: dict[str, IPStats]) -> list[IPStats]:
|
||||||
"""Stats sorted by request count, descending."""
|
"""Stats sorted by request count, descending."""
|
||||||
return sorted(stats.values(), key=lambda s: (-s.total, s.ip))
|
return sorted(stats.values(), key=lambda s: (-s.total, s.ip))
|
||||||
+111
-111
@@ -1,111 +1,111 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""MikroTik RouterOS script generation."""
|
"""MikroTik RouterOS script generation."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from .ip_utils import is_ipv4, is_ipv6, is_whitelisted, parse_ip
|
from .utils.ip_utils import is_ipv4, is_ipv6, is_whitelisted, parse_ip
|
||||||
from .whitelists import WHITELISTS
|
from .utils.whitelists import WHITELISTS
|
||||||
from .constants import BLOCKLIST_NAME
|
from .utils.constants import BLOCKLIST_NAME
|
||||||
|
|
||||||
|
|
||||||
def split_cidr_versions(targets: Iterable[str]) -> tuple[list[str], list[str]]:
|
def split_cidr_versions(targets: Iterable[str]) -> tuple[list[str], list[str]]:
|
||||||
"""Split target CIDRs/IPs into (sorted IPv4 list, sorted IPv6 list)."""
|
"""Split target CIDRs/IPs into (sorted IPv4 list, sorted IPv6 list)."""
|
||||||
v4 = []
|
v4 = []
|
||||||
v6 = []
|
v6 = []
|
||||||
for t in targets:
|
for t in targets:
|
||||||
ip = t.split("/")[0]
|
ip = t.split("/")[0]
|
||||||
if is_ipv4(ip):
|
if is_ipv4(ip):
|
||||||
v4.append(t)
|
v4.append(t)
|
||||||
elif is_ipv6(ip):
|
elif is_ipv6(ip):
|
||||||
v6.append(t)
|
v6.append(t)
|
||||||
|
|
||||||
def key_cidr(cidr: str):
|
def key_cidr(cidr: str):
|
||||||
ip = cidr.split("/")[0]
|
ip = cidr.split("/")[0]
|
||||||
addr = parse_ip(ip)
|
addr = parse_ip(ip)
|
||||||
if addr is None:
|
if addr is None:
|
||||||
return (9, b"", cidr)
|
return (9, b"", cidr)
|
||||||
return (addr.version, addr.packed, cidr)
|
return (addr.version, addr.packed, cidr)
|
||||||
|
|
||||||
return sorted(v4, key=key_cidr), sorted(v6, key=key_cidr)
|
return sorted(v4, key=key_cidr), sorted(v6, key=key_cidr)
|
||||||
|
|
||||||
|
|
||||||
def mikrotik_script(tag: str, blocked_ips_or_subnets: Iterable[str],
|
def mikrotik_script(tag: str, blocked_ips_or_subnets: Iterable[str],
|
||||||
whitelists: dict[str, list[str]] | None = None,
|
whitelists: dict[str, list[str]] | None = None,
|
||||||
generated: str | None = None) -> tuple[str, int, int]:
|
generated: str | None = None) -> tuple[str, int, int]:
|
||||||
"""Build a RouterOS .rsc script.
|
"""Build a RouterOS .rsc script.
|
||||||
|
|
||||||
Whitelisted ranges become `accept` rules placed above the `drop` rules.
|
Whitelisted ranges become `accept` rules placed above the `drop` rules.
|
||||||
Blocked targets can be individual IPs (e.g. 1.2.3.4/32) or CIDR blocks
|
Blocked targets can be individual IPs (e.g. 1.2.3.4/32) or CIDR blocks
|
||||||
(e.g. 1.2.3.0/24).
|
(e.g. 1.2.3.0/24).
|
||||||
|
|
||||||
Returns (script_text, blocked_count, whitelisted_cidr_count).
|
Returns (script_text, blocked_count, whitelisted_cidr_count).
|
||||||
"""
|
"""
|
||||||
whitelists = WHITELISTS if whitelists is None else whitelists
|
whitelists = WHITELISTS if whitelists is None else whitelists
|
||||||
generated = generated or datetime.now().isoformat(timespec="seconds")
|
generated = generated or datetime.now().isoformat(timespec="seconds")
|
||||||
|
|
||||||
def clean_blocked(targets: Iterable[str]) -> list[str]:
|
def clean_blocked(targets: Iterable[str]) -> list[str]:
|
||||||
cleaned = []
|
cleaned = []
|
||||||
for t in targets:
|
for t in targets:
|
||||||
ip = t.split("/")[0]
|
ip = t.split("/")[0]
|
||||||
if not is_whitelisted(ip):
|
if not is_whitelisted(ip):
|
||||||
cleaned.append(t)
|
cleaned.append(t)
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
blocked = clean_blocked(set(blocked_ips_or_subnets))
|
blocked = clean_blocked(set(blocked_ips_or_subnets))
|
||||||
block_v4, block_v6 = split_cidr_versions(blocked)
|
block_v4, block_v6 = split_cidr_versions(blocked)
|
||||||
|
|
||||||
lines: list[str] = [f"# {tag} firewall export - generated {generated}", ""]
|
lines: list[str] = [f"# {tag} firewall export - generated {generated}", ""]
|
||||||
wl_count = 0
|
wl_count = 0
|
||||||
|
|
||||||
for name, cidrs in whitelists.items():
|
for name, cidrs in whitelists.items():
|
||||||
nets = [ipaddress.ip_network(c) for c in cidrs]
|
nets = [ipaddress.ip_network(c) for c in cidrs]
|
||||||
wl_count += len(nets)
|
wl_count += len(nets)
|
||||||
for version, cmd in ((4, "/ip firewall address-list"),
|
for version, cmd in ((4, "/ip firewall address-list"),
|
||||||
(6, "/ipv6 firewall address-list")):
|
(6, "/ipv6 firewall address-list")):
|
||||||
chunk = [n for n in nets if n.version == version]
|
chunk = [n for n in nets if n.version == version]
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
lines.append(f"# {name} IPv{version} ranges (whitelisted)")
|
lines.append(f"# {name} IPv{version} ranges (whitelisted)")
|
||||||
lines.append(cmd)
|
lines.append(cmd)
|
||||||
lines += [f"add list={name} address={n}" for n in chunk]
|
lines += [f"add list={name} address={n}" for n in chunk]
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
if block_v4:
|
if block_v4:
|
||||||
lines.append(f"# Blocked IPv4 ({len(block_v4)}, non-whitelisted)")
|
lines.append(f"# Blocked IPv4 ({len(block_v4)}, non-whitelisted)")
|
||||||
lines.append("/ip firewall address-list")
|
lines.append("/ip firewall address-list")
|
||||||
lines += [f"add list={BLOCKLIST_NAME} address={t}" for t in block_v4]
|
lines += [f"add list={BLOCKLIST_NAME} address={t}" for t in block_v4]
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if block_v6:
|
if block_v6:
|
||||||
lines.append(f"# Blocked IPv6 ({len(block_v6)}, non-whitelisted)")
|
lines.append(f"# Blocked IPv6 ({len(block_v6)}, non-whitelisted)")
|
||||||
lines.append("/ipv6 firewall address-list")
|
lines.append("/ipv6 firewall address-list")
|
||||||
lines += [f"add list={BLOCKLIST_NAME} address={t}" for t in block_v6]
|
lines += [f"add list={BLOCKLIST_NAME} address={t}" for t in block_v6]
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
for version, cmd, blocks in ((4, "/ip firewall filter", block_v4),
|
for version, cmd, blocks in ((4, "/ip firewall filter", block_v4),
|
||||||
(6, "/ipv6 firewall filter", block_v6)):
|
(6, "/ipv6 firewall filter", block_v6)):
|
||||||
lines.append(cmd)
|
lines.append(cmd)
|
||||||
for name in whitelists:
|
for name in whitelists:
|
||||||
lines.append(
|
lines.append(
|
||||||
f"add chain=input action=accept src-address-list={name} "
|
f"add chain=input action=accept src-address-list={name} "
|
||||||
f'comment="{tag} {name.lower()} whitelist (v{version})"')
|
f'comment="{tag} {name.lower()} whitelist (v{version})"')
|
||||||
if blocks:
|
if blocks:
|
||||||
for chain in ("input", "forward"):
|
for chain in ("input", "forward"):
|
||||||
lines.append(
|
lines.append(
|
||||||
f"add chain={chain} action=drop src-address-list={BLOCKLIST_NAME} "
|
f"add chain={chain} action=drop src-address-list={BLOCKLIST_NAME} "
|
||||||
f'comment="{tag} blocked (v{version})"')
|
f'comment="{tag} blocked (v{version})"')
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines), len(blocked), wl_count
|
return "\n".join(lines), len(blocked), wl_count
|
||||||
|
|
||||||
|
|
||||||
def write_mikrotik(path: str, tag: str,
|
def write_mikrotik(path: str, tag: str,
|
||||||
blocked_ips_or_subnets: Iterable[str]) -> tuple[str, int, int]:
|
blocked_ips_or_subnets: Iterable[str]) -> tuple[str, int, int]:
|
||||||
text, n_blocked, n_whitelisted = mikrotik_script(
|
text, n_blocked, n_whitelisted = mikrotik_script(
|
||||||
tag, blocked_ips_or_subnets)
|
tag, blocked_ips_or_subnets)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
return path, n_blocked, n_whitelisted
|
return path, n_blocked, n_whitelisted
|
||||||
+170
-170
@@ -1,170 +1,170 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Nginx Proxy Manager access-log analyzer.
|
"""Nginx Proxy Manager access-log analyzer.
|
||||||
|
|
||||||
Parses NPM proxy access logs (plain + gzipped), aggregates per-IP
|
Parses NPM proxy access logs (plain + gzipped), aggregates per-IP
|
||||||
behaviour and flags scanner IPs: clients with many error responses and a
|
behaviour and flags scanner IPs: clients with many error responses and a
|
||||||
high error ratio. Flagged IPs become the MikroTik block list.
|
high error ratio. Flagged IPs become the MikroTik block list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from .analyzer import Analyzer
|
from .analyzer import Analyzer
|
||||||
from .constants import (
|
from .utils.constants import (
|
||||||
DEFAULT_ERROR_RATIO,
|
DEFAULT_ERROR_RATIO,
|
||||||
DEFAULT_MIN_ERRORS,
|
DEFAULT_MIN_ERRORS,
|
||||||
NPM_LOG_PATH,
|
NPM_LOG_PATH,
|
||||||
NPM_REPORT_DIR,
|
NPM_REPORT_DIR,
|
||||||
)
|
)
|
||||||
from .files import write_csv
|
from .utils.files import write_csv
|
||||||
from .flags import flag_scanners
|
from .flags import flag_scanners
|
||||||
from .models import Request
|
from .utils.models import Request
|
||||||
from .utils import arg_parser
|
from .utils.utils import arg_parser
|
||||||
|
|
||||||
# [02/Aug/2026:05:01:31 +0200] - 401 401 - GET https host "/path"
|
# [02/Aug/2026:05:01:31 +0200] - 401 401 - GET https host "/path"
|
||||||
# [Client IP] [Length N] [Gzip X] [Sent-to IP] "UA" "Referer"
|
# [Client IP] [Length N] [Gzip X] [Sent-to IP] "UA" "Referer"
|
||||||
LINE_RE = re.compile(
|
LINE_RE = re.compile(
|
||||||
r"^\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+[+-]\d{4})\]\s+" # [datetime]
|
r"^\[(\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}\s+[+-]\d{4})\]\s+" # [datetime]
|
||||||
r"-\s+"
|
r"-\s+"
|
||||||
r"(\d+|-)\s+" # upstream
|
r"(\d+|-)\s+" # upstream
|
||||||
r"(\d+)\s+" # status
|
r"(\d+)\s+" # status
|
||||||
r"-\s+"
|
r"-\s+"
|
||||||
r"(\S+)\s+" # method
|
r"(\S+)\s+" # method
|
||||||
r"(\S+)\s+" # scheme
|
r"(\S+)\s+" # scheme
|
||||||
r"(\S+)\s+" # host
|
r"(\S+)\s+" # host
|
||||||
r'"([^"]*)"\s+' # "path"
|
r'"([^"]*)"\s+' # "path"
|
||||||
r"\[Client\s+(\S+)\]\s+"
|
r"\[Client\s+(\S+)\]\s+"
|
||||||
r"\[Length\s+(\d+|-)\]\s+"
|
r"\[Length\s+(\d+|-)\]\s+"
|
||||||
r"\[Gzip\s+(\S+)\]\s+"
|
r"\[Gzip\s+(\S+)\]\s+"
|
||||||
r"\[Sent-to\s+(\S+)\]\s+"
|
r"\[Sent-to\s+(\S+)\]\s+"
|
||||||
r'"([^"]*)"\s+' # "UA"
|
r'"([^"]*)"\s+' # "UA"
|
||||||
r'"([^"]*)"' # "Referer"
|
r'"([^"]*)"' # "Referer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NpmAnalyzer(Analyzer):
|
class NpmAnalyzer(Analyzer):
|
||||||
name = "npm"
|
name = "npm"
|
||||||
title = "Nginx Proxy Manager log analysis report"
|
title = "Nginx Proxy Manager log analysis report"
|
||||||
log_suffixes = (".log", ".gz")
|
log_suffixes = (".log", ".gz")
|
||||||
exclude_prefixes = ("letsencrypt",)
|
exclude_prefixes = ("letsencrypt",)
|
||||||
paths_file = "error_paths.csv"
|
paths_file = "error_paths.csv"
|
||||||
paths_label = "error paths"
|
paths_label = "error paths"
|
||||||
flag_files = ("scanner_ips.txt", "scanner_ipv6.txt")
|
flag_files = ("scanner_ips.txt", "scanner_ipv6.txt")
|
||||||
flag_label = "Scanner IPs"
|
flag_label = "Scanner IPs"
|
||||||
flag_note = "high error rate -- probable scanners/attackers"
|
flag_note = "high error rate -- probable scanners/attackers"
|
||||||
|
|
||||||
ip_columns = (
|
ip_columns = (
|
||||||
("ip", lambda s: s.ip),
|
("ip", lambda s: s.ip),
|
||||||
("total", lambda s: s.total),
|
("total", lambda s: s.total),
|
||||||
("4xx", lambda s: s.c4xx),
|
("4xx", lambda s: s.c4xx),
|
||||||
("5xx", lambda s: s.c5xx),
|
("5xx", lambda s: s.c5xx),
|
||||||
("ratio", lambda s: s.error_ratio),
|
("ratio", lambda s: s.error_ratio),
|
||||||
("first", lambda s: s.first),
|
("first", lambda s: s.first),
|
||||||
("last", lambda s: s.last),
|
("last", lambda s: s.last),
|
||||||
("methods", lambda s: s.methods_str),
|
("methods", lambda s: s.methods_str),
|
||||||
("whitelist", lambda s: s.whitelist),
|
("whitelist", lambda s: s.whitelist),
|
||||||
)
|
)
|
||||||
|
|
||||||
request_columns = (
|
request_columns = (
|
||||||
("datetime", lambda r: r.ts),
|
("datetime", lambda r: r.ts),
|
||||||
("ip", lambda r: r.ip),
|
("ip", lambda r: r.ip),
|
||||||
("method", lambda r: r.method),
|
("method", lambda r: r.method),
|
||||||
("scheme", lambda r: r.extra.get("scheme")),
|
("scheme", lambda r: r.extra.get("scheme")),
|
||||||
("host", lambda r: r.host),
|
("host", lambda r: r.host),
|
||||||
("path", lambda r: r.path),
|
("path", lambda r: r.path),
|
||||||
("status", lambda r: r.status),
|
("status", lambda r: r.status),
|
||||||
("upstream_status", lambda r: r.upstream_status),
|
("upstream_status", lambda r: r.upstream_status),
|
||||||
("length", lambda r: r.extra.get("length")),
|
("length", lambda r: r.extra.get("length")),
|
||||||
("gzip", lambda r: r.extra.get("gzip")),
|
("gzip", lambda r: r.extra.get("gzip")),
|
||||||
("sent_to", lambda r: r.extra.get("sent_to")),
|
("sent_to", lambda r: r.extra.get("sent_to")),
|
||||||
("referer", lambda r: r.referer),
|
("referer", lambda r: r.referer),
|
||||||
("ua", lambda r: r.ua),
|
("ua", lambda r: r.ua),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, min_errors: int = DEFAULT_MIN_ERRORS,
|
def __init__(self, *args, min_errors: int = DEFAULT_MIN_ERRORS,
|
||||||
error_ratio: float = DEFAULT_ERROR_RATIO, **kwargs):
|
error_ratio: float = DEFAULT_ERROR_RATIO, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.min_errors = min_errors
|
self.min_errors = min_errors
|
||||||
self.min_ratio = error_ratio
|
self.min_ratio = error_ratio
|
||||||
|
|
||||||
# --- parsing -----------------------------------------------------------
|
# --- parsing -----------------------------------------------------------
|
||||||
|
|
||||||
def parse_line(self, line: str) -> Request | None:
|
def parse_line(self, line: str) -> Request | None:
|
||||||
if not line:
|
if not line:
|
||||||
return None
|
return None
|
||||||
m = LINE_RE.match(line)
|
m = LINE_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
(ts, upstream, status, method, scheme, host, path, ip,
|
(ts, upstream, status, method, scheme, host, path, ip,
|
||||||
length, gz, sent_to, ua, referer) = m.groups()
|
length, gz, sent_to, ua, referer) = m.groups()
|
||||||
return Request(
|
return Request(
|
||||||
ts=ts,
|
ts=ts,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
method=method,
|
method=method,
|
||||||
path=path,
|
path=path,
|
||||||
status=int(status),
|
status=int(status),
|
||||||
upstream_status=int(upstream) if upstream.isdigit() else None,
|
upstream_status=int(upstream) if upstream.isdigit() else None,
|
||||||
host=host,
|
host=host,
|
||||||
ua=ua,
|
ua=ua,
|
||||||
referer=referer,
|
referer=referer,
|
||||||
extra={"scheme": scheme, "length": length,
|
extra={"scheme": scheme, "length": length,
|
||||||
"gzip": gz, "sent_to": sent_to},
|
"gzip": gz, "sent_to": sent_to},
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- flagging ----------------------------------------------------------
|
# --- flagging ----------------------------------------------------------
|
||||||
|
|
||||||
def flag(self, stats):
|
def flag(self, stats):
|
||||||
return flag_scanners(stats, min_errors=self.min_errors,
|
return flag_scanners(stats, min_errors=self.min_errors,
|
||||||
min_ratio=self.min_ratio)
|
min_ratio=self.min_ratio)
|
||||||
|
|
||||||
# --- extras ------------------------------------------------------------
|
# --- extras ------------------------------------------------------------
|
||||||
|
|
||||||
def extra_summary_rows(self):
|
def extra_summary_rows(self):
|
||||||
c4 = sum(1 for r in self.requests if r.is_client_error)
|
c4 = sum(1 for r in self.requests if r.is_client_error)
|
||||||
c5 = sum(1 for r in self.requests if r.is_server_error)
|
c5 = sum(1 for r in self.requests if r.is_server_error)
|
||||||
return [
|
return [
|
||||||
("4xx / 5xx", f"{c4} / {c5}"),
|
("4xx / 5xx", f"{c4} / {c5}"),
|
||||||
("hosts seen", len({r.host for r in self.requests if r.host})),
|
("hosts seen", len({r.host for r in self.requests if r.host})),
|
||||||
("scanner threshold",
|
("scanner threshold",
|
||||||
f">= {self.min_errors} errors and ratio >= {self.min_ratio}"),
|
f">= {self.min_errors} errors and ratio >= {self.min_ratio}"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def write_extra(self, data_dir: str) -> list[str]:
|
def write_extra(self, data_dir: str) -> list[str]:
|
||||||
uas = Counter(r.ua for r in self.requests if r.ua and r.ua != "-")
|
uas = Counter(r.ua for r in self.requests if r.ua and r.ua != "-")
|
||||||
hosts = Counter(r.host for r in self.requests if r.host)
|
hosts = Counter(r.host for r in self.requests if r.host)
|
||||||
return [
|
return [
|
||||||
write_csv(os.path.join(data_dir, "ua_summary.csv"),
|
write_csv(os.path.join(data_dir, "ua_summary.csv"),
|
||||||
["user_agent", "count"], uas.most_common()),
|
["user_agent", "count"], uas.most_common()),
|
||||||
write_csv(os.path.join(data_dir, "host_summary.csv"),
|
write_csv(os.path.join(data_dir, "host_summary.csv"),
|
||||||
["host", "count"], hosts.most_common()),
|
["host", "count"], hosts.most_common()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = arg_parser(
|
parser = arg_parser(
|
||||||
"analyze NPM proxy logs, flag scanner IPs, export MikroTik rules",
|
"analyze NPM proxy logs, flag scanner IPs, export MikroTik rules",
|
||||||
NPM_LOG_PATH, NPM_REPORT_DIR)
|
NPM_LOG_PATH, NPM_REPORT_DIR)
|
||||||
parser.add_argument("--min-errors", type=int, default=DEFAULT_MIN_ERRORS,
|
parser.add_argument("--min-errors", type=int, default=DEFAULT_MIN_ERRORS,
|
||||||
help=f"minimum error responses to flag (default: {DEFAULT_MIN_ERRORS})")
|
help=f"minimum error responses to flag (default: {DEFAULT_MIN_ERRORS})")
|
||||||
parser.add_argument("--error-ratio", type=float, default=DEFAULT_ERROR_RATIO,
|
parser.add_argument("--error-ratio", type=float, default=DEFAULT_ERROR_RATIO,
|
||||||
help=f"minimum error ratio to flag (default: {DEFAULT_ERROR_RATIO})")
|
help=f"minimum error ratio to flag (default: {DEFAULT_ERROR_RATIO})")
|
||||||
parser.add_argument("--full", action="store_true",
|
parser.add_argument("--full", action="store_true",
|
||||||
help="rescan all logs from the start (ignore incremental state)")
|
help="rescan all logs from the start (ignore incremental state)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
NpmAnalyzer(
|
NpmAnalyzer(
|
||||||
log_path=args.log_path,
|
log_path=args.log_path,
|
||||||
report_dir=args.report_dir,
|
report_dir=args.report_dir,
|
||||||
top=args.top,
|
top=args.top,
|
||||||
quiet=args.quiet,
|
quiet=args.quiet,
|
||||||
full=args.full,
|
full=args.full,
|
||||||
min_errors=args.min_errors,
|
min_errors=args.min_errors,
|
||||||
error_ratio=args.error_ratio,
|
error_ratio=args.error_ratio,
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -8,9 +8,16 @@ and outputs a single consolidated MikroTik RouterOS blocklist.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from .constants import (
|
from ..utils.blocklist_history import (
|
||||||
|
committed_lines,
|
||||||
|
cumulative_blocklist,
|
||||||
|
current_file_lines,
|
||||||
|
write_blocklist_report,
|
||||||
|
)
|
||||||
|
from ..utils.constants import (
|
||||||
COPYPARTY_LOG_PATH,
|
COPYPARTY_LOG_PATH,
|
||||||
COPYPARTY_REPORT_DIR,
|
COPYPARTY_REPORT_DIR,
|
||||||
DEFAULT_ERROR_RATIO,
|
DEFAULT_ERROR_RATIO,
|
||||||
@@ -20,14 +27,64 @@ from .constants import (
|
|||||||
NPM_LOG_PATH,
|
NPM_LOG_PATH,
|
||||||
NPM_REPORT_DIR,
|
NPM_REPORT_DIR,
|
||||||
REPORT_DIR,
|
REPORT_DIR,
|
||||||
|
REPORT_RETENTION_DAYS,
|
||||||
|
REPORT_RETENTION_MAX_FILES,
|
||||||
SUBNET_THRESHOLD,
|
SUBNET_THRESHOLD,
|
||||||
)
|
)
|
||||||
from .firewall import MikrotikFirewall, write_blocklist_files
|
from ..firewall import MikrotikFirewall, write_blocklist_files
|
||||||
from .copyparty import CopypartyAnalyzer
|
from ..utils.retention import prune_report_dirs
|
||||||
from .npm import NpmAnalyzer
|
from ..copyparty import CopypartyAnalyzer
|
||||||
from .ip_utils import is_whitelisted
|
from ..npm import NpmAnalyzer
|
||||||
from .mikrotik import split_cidr_versions
|
from ..utils.ip_utils import is_whitelisted
|
||||||
from .whois import consolidate_by_subnet
|
from ..mikrotik import split_cidr_versions
|
||||||
|
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) -> dict | None:
|
||||||
|
"""Best-effort grow-only sync of block targets into the SQLite ledger.
|
||||||
|
|
||||||
|
Uses ``record_blocklist`` (add-only) so the ledger is the authoritative
|
||||||
|
cumulative record: once an IP is ever blocked it stays active even after it
|
||||||
|
leaves the scan window, matching the router's cumulative model. Returns the
|
||||||
|
added delta or None if the DB is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
conn = get_connection()
|
||||||
|
result = repo.record_blocklist(
|
||||||
|
conn, list(desired_v4) + list(desired_v6), added_by=added_by)
|
||||||
|
# Retention policy: expire stale single-host entries after 30 days
|
||||||
|
# while keeping CIDR ranges/subnets intact.
|
||||||
|
pruned = repo.prune_old_single_ip_blocks(conn, older_than_days=30)
|
||||||
|
result["pruned_single_ips"] = pruned
|
||||||
|
return result
|
||||||
|
except Exception as exc: # never break the scan for a DB issue
|
||||||
|
print(f"WARN: could not sync blocklist to db ({exc})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _db_complete_blocklist(desired_v4: list[str], desired_v6: list[str]):
|
||||||
|
"""Complete cumulative blocklist from the DB ledger (B001).
|
||||||
|
|
||||||
|
Returns ``(complete_v4, complete_v6)`` as **sets** (same contract as
|
||||||
|
``cumulative_blocklist``) derived from the active blocklist ledger plus
|
||||||
|
this scan's newly desired targets (in case the DB write for this scan has
|
||||||
|
not run yet). Falls back to the git-history reconstruction when the DB is
|
||||||
|
unavailable, so nothing regresses.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
conn = get_connection()
|
||||||
|
active = [e["cidr"] for e in repo.active_blocklist(conn)]
|
||||||
|
except Exception:
|
||||||
|
active = []
|
||||||
|
union = set(active) | set(desired_v4) | set(desired_v6)
|
||||||
|
v4, v6 = split_cidr_versions(union)
|
||||||
|
return set(v4), set(v6)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -70,6 +127,7 @@ class ScanResult:
|
|||||||
blocklist_v6_path: str | None = None
|
blocklist_v6_path: str | None = None
|
||||||
firewall_connected: bool = False
|
firewall_connected: bool = False
|
||||||
firewall_changes: dict | None = None
|
firewall_changes: dict | None = None
|
||||||
|
blocklist_report_path: str | None = None
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,6 +140,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.
|
||||||
@@ -123,7 +199,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}")
|
||||||
@@ -132,7 +208,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}")
|
||||||
@@ -144,6 +220,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
|
||||||
@@ -173,12 +257,22 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
print(f"Total target blocks/subnets: {len(final_blocked_targets)}")
|
print(f"Total target blocks/subnets: {len(final_blocked_targets)}")
|
||||||
|
|
||||||
# --- Step 6: write consolidated blocklists to reports/app/data ----------
|
# --- Step 6: write consolidated blocklists to reports/app/data ----------
|
||||||
blocklist_v4_path, blocklist_v6_path = write_blocklist_files(
|
# Capture the previous blocklist state BEFORE the files are overwritten so
|
||||||
blocked_ipv4, blocked_ipv6, params.report_dir)
|
# the change report can compute the complete-set delta.
|
||||||
|
app_data_dir = os.path.join(params.report_dir, "app", "data")
|
||||||
|
blocklist_v4_path = os.path.join(app_data_dir, "blocklist_ipv4.txt")
|
||||||
|
blocklist_v6_path = os.path.join(app_data_dir, "blocklist_ipv6.txt")
|
||||||
|
prev_v4 = current_file_lines(blocklist_v4_path)
|
||||||
|
prev_v6 = current_file_lines(blocklist_v6_path)
|
||||||
|
history_v4 = committed_lines(blocklist_v4_path)
|
||||||
|
history_v6 = committed_lines(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}")
|
print(f"blocklist files:\n {blocklist_v4_path}\n {blocklist_v6_path}")
|
||||||
|
|
||||||
# --- Steps 5+7: compare current state with candidates and apply ---------
|
# --- Steps 5+7: compare current state with candidates and apply ---------
|
||||||
firewall_changes: dict | None = None
|
firewall_changes: dict | None = None
|
||||||
|
blocklist_report_path: str | None = None
|
||||||
if firewall is not None:
|
if firewall is not None:
|
||||||
try:
|
try:
|
||||||
firewall_changes = firewall.sync_blocklist(
|
firewall_changes = firewall.sync_blocklist(
|
||||||
@@ -192,6 +286,69 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
f"remove {len(firewall_changes['removed_ipv4'])} IPv4 and "
|
f"remove {len(firewall_changes['removed_ipv4'])} IPv4 and "
|
||||||
f"{len(firewall_changes['removed_ipv6'])} IPv6 addresses.")
|
f"{len(firewall_changes['removed_ipv6'])} IPv6 addresses.")
|
||||||
|
|
||||||
|
# Timestamped change report — only when the router was reachable.
|
||||||
|
# Source of truth for the cumulative set: the SQLite |ledger| first
|
||||||
|
# (B001), falling back to the git-history reconstruction only if the
|
||||||
|
# DB is unavailable.
|
||||||
|
complete_v4, complete_v6 = _db_complete_blocklist(
|
||||||
|
blocked_ipv4, blocked_ipv6)
|
||||||
|
if not complete_v4 and not complete_v6:
|
||||||
|
complete_v4, complete_v6 = cumulative_blocklist(
|
||||||
|
blocklist_v4_path, blocklist_v6_path,
|
||||||
|
blocked_ipv4, blocked_ipv6)
|
||||||
|
blocklist_report_path = write_blocklist_report(
|
||||||
|
params.report_dir,
|
||||||
|
history_v4=history_v4, history_v6=history_v6,
|
||||||
|
prev_v4=prev_v4, prev_v6=prev_v6,
|
||||||
|
complete_v4=complete_v4, complete_v6=complete_v6,
|
||||||
|
firewall_changes=firewall_changes,
|
||||||
|
dry_run=params.dry_run,
|
||||||
|
errors=errors,
|
||||||
|
scan_id=db_scan_id)
|
||||||
|
print(f"blocklist report: {blocklist_report_path}")
|
||||||
|
|
||||||
|
# --- Step 8: prune old reports (retention) ------------------------------
|
||||||
|
# Each report subdirectory keeps at most REPORT_RETENTION_MAX_FILES files,
|
||||||
|
# all newer than REPORT_RETENTION_DAYS days. The blocklist files in
|
||||||
|
# ``app/data`` are always protected. ``--dry-run`` only previews.
|
||||||
|
pruned = prune_report_dirs(
|
||||||
|
params.report_dir, params.copyparty_report_dir, params.npm_report_dir,
|
||||||
|
dry_run=params.dry_run)
|
||||||
|
total_pruned = sum(len(paths) for paths in pruned.values())
|
||||||
|
action = "Would prune" if params.dry_run else "Pruned"
|
||||||
|
print(f"{action} {total_pruned} report file(s) "
|
||||||
|
f"(keep {REPORT_RETENTION_DAYS}d / "
|
||||||
|
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,
|
||||||
@@ -206,6 +363,7 @@ def run_scan(params: ScanParams) -> ScanResult:
|
|||||||
blocklist_v6_path=blocklist_v6_path,
|
blocklist_v6_path=blocklist_v6_path,
|
||||||
firewall_connected=firewall is not None,
|
firewall_connected=firewall is not None,
|
||||||
firewall_changes=firewall_changes,
|
firewall_changes=firewall_changes,
|
||||||
|
blocklist_report_path=blocklist_report_path,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Persistent state for incremental log scanning.
|
"""Persistent state for incremental log scanning.
|
||||||
|
|
||||||
Tracks the last processed byte offset per absolute log file path so a scan
|
Tracks the last processed byte offset per absolute log file path so a scan
|
||||||
only reads new entries instead of re-reading the whole file. The state file
|
only reads new entries instead of re-reading the whole file. The state file
|
||||||
is written atomically (temp file + rename) to avoid corruption.
|
is written atomically (temp file + rename) to avoid corruption.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .constants import SCAN_STATE_PATH
|
from ..utils.constants import SCAN_STATE_PATH
|
||||||
|
|
||||||
|
|
||||||
def load_state() -> dict[str, int]:
|
def load_state() -> dict[str, int]:
|
||||||
"""Return ``{absolute log path: byte offset}`` from the scan-state file."""
|
"""Return ``{absolute log path: byte offset}`` from the scan-state file."""
|
||||||
try:
|
try:
|
||||||
with open(SCAN_STATE_PATH, "r", encoding="utf-8") as f:
|
with open(SCAN_STATE_PATH, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return {k: int(v) for k, v in data.items()}
|
return {k: int(v) for k, v in data.items()}
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def save_state(state: dict[str, int]) -> None:
|
def save_state(state: dict[str, int]) -> None:
|
||||||
"""Write the scan-state file atomically (temp file + rename)."""
|
"""Write the scan-state file atomically (temp file + rename)."""
|
||||||
os.makedirs(os.path.dirname(SCAN_STATE_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(SCAN_STATE_PATH), exist_ok=True)
|
||||||
tmp = SCAN_STATE_PATH + ".tmp"
|
tmp = SCAN_STATE_PATH + ".tmp"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(state, f, indent=2, sort_keys=True)
|
json.dump(state, f, indent=2, sort_keys=True)
|
||||||
os.replace(tmp, SCAN_STATE_PATH)
|
os.replace(tmp, SCAN_STATE_PATH)
|
||||||
+83
-83
@@ -1,83 +1,83 @@
|
|||||||
// Shared helpers for the Intrusion Detection web UI.
|
// Shared helpers for the Intrusion Detection web UI.
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const opts = { headers: {}, ...options };
|
const opts = { headers: {}, ...options };
|
||||||
if (opts.body && typeof opts.body !== "string") {
|
if (opts.body && typeof opts.body !== "string") {
|
||||||
opts.headers["Content-Type"] = "application/json";
|
opts.headers["Content-Type"] = "application/json";
|
||||||
opts.body = JSON.stringify(opts.body);
|
opts.body = JSON.stringify(opts.body);
|
||||||
}
|
}
|
||||||
const res = await fetch(path, opts);
|
const res = await fetch(path, opts);
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* non-JSON */
|
/* non-JSON */
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? "").replace(
|
return String(s ?? "").replace(
|
||||||
/[&<>"']/g,
|
/[&<>"']/g,
|
||||||
(c) =>
|
(c) =>
|
||||||
({
|
({
|
||||||
"&": "&",
|
"&": "&",
|
||||||
"<": "<",
|
"<": "<",
|
||||||
">": ">",
|
">": ">",
|
||||||
'"': """,
|
'"': """,
|
||||||
"'": "'",
|
"'": "'",
|
||||||
})[c],
|
})[c],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toast(message, type = "info") {
|
function toast(message, type = "info") {
|
||||||
const box = document.getElementById("toasts");
|
const box = document.getElementById("toasts");
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
const node = document.createElement("div");
|
const node = document.createElement("div");
|
||||||
node.className = `toast toast-${type}`;
|
node.className = `toast toast-${type}`;
|
||||||
node.textContent = message;
|
node.textContent = message;
|
||||||
box.appendChild(node);
|
box.appendChild(node);
|
||||||
setTimeout(() => node.remove(), 4000);
|
setTimeout(() => node.remove(), 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(s) {
|
function fmtTime(s) {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
return isNaN(d) ? s : d.toLocaleString();
|
return isNaN(d) ? s : d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function badge(state) {
|
function badge(state) {
|
||||||
const node = document.createElement("span");
|
const node = document.createElement("span");
|
||||||
node.className = `badge ${state}`;
|
node.className = `badge ${state}`;
|
||||||
node.textContent = state;
|
node.textContent = state;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function el(tag, attrs = {}, ...children) {
|
function el(tag, attrs = {}, ...children) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
// Allow el("tr", child1, child2, ...): if the 2nd argument is a node or an
|
// Allow el("tr", child1, child2, ...): if the 2nd argument is a node or an
|
||||||
// array, treat it as the first child instead of an attributes object.
|
// array, treat it as the first child instead of an attributes object.
|
||||||
if (attrs && (attrs.nodeType === 1 || Array.isArray(attrs))) {
|
if (attrs && (attrs.nodeType === 1 || Array.isArray(attrs))) {
|
||||||
children.unshift(attrs);
|
children.unshift(attrs);
|
||||||
attrs = {};
|
attrs = {};
|
||||||
}
|
}
|
||||||
for (const [k, v] of Object.entries(attrs || {})) {
|
for (const [k, v] of Object.entries(attrs || {})) {
|
||||||
if (k === "class") node.className = v;
|
if (k === "class") node.className = v;
|
||||||
else if (k === "text") node.textContent = v;
|
else if (k === "text") node.textContent = v;
|
||||||
else if (k === "html") node.innerHTML = v;
|
else if (k === "html") node.innerHTML = v;
|
||||||
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||||
else node.setAttribute(k, v);
|
else node.setAttribute(k, v);
|
||||||
}
|
}
|
||||||
for (const c of children) {
|
for (const c of children) {
|
||||||
node.append(c == null ? "" : c);
|
node.append(c == null ? "" : c);
|
||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startScan(kind, params) {
|
async function startScan(kind, params) {
|
||||||
return api(`/api/scan/${kind}`, { method: "POST", body: params });
|
return api(`/api/scan/${kind}`, { method: "POST", body: params });
|
||||||
}
|
}
|
||||||
+499
-499
@@ -1,499 +1,499 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0f172a;
|
--bg: #0f172a;
|
||||||
--surface: #1e293b;
|
--surface: #1e293b;
|
||||||
--surface-2: #263449;
|
--surface-2: #263449;
|
||||||
--border: #334155;
|
--border: #334155;
|
||||||
--text: #e2e8f0;
|
--text: #e2e8f0;
|
||||||
--muted: #94a3b8;
|
--muted: #94a3b8;
|
||||||
--accent: #22d3ee;
|
--accent: #22d3ee;
|
||||||
--ok: #34d399;
|
--ok: #34d399;
|
||||||
--warn: #fbbf24;
|
--warn: #fbbf24;
|
||||||
--danger: #f87171;
|
--danger: #f87171;
|
||||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family:
|
font-family:
|
||||||
system-ui,
|
system-ui,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
"Segoe UI",
|
"Segoe UI",
|
||||||
Roboto,
|
Roboto,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar nav {
|
.topbar nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar nav a {
|
.topbar nav a {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar nav a:hover {
|
.topbar nav a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
.topbar nav a.active {
|
.topbar nav a.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards {
|
.cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-label {
|
.card-label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.card-value {
|
.card-value {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
.card-value.ok {
|
.card-value.ok {
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
.card-value.warn {
|
.card-value.warn {
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
.card-value.danger {
|
.card-value.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.card-value.muted {
|
.card-value.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table th {
|
.table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table td {
|
.table td {
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.table tr:last-child td {
|
.table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.table .num {
|
.table .num {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.ok,
|
.badge.ok,
|
||||||
.badge.done {
|
.badge.done {
|
||||||
background: rgba(52, 211, 153, 0.15);
|
background: rgba(52, 211, 153, 0.15);
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
.badge.warn,
|
.badge.warn,
|
||||||
.badge.running,
|
.badge.running,
|
||||||
.badge.queued {
|
.badge.queued {
|
||||||
background: rgba(251, 191, 36, 0.15);
|
background: rgba(251, 191, 36, 0.15);
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
.badge.danger,
|
.badge.danger,
|
||||||
.badge.error {
|
.badge.error {
|
||||||
background: rgba(248, 113, 113, 0.15);
|
background: rgba(248, 113, 113, 0.15);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #062a35;
|
color: #062a35;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.btn.primary:hover {
|
.btn.primary:hover {
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
.btn.danger {
|
.btn.danger {
|
||||||
border-color: rgba(248, 113, 113, 0.5);
|
border-color: rgba(248, 113, 113, 0.5);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.btn.danger:hover {
|
.btn.danger:hover {
|
||||||
background: rgba(248, 113, 113, 0.12);
|
background: rgba(248, 113, 113, 0.12);
|
||||||
}
|
}
|
||||||
.btn.ghost {
|
.btn.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-actions {
|
.panel-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
label.check {
|
label.check {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-top: 1.4rem;
|
margin-top: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-status {
|
.job-status {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toasts {
|
#toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-error {
|
.toast-error {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.toast-ok {
|
.toast-ok {
|
||||||
border-color: var(--ok);
|
border-color: var(--ok);
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
.toast-warn {
|
.toast-warn {
|
||||||
border-color: var(--warn);
|
border-color: var(--warn);
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.content {
|
pre.content {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
.list a {
|
.list a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.list a:hover {
|
.list a:hover {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blocklist display */
|
/* Blocklist display */
|
||||||
|
|
||||||
.table tbody[id="v4"] td {
|
.table tbody[id="v4"] td {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.table tbody[id="v4"] td .btn {
|
.table tbody[id="v4"] td .btn {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IPv4 graph page */
|
/* IPv4 graph page */
|
||||||
|
|
||||||
.graph-controls {
|
.graph-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw {
|
.sw {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.8rem;
|
width: 0.8rem;
|
||||||
height: 0.8rem;
|
height: 0.8rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.sw.red {
|
.sw.red {
|
||||||
background: #e53935;
|
background: #e53935;
|
||||||
}
|
}
|
||||||
.sw.green {
|
.sw.green {
|
||||||
background: #43a047;
|
background: #43a047;
|
||||||
}
|
}
|
||||||
.sw.plain {
|
.sw.plain {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-wrap {
|
.graph-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 920px;
|
max-width: 920px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#graph-canvas {
|
#graph-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-overlay {
|
.graph-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.g-line {
|
.g-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: rgba(148, 163, 184, 0.55);
|
background: rgba(148, 163, 184, 0.55);
|
||||||
}
|
}
|
||||||
.g-v {
|
.g-v {
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
.g-h {
|
.g-h {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.g-label {
|
.g-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(6px, 4px);
|
transform: translate(6px, 4px);
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
text-shadow: 0 0 2px #fff, 0 0 2px #fff;
|
text-shadow: 0 0 2px #fff, 0 0 2px #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-tooltip {
|
.graph-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
}
|
}
|
||||||
.graph-tooltip.hidden {
|
.graph-tooltip.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tt-head {
|
.tt-head {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.tt-status {
|
.tt-status {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.tt-status.blocked {
|
.tt-status.blocked {
|
||||||
color: #e53935;
|
color: #e53935;
|
||||||
}
|
}
|
||||||
.tt-status.whitelisted {
|
.tt-status.whitelisted {
|
||||||
color: #43a047;
|
color: #43a047;
|
||||||
}
|
}
|
||||||
.tt-status.unlisted {
|
.tt-status.unlisted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.tt-list {
|
.tt-list {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@@ -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 %}
|
||||||
+55
-50
@@ -1,50 +1,55 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{% block title %}Intrusion Detection{% endblock %}</title>
|
<title>{% block title %}Intrusion Detection{% endblock %}</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='style.css') }}"
|
href="{{ url_for('static', filename='style.css') }}"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">🛡 Intrusion Detection</div>
|
<div class="brand">🛡 Intrusion Detection</div>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{{ 'active' if request.path == '/' }}">Dashboard</a>
|
<a href="/" class="{{ 'active' if request.path == '/' }}">Dashboard</a>
|
||||||
<a href="/scan" class="{{ 'active' if request.path == '/scan' }}"
|
<a href="/scan" class="{{ 'active' if request.path == '/scan' }}"
|
||||||
>Scan</a
|
>Scan</a
|
||||||
>
|
>
|
||||||
<a href="/reports" class="{{ 'active' if request.path == '/reports' }}"
|
<a href="/reports" class="{{ 'active' if request.path == '/reports' }}"
|
||||||
>Reports</a
|
>Reports</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/blocklist"
|
href="/blocklist"
|
||||||
class="{{ 'active' if request.path == '/blocklist' }}"
|
class="{{ 'active' if request.path == '/blocklist' }}"
|
||||||
>Blocklist</a
|
>Blocklist</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/firewall"
|
href="/firewall"
|
||||||
class="{{ 'active' if request.path == '/firewall' }}"
|
class="{{ 'active' if request.path == '/firewall' }}"
|
||||||
>Firewall</a
|
>Firewall</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/whitelist"
|
href="/whitelist"
|
||||||
class="{{ 'active' if request.path == '/whitelist' }}"
|
class="{{ 'active' if request.path == '/whitelist' }}"
|
||||||
>Whitelist</a
|
>Whitelist</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/graph"
|
href="/graph"
|
||||||
class="{{ 'active' if request.path == '/graph' }}"
|
class="{{ 'active' if request.path == '/graph' }}"
|
||||||
>Graph</a
|
>Graph</a
|
||||||
>
|
>
|
||||||
</nav>
|
<a
|
||||||
</header>
|
href="/analysis"
|
||||||
<main class="container">{% block content %}{% endblock %}</main>
|
class="{{ 'active' if request.path == '/analysis' }}"
|
||||||
<div id="toasts"></div>
|
>Analysis</a
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
>
|
||||||
{% block scripts %}{% endblock %}
|
</nav>
|
||||||
</body>
|
</header>
|
||||||
</html>
|
<main class="container">{% block content %}{% endblock %}</main>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+134
-134
@@ -1,134 +1,134 @@
|
|||||||
{% extends "base.html" %} {% block title %}Blocklist — Intrusion Detection{%
|
{% extends "base.html" %} {% block title %}Blocklist — Intrusion Detection{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<h1>Blocklist</h1>
|
<h1>Blocklist</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Current consolidated blocklist (from the latest scan). Adding/removing touches
|
Current consolidated blocklist (from the latest scan). Adding/removing touches
|
||||||
the MikroTik router.
|
the MikroTik router.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head"><h2>Add / remove entry</h2></div>
|
<div class="panel-head"><h2>Add / remove entry</h2></div>
|
||||||
<div class="panel-actions" style="margin-top: 0">
|
<div class="panel-actions" style="margin-top: 0">
|
||||||
<input
|
<input
|
||||||
id="ip"
|
id="ip"
|
||||||
placeholder="IP or CIDR, e.g. 1.2.3.4 or 1.2.0.0/16"
|
placeholder="IP or CIDR, e.g. 1.2.3.4 or 1.2.0.0/16"
|
||||||
style="min-width: 260px"
|
style="min-width: 260px"
|
||||||
/>
|
/>
|
||||||
<button class="btn primary" id="add-btn">Add to Blocked</button>
|
<button class="btn primary" id="add-btn">Add to Blocked</button>
|
||||||
<button class="btn danger" id="remove-btn">Remove from Blocked</button>
|
<button class="btn danger" id="remove-btn">Remove from Blocked</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>IPv4 <span id="v4-count"></span></h2>
|
<h2>IPv4 <span id="v4-count"></span></h2>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Address</th>
|
<th>Address</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="v4"></tbody>
|
<tbody id="v4"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>IPv6 <span id="v6-count"></span></h2>
|
<h2>IPv6 <span id="v6-count"></span></h2>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Address</th>
|
<th>Address</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="v6"></tbody>
|
<tbody id="v6"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
async function loadBlocklist() {
|
async function loadBlocklist() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/blocklist");
|
const data = await api("/api/blocklist");
|
||||||
render("v4", data.ipv4 || [], "v4-count");
|
render("v4", data.ipv4 || [], "v4-count");
|
||||||
render("v6", data.ipv6 || [], "v6-count");
|
render("v6", data.ipv6 || [], "v6-count");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(tbodyId, entries, countId) {
|
function render(tbodyId, entries, countId) {
|
||||||
const body = document.getElementById(tbodyId);
|
const body = document.getElementById(tbodyId);
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
document.getElementById(countId).textContent = `(${entries.length})`;
|
document.getElementById(countId).textContent = `(${entries.length})`;
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
body.innerHTML =
|
body.innerHTML =
|
||||||
'<tr><td colspan="2" class="muted">No entries.</td></tr>';
|
'<tr><td colspan="2" class="muted">No entries.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const ip of entries) {
|
for (const ip of entries) {
|
||||||
body.append(
|
body.append(
|
||||||
el(
|
el(
|
||||||
"tr",
|
"tr",
|
||||||
el("td", { class: "mono", text: ip }),
|
el("td", { class: "mono", text: ip }),
|
||||||
el(
|
el(
|
||||||
"td",
|
"td",
|
||||||
{ style: "text-align:right" },
|
{ style: "text-align:right" },
|
||||||
el("button", {
|
el("button", {
|
||||||
class: "btn danger",
|
class: "btn danger",
|
||||||
text: "Remove",
|
text: "Remove",
|
||||||
onclick: () => remove(ip),
|
onclick: () => remove(ip),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function act(action) {
|
async function act(action) {
|
||||||
const ip = document.getElementById("ip").value.trim();
|
const ip = document.getElementById("ip").value.trim();
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
toast("Enter an IP or CIDR", "warn");
|
toast("Enter an IP or CIDR", "warn");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/blocklist/${action}`, {
|
const data = await api(`/api/blocklist/${action}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { ip },
|
body: { ip },
|
||||||
});
|
});
|
||||||
toast(
|
toast(
|
||||||
`Removed ${ip}`.replace(
|
`Removed ${ip}`.replace(
|
||||||
"Removed",
|
"Removed",
|
||||||
action === "add" ? "Added" : "Removed",
|
action === "add" ? "Added" : "Removed",
|
||||||
),
|
),
|
||||||
"ok",
|
"ok",
|
||||||
);
|
);
|
||||||
document.getElementById("ip").value = "";
|
document.getElementById("ip").value = "";
|
||||||
loadBlocklist();
|
loadBlocklist();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(ip) {
|
async function remove(ip) {
|
||||||
try {
|
try {
|
||||||
await api("/api/blocklist/remove", { method: "POST", body: { ip } });
|
await api("/api/blocklist/remove", { method: "POST", body: { ip } });
|
||||||
toast(`Removed ${ip}`, "ok");
|
toast(`Removed ${ip}`, "ok");
|
||||||
loadBlocklist();
|
loadBlocklist();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("add-btn")
|
.getElementById("add-btn")
|
||||||
.addEventListener("click", () => act("add"));
|
.addEventListener("click", () => act("add"));
|
||||||
document
|
document
|
||||||
.getElementById("remove-btn")
|
.getElementById("remove-btn")
|
||||||
.addEventListener("click", () => act("remove"));
|
.addEventListener("click", () => act("remove"));
|
||||||
loadBlocklist();
|
loadBlocklist();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+109
-102
@@ -1,102 +1,109 @@
|
|||||||
{% extends "base.html" %} {% block title %}Dashboard — Intrusion Detection{%
|
{% extends "base.html" %} {% block title %}Dashboard — Intrusion Detection{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Localhost-only control panel for the intrusion detection scanner.
|
Localhost-only control panel for the intrusion detection scanner.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="cards" id="status-cards"></div>
|
<div class="cards" id="status-cards"></div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Latest scans</h2>
|
<h2>Latest scans</h2>
|
||||||
<a class="btn ghost" href="/scan">Run a scan →</a>
|
<a class="btn ghost" href="/scan">Run a scan →</a>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Job</th>
|
<th>Job</th>
|
||||||
<th>Kind</th>
|
<th>Kind</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Finished</th>
|
<th>Finished</th>
|
||||||
<th class="num">Blocked</th>
|
<th class="num">Blocked</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="latest-scans"></tbody>
|
<tbody id="latest-scans"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
try {
|
try {
|
||||||
const s = await api("/api/status");
|
const s = await api("/api/status");
|
||||||
renderCards(s);
|
renderCards(s);
|
||||||
renderScans(s.latest_scans || []);
|
renderScans(s.latest_scans || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCards(s) {
|
function renderCards(s) {
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
label: "Scan running",
|
label: "Scan running",
|
||||||
value: s.scan_running ? "Yes" : "No",
|
value: s.scan_running ? "Yes" : "No",
|
||||||
tone: s.scan_running ? "warn" : "ok",
|
tone: s.scan_running ? "warn" : "ok",
|
||||||
},
|
},
|
||||||
{ label: "Blocklist IPv4", value: s.blocklist_ipv4_count ?? "—" },
|
{ label: "Blocklist IPv4", value: s.blocklist_ipv4_count ?? "—" },
|
||||||
{ label: "Blocklist IPv6", value: s.blocklist_ipv6_count ?? "—" },
|
{ label: "Blocklist IPv6", value: s.blocklist_ipv6_count ?? "—" },
|
||||||
{
|
{
|
||||||
label: "Firewall snapshot",
|
label: "Firewall snapshot",
|
||||||
value:
|
value:
|
||||||
s.firewall_snapshot && s.firewall_snapshot.exists
|
s.firewall_snapshot && s.firewall_snapshot.exists
|
||||||
? "Present"
|
? "Present"
|
||||||
: "None",
|
: "None",
|
||||||
tone:
|
tone:
|
||||||
s.firewall_snapshot && s.firewall_snapshot.exists ? "ok" : "muted",
|
s.firewall_snapshot && s.firewall_snapshot.exists ? "ok" : "muted",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const box = document.getElementById("status-cards");
|
if (s.analysis && s.analysis.available) {
|
||||||
box.innerHTML = "";
|
cards.push({
|
||||||
for (const c of cards) {
|
label: "SQLite stores",
|
||||||
box.append(
|
value: `${s.analysis.request_count ?? 0} req / ${s.analysis.scan_count ?? 0} scans`,
|
||||||
el(
|
tone: "ok",
|
||||||
"div",
|
});
|
||||||
{ class: "card" },
|
}
|
||||||
el("div", { class: "card-label", text: c.label }),
|
const box = document.getElementById("status-cards");
|
||||||
el("div", { class: `card-value ${c.tone || ""}`, text: c.value }),
|
box.innerHTML = "";
|
||||||
),
|
for (const c of cards) {
|
||||||
);
|
box.append(
|
||||||
}
|
el(
|
||||||
}
|
"div",
|
||||||
|
{ class: "card" },
|
||||||
function renderScans(scans) {
|
el("div", { class: "card-label", text: c.label }),
|
||||||
const body = document.getElementById("latest-scans");
|
el("div", { class: `card-value ${c.tone || ""}`, text: c.value }),
|
||||||
body.innerHTML = "";
|
),
|
||||||
if (!scans.length) {
|
);
|
||||||
body.innerHTML =
|
}
|
||||||
'<tr><td colspan="6" class="muted">No scans yet — run one from the Scan page.</td></tr>';
|
}
|
||||||
return;
|
|
||||||
}
|
function renderScans(scans) {
|
||||||
for (const j of scans) {
|
const body = document.getElementById("latest-scans");
|
||||||
const r = j.result || {};
|
body.innerHTML = "";
|
||||||
body.append(
|
if (!scans.length) {
|
||||||
el(
|
body.innerHTML =
|
||||||
"tr",
|
'<tr><td colspan="6" class="muted">No scans yet — run one from the Scan page.</td></tr>';
|
||||||
el("td", { class: "mono", text: j.job_id }),
|
return;
|
||||||
el("td", { text: j.kind }),
|
}
|
||||||
el("td", {}, badge(j.state)),
|
for (const j of scans) {
|
||||||
el("td", { text: fmtTime(j.started) }),
|
const r = j.result || {};
|
||||||
el("td", { text: fmtTime(j.finished) }),
|
body.append(
|
||||||
el("td", { class: "num", text: r.blocked_targets ?? "—" }),
|
el(
|
||||||
),
|
"tr",
|
||||||
);
|
el("td", { class: "mono", text: j.job_id }),
|
||||||
}
|
el("td", { text: j.kind }),
|
||||||
}
|
el("td", {}, badge(j.state)),
|
||||||
|
el("td", { text: fmtTime(j.started) }),
|
||||||
loadDashboard();
|
el("td", { text: fmtTime(j.finished) }),
|
||||||
// Keep the latest-scans table fresh without hammering /api/scan/status.
|
el("td", { class: "num", text: r.blocked_targets ?? "—" }),
|
||||||
setInterval(loadDashboard, 15000);
|
),
|
||||||
</script>
|
);
|
||||||
{% endblock %}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard();
|
||||||
|
// Keep the latest-scans table fresh without hammering /api/scan/status.
|
||||||
|
setInterval(loadDashboard, 15000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+221
-176
@@ -1,176 +1,221 @@
|
|||||||
{% extends "base.html" %} {% block title %}Firewall — Intrusion Detection{%
|
{% extends "base.html" %} {% block title %}Firewall — Intrusion Detection{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<h1>Firewall</h1>
|
<h1>Firewall</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Current MikroTik state (from the latest <code>data/mikrotik</code> snapshot).
|
Current MikroTik state (from the latest <code>data/mikrotik</code> snapshot).
|
||||||
Sync previews or applies the blocklist diff.
|
Sync previews or applies the blocklist diff.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-actions" style="margin-top: 0">
|
<div class="panel-actions" style="margin-top: 0">
|
||||||
<button class="btn" id="preview-btn">Preview sync (dry-run)</button>
|
<button class="btn" id="preview-btn">Preview sync (dry-run)</button>
|
||||||
<button class="btn danger" id="apply-btn">Apply sync</button>
|
<button class="btn danger" id="apply-btn">Apply sync</button>
|
||||||
<span class="muted" id="fw-result"></span>
|
<span class="muted" id="fw-result"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head"><h2>Address lists</h2></div>
|
<div class="panel-head"><h2>Address lists</h2></div>
|
||||||
<div id="address-lists"></div>
|
<div id="address-lists"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head"><h2>Firewall rules</h2></div>
|
<div class="panel-head"><h2>Firewall rules</h2></div>
|
||||||
<div id="firewall-rules"></div>
|
<div id="firewall-rules"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
<div class="panel">
|
||||||
async function loadState() {
|
<div class="panel-head"><h2>Snapshot history (SQLite)</h2></div>
|
||||||
try {
|
<div id="fw-snapshots"></div>
|
||||||
const s = await api("/api/firewall/state");
|
</div>
|
||||||
renderAddressLists(s.address_lists || {});
|
{% endblock %} {% block scripts %}
|
||||||
renderRules("firewall-rules", s.firewall_rules || {});
|
<script>
|
||||||
} catch (e) {
|
async function loadState() {
|
||||||
toast(e.message, "error");
|
try {
|
||||||
}
|
const s = await api("/api/firewall/state");
|
||||||
}
|
renderAddressLists(s.address_lists || {});
|
||||||
|
renderRules("firewall-rules", s.firewall_rules || {});
|
||||||
function renderAddressLists(addr) {
|
} catch (e) {
|
||||||
const box = document.getElementById("address-lists");
|
toast(e.message, "error");
|
||||||
box.innerHTML = "";
|
}
|
||||||
const byList = {};
|
}
|
||||||
for (const version of ["ipv4", "ipv6"]) {
|
|
||||||
for (const e of addr[version] || []) {
|
async function loadSnapshots() {
|
||||||
const name = e.list || "?";
|
const box = document.getElementById("fw-snapshots");
|
||||||
(byList[name] = byList[name] || { ipv4: [], ipv6: [] })[version].push(
|
try {
|
||||||
e.address,
|
const d = await api("/api/firewall/snapshots?limit=10");
|
||||||
);
|
const snaps = d.snapshots || [];
|
||||||
}
|
if (!snaps.length) {
|
||||||
}
|
box.innerHTML =
|
||||||
const names = Object.keys(byList);
|
'<p class="muted">No SQLite snapshots yet — they are recorded on each <code>--update-firewall</code> run.</p>';
|
||||||
if (!names.length) {
|
return;
|
||||||
box.innerHTML =
|
}
|
||||||
'<p class="muted">No address-list snapshot available. Run <code>app.py --update-firewall</code> first.</p>';
|
box.innerHTML = "";
|
||||||
return;
|
const rows = snaps.map((s) =>
|
||||||
}
|
el(
|
||||||
for (const name of names) {
|
"tr",
|
||||||
const rows = [];
|
el("td", { class: "mono", text: `#${s.id}` }),
|
||||||
for (const version of ["ipv4", "ipv6"]) {
|
el("td", { text: fmtTime(s.captured_at) }),
|
||||||
const entries = (byList[name][version] || []).sort();
|
el("td", { text: s.kind }),
|
||||||
rows.push(
|
el("td", { text: s.trigger }),
|
||||||
el(
|
el("td", { class: "num", text: s.addr_entries }),
|
||||||
"tr",
|
el("td", { class: "num", text: s.rule_entries }),
|
||||||
el("td", { text: version }),
|
),
|
||||||
el("td", { class: "mono", text: entries.join(", ") || "—" }),
|
);
|
||||||
),
|
box.append(
|
||||||
);
|
el(
|
||||||
}
|
"table",
|
||||||
box.append(
|
{ class: "table" },
|
||||||
el(
|
el(
|
||||||
"div",
|
"thead",
|
||||||
{ style: "margin-bottom:1rem" },
|
el("tr", {}, el("th", { text: "ID" }), el("th", { text: "Captured" }), el("th", { text: "Kind" }), el("th", { text: "Trigger" }), el("th", { text: "Addr" }), el("th", { text: "Rules" })),
|
||||||
el("div", {
|
),
|
||||||
style: "font-weight:600;margin-bottom:.4rem",
|
el("tbody", {}, ...rows),
|
||||||
text: name,
|
),
|
||||||
}),
|
);
|
||||||
el(
|
} catch (e) {
|
||||||
"table",
|
box.innerHTML=
|
||||||
{ class: "table" },
|
'<p class="muted">Snapshot history unavailable (SQLite).</p>';
|
||||||
el(
|
}
|
||||||
"thead",
|
}
|
||||||
{},
|
|
||||||
el(
|
function renderAddressLists(addr) {
|
||||||
"tr",
|
const box = document.getElementById("address-lists");
|
||||||
{},
|
box.innerHTML = "";
|
||||||
el("th", { text: "Version" }),
|
const byList = {};
|
||||||
el("th", { text: "Addresses" }),
|
for (const version of ["ipv4", "ipv6"]) {
|
||||||
),
|
for (const e of addr[version] || []) {
|
||||||
),
|
const name = e.list || "?";
|
||||||
el("tbody", {}, ...rows),
|
(byList[name] = byList[name] || { ipv4: [], ipv6: [] })[version].push(
|
||||||
),
|
e.address,
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
}
|
const names = Object.keys(byList);
|
||||||
|
if (!names.length) {
|
||||||
function renderRules(boxId, rules) {
|
box.innerHTML =
|
||||||
const box = document.getElementById(boxId);
|
'<p class="muted">No address-list snapshot available. Run <code>app.py --update-firewall</code> first.</p>';
|
||||||
box.innerHTML = "";
|
return;
|
||||||
for (const version of ["ipv4", "ipv6"]) {
|
}
|
||||||
const list = rules[version] || [];
|
for (const name of names) {
|
||||||
const rows = list.map((r) =>
|
const rows = [];
|
||||||
el(
|
for (const version of ["ipv4", "ipv6"]) {
|
||||||
"tr",
|
const entries = (byList[name][version] || []).sort();
|
||||||
el("td", {
|
rows.push(
|
||||||
class: "mono",
|
el(
|
||||||
text: r.address ? r.address : r["src-address-list"] || "",
|
"tr",
|
||||||
}),
|
el("td", { text: version }),
|
||||||
el("td", { text: r.chain || "—" }),
|
el("td", { class: "mono", text: entries.join(", ") || "—" }),
|
||||||
el("td", { text: r.action || "—" }),
|
),
|
||||||
el("td", { text: r.comment || "" }),
|
);
|
||||||
),
|
}
|
||||||
);
|
box.append(
|
||||||
box.append(
|
el(
|
||||||
el(
|
"div",
|
||||||
"div",
|
{ style: "margin-bottom:1rem" },
|
||||||
{ style: "margin-bottom:1rem" },
|
el("div", {
|
||||||
el("div", {
|
style: "font-weight:600;margin-bottom:.4rem",
|
||||||
style: "font-weight:600;margin-bottom:.4rem",
|
text: name,
|
||||||
text: `IPv${version} (${list.length})`,
|
}),
|
||||||
}),
|
el(
|
||||||
rows.length
|
"table",
|
||||||
? el(
|
{ class: "table" },
|
||||||
"table",
|
el(
|
||||||
{ class: "table" },
|
"thead",
|
||||||
el(
|
{},
|
||||||
"thead",
|
el(
|
||||||
{},
|
"tr",
|
||||||
el(
|
{},
|
||||||
"tr",
|
el("th", { text: "Version" }),
|
||||||
{},
|
el("th", { text: "Addresses" }),
|
||||||
el("th", { text: "Address / List" }),
|
),
|
||||||
el("th", { text: "Chain" }),
|
),
|
||||||
el("th", { text: "Action" }),
|
el("tbody", {}, ...rows),
|
||||||
el("th", { text: "Comment" }),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
el("tbody", {}, ...rows),
|
}
|
||||||
)
|
}
|
||||||
: el("p", { class: "muted", text: "No rules in snapshot." }),
|
|
||||||
),
|
function renderRules(boxId, rules) {
|
||||||
);
|
const box = document.getElementById(boxId);
|
||||||
}
|
box.innerHTML = "";
|
||||||
}
|
for (const version of ["ipv4", "ipv6"]) {
|
||||||
|
const list = rules[version] || [];
|
||||||
async function sync(dryRun) {
|
const rows = list.map((r) =>
|
||||||
const out = document.getElementById("fw-result");
|
el(
|
||||||
out.textContent = "Working…";
|
"tr",
|
||||||
try {
|
el("td", {
|
||||||
const changes = await api(`/api/firewall/${dryRun ? "sync" : "apply"}`, {
|
class: "mono",
|
||||||
method: "POST",
|
text: r.address ? r.address : r["src-address-list"] || "",
|
||||||
body: {},
|
}),
|
||||||
});
|
el("td", { text: r.chain || "—" }),
|
||||||
const verb = dryRun ? "Would" : "";
|
el("td", { text: r.action || "—" }),
|
||||||
out.textContent =
|
el("td", { text: r.comment || "" }),
|
||||||
`${verb} add ${(changes.added_ipv4 || []).length} IPv4 + ${(changes.added_ipv6 || []).length} IPv6; ` +
|
),
|
||||||
`remove ${(changes.removed_ipv4 || []).length} IPv4 + ${(changes.removed_ipv6 || []).length} IPv6.`;
|
);
|
||||||
toast(
|
box.append(
|
||||||
(dryRun ? "Preview" : "Applied") + " firewall sync",
|
el(
|
||||||
dryRun ? "warn" : "ok",
|
"div",
|
||||||
);
|
{ style: "margin-bottom:1rem" },
|
||||||
if (!dryRun) loadState();
|
el("div", {
|
||||||
} catch (e) {
|
style: "font-weight:600;margin-bottom:.4rem",
|
||||||
out.textContent = "";
|
text: `IPv${version} (${list.length})`,
|
||||||
toast(e.message, "error");
|
}),
|
||||||
}
|
rows.length
|
||||||
}
|
? el(
|
||||||
|
"table",
|
||||||
document
|
{ class: "table" },
|
||||||
.getElementById("preview-btn")
|
el(
|
||||||
.addEventListener("click", () => sync(true));
|
"thead",
|
||||||
document
|
{},
|
||||||
.getElementById("apply-btn")
|
el(
|
||||||
.addEventListener("click", () => sync(false));
|
"tr",
|
||||||
loadState();
|
{},
|
||||||
</script>
|
el("th", { text: "Address / List" }),
|
||||||
{% endblock %}
|
el("th", { text: "Chain" }),
|
||||||
|
el("th", { text: "Action" }),
|
||||||
|
el("th", { text: "Comment" }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
el("tbody", {}, ...rows),
|
||||||
|
)
|
||||||
|
: el("p", { class: "muted", text: "No rules in snapshot." }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sync(dryRun) {
|
||||||
|
const out = document.getElementById("fw-result");
|
||||||
|
out.textContent = "Working…";
|
||||||
|
try {
|
||||||
|
const changes = await api(`/api/firewall/${dryRun ? "sync" : "apply"}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
const verb = dryRun ? "Would" : "";
|
||||||
|
out.textContent =
|
||||||
|
`${verb} add ${(changes.added_ipv4 || []).length} IPv4 + ${(changes.added_ipv6 || []).length} IPv6; ` +
|
||||||
|
`remove ${(changes.removed_ipv4 || []).length} IPv4 + ${(changes.removed_ipv6 || []).length} IPv6.`;
|
||||||
|
toast(
|
||||||
|
(dryRun ? "Preview" : "Applied") + " firewall sync",
|
||||||
|
dryRun ? "warn" : "ok",
|
||||||
|
);
|
||||||
|
if (!dryRun) loadState();
|
||||||
|
} catch (e) {
|
||||||
|
out.textContent = "";
|
||||||
|
toast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("preview-btn")
|
||||||
|
.addEventListener("click", () => sync(true));
|
||||||
|
document
|
||||||
|
.getElementById("apply-btn")
|
||||||
|
.addEventListener("click", () => sync(false));
|
||||||
|
loadState();
|
||||||
|
loadSnapshots();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+12
-29
@@ -1,9 +1,8 @@
|
|||||||
{% extends "base.html" %} {% block title %}Graph — Intrusion Detection{%
|
{% extends "base.html" %} {% block title %}Graph — Intrusion Detection{% endblock %} {% block content %}
|
||||||
endblock %} {% block content %}
|
|
||||||
<h1>IPv4 address space map</h1>
|
<h1>IPv4 address space map</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
One pixel per /24 network, arranged as 16×16 tiles of /8 (first octet).
|
One pixel per /24 network, arranged as 16×16 tiles of /8 (first octet). Blocked targets are red, whitelisted ranges
|
||||||
Blocked targets are red, whitelisted ranges are green.
|
are green.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -14,8 +13,7 @@ endblock %} {% block content %}
|
|||||||
Show whitelisted
|
Show whitelisted
|
||||||
</label>
|
</label>
|
||||||
<span class="legend">
|
<span class="legend">
|
||||||
<span class="sw red"></span> Blocked
|
<span class="sw red"></span> Blocked <span class="sw green"></span> Whitelisted
|
||||||
<span class="sw green"></span> Whitelisted
|
|
||||||
<span class="sw plain"></span> Unlisted
|
<span class="sw plain"></span> Unlisted
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +111,7 @@ endblock %} {% block content %}
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const showWl = document.getElementById("show-whitelisted").checked;
|
const showWl = document.getElementById("show-whitelisted").checked;
|
||||||
ctx.fillStyle = "#ffffff";
|
ctx.fillStyle = "#0c0c0c";
|
||||||
ctx.fillRect(0, 0, TOTAL, TOTAL);
|
ctx.fillRect(0, 0, TOTAL, TOTAL);
|
||||||
for (const [idx, value] of marked) {
|
for (const [idx, value] of marked) {
|
||||||
if (!showWl && value === V_WHITELISTED) continue;
|
if (!showWl && value === V_WHITELISTED) continue;
|
||||||
@@ -142,37 +140,24 @@ endblock %} {% block content %}
|
|||||||
const c = y % SIZE;
|
const c = y % SIZE;
|
||||||
const idx = (a << 16) | (b << 8) | c;
|
const idx = (a << 16) | (b << 8) | c;
|
||||||
const value = marked.get(idx) ?? V_UNLISTED;
|
const value = marked.get(idx) ?? V_UNLISTED;
|
||||||
const status =
|
const status = value === V_BLOCKED ? "blocked" : value === V_WHITELISTED ? "whitelisted" : "unlisted";
|
||||||
value === V_BLOCKED
|
const entries = matchesIn(blockedRanges, idx).concat(matchesIn(whitelistedRanges, idx));
|
||||||
? "blocked"
|
|
||||||
: value === V_WHITELISTED
|
|
||||||
? "whitelisted"
|
|
||||||
: "unlisted";
|
|
||||||
const entries = matchesIn(blockedRanges, idx).concat(
|
|
||||||
matchesIn(whitelistedRanges, idx),
|
|
||||||
);
|
|
||||||
tooltip.innerHTML = "";
|
tooltip.innerHTML = "";
|
||||||
tooltip.append(
|
tooltip.append(
|
||||||
el("div", { class: "tt-head mono", text: `${a}.${b}.${c}.0/24` }),
|
el("div", { class: "tt-head mono", text: `${a}.${b}.${c}.0/24` }),
|
||||||
el("div", { class: `tt-status ${status}`, text: status }),
|
el("div", { class: `tt-status ${status}`, text: status }),
|
||||||
);
|
);
|
||||||
if (entries.length) {
|
if (entries.length) {
|
||||||
tooltip.append(
|
tooltip.append(el("div", { class: "tt-list mono", text: entries.join("\n") }));
|
||||||
el("div", { class: "tt-list mono", text: entries.join("\n") }),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
tooltip.append(
|
tooltip.append(el("div", { class: "tt-list muted", text: "no matching entry" }));
|
||||||
el("div", { class: "tt-list muted", text: "no matching entry" }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
tooltip.classList.remove("hidden");
|
tooltip.classList.remove("hidden");
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
let left = e.clientX - rect.left + 14;
|
let left = e.clientX - rect.left + 14;
|
||||||
let top = e.clientY - rect.top + 14;
|
let top = e.clientY - rect.top + 14;
|
||||||
if (left + tooltip.offsetWidth > rect.width)
|
if (left + tooltip.offsetWidth > rect.width) left = e.clientX - rect.left - tooltip.offsetWidth - 14;
|
||||||
left = e.clientX - rect.left - tooltip.offsetWidth - 14;
|
if (top + tooltip.offsetHeight > rect.height) top = e.clientY - rect.top - tooltip.offsetHeight - 14;
|
||||||
if (top + tooltip.offsetHeight > rect.height)
|
|
||||||
top = e.clientY - rect.top - tooltip.offsetHeight - 14;
|
|
||||||
tooltip.style.left = `${Math.max(0, left)}px`;
|
tooltip.style.left = `${Math.max(0, left)}px`;
|
||||||
tooltip.style.top = `${Math.max(0, top)}px`;
|
tooltip.style.top = `${Math.max(0, top)}px`;
|
||||||
}
|
}
|
||||||
@@ -217,9 +202,7 @@ endblock %} {% block content %}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
document.getElementById("show-whitelisted").addEventListener("change", render);
|
||||||
.getElementById("show-whitelisted")
|
|
||||||
.addEventListener("change", render);
|
|
||||||
document.getElementById("graph-refresh").addEventListener("click", loadGraph);
|
document.getElementById("graph-refresh").addEventListener("click", loadGraph);
|
||||||
|
|
||||||
loadGraph();
|
loadGraph();
|
||||||
|
|||||||
+75
-27
@@ -1,5 +1,4 @@
|
|||||||
{% extends "base.html" %} {% block title %}Reports — Intrusion Detection{%
|
{% extends "base.html" %} {% block title %}Reports — Intrusion Detection{% endblock %} {% block content %}
|
||||||
endblock %} {% block content %}
|
|
||||||
<h1>Reports</h1>
|
<h1>Reports</h1>
|
||||||
<p class="muted">Browse the timestamped reports written by each scanner.</p>
|
<p class="muted">Browse the timestamped reports written by each scanner.</p>
|
||||||
|
|
||||||
@@ -21,38 +20,87 @@ endblock %} {% block content %}
|
|||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head"><h2 id="content-title">Report content</h2></div>
|
<div class="panel-head"><h2 id="content-title">Report content</h2></div>
|
||||||
<pre class="content" id="content">
|
<pre class="content" id="content">Select a report on the left to view it.</pre>
|
||||||
Select a report on the left to view it.</pre
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
async function loadFiles() {
|
let nextCursor = null;
|
||||||
|
|
||||||
|
function addReportLink(list, label, onClick) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = "#";
|
||||||
|
link.textContent = label;
|
||||||
|
link.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
list.append(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles(append = false) {
|
||||||
const source = document.getElementById("source").value;
|
const source = document.getElementById("source").value;
|
||||||
const list = document.getElementById("files");
|
const list = document.getElementById("files");
|
||||||
list.innerHTML = '<div class="muted">Loading…</div>';
|
if (!append) {
|
||||||
|
nextCursor = null;
|
||||||
|
list.innerHTML = '<div class="muted">Loading…</div>';
|
||||||
|
} else {
|
||||||
|
document.getElementById("load-more")?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedPaths = new Set();
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/reports/${source}`);
|
const query = new URLSearchParams({ source, limit: "50" });
|
||||||
list.innerHTML = "";
|
if (append && nextCursor) query.set("cursor", nextCursor);
|
||||||
const files = data.files || [];
|
const data = await api(`/api/reports?${query}`);
|
||||||
if (!files.length) {
|
if (!append) list.innerHTML = "";
|
||||||
list.innerHTML = '<div class="muted">No reports in this source.</div>';
|
for (const artifact of data.items || []) {
|
||||||
return;
|
importedPaths.add(artifact.original_path);
|
||||||
}
|
addReportLink(list, `${artifact.generated_at} | ${artifact.report_type} | ${artifact.original_path}`, () =>
|
||||||
for (const name of files) {
|
openArtifact(artifact.id),
|
||||||
list.append(
|
|
||||||
el("a", {
|
|
||||||
href: "#",
|
|
||||||
text: name,
|
|
||||||
onclick: (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
openReport(name);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
nextCursor = data.next_cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
list.innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`;
|
if (append) {
|
||||||
|
list.append(el("div", { className: "muted", text: e.message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!append) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/reports/${source}`);
|
||||||
|
for (const name of data.files || []) {
|
||||||
|
if (importedPaths.has(`${source}/${name}`)) continue;
|
||||||
|
addReportLink(list, `${name} | legacy file`, () => openReport(name));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!list.childElementCount) list.append(el("div", { className: "muted", text: e.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCursor) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.id = "load-more";
|
||||||
|
button.className = "btn";
|
||||||
|
button.textContent = "Load more";
|
||||||
|
button.addEventListener("click", () => loadFiles(true));
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
if (!list.childElementCount) list.innerHTML = '<div class="muted">No reports in this source.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openArtifact(id) {
|
||||||
|
document.getElementById("content-title").textContent = "Database report";
|
||||||
|
const pre = document.getElementById("content");
|
||||||
|
pre.textContent = "Loading…";
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/report-artifacts/${id}`);
|
||||||
|
document.getElementById("content-title").textContent = data.original_path;
|
||||||
|
const payload = data.payload || {};
|
||||||
|
pre.textContent = payload.format === "text" ? payload.content || "" : JSON.stringify(payload, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
pre.textContent = `Error: ${e.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +117,8 @@ Select a report on the left to view it.</pre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("refresh-btn").addEventListener("click", loadFiles);
|
document.getElementById("refresh-btn").addEventListener("click", () => loadFiles());
|
||||||
document.getElementById("source").addEventListener("change", loadFiles);
|
document.getElementById("source").addEventListener("change", () => loadFiles());
|
||||||
loadFiles();
|
loadFiles();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+185
-185
@@ -1,185 +1,185 @@
|
|||||||
{% extends "base.html" %} {% block title %}Scan — Intrusion Detection{% endblock
|
{% extends "base.html" %} {% block title %}Scan — Intrusion Detection{% endblock
|
||||||
%} {% block content %}
|
%} {% block content %}
|
||||||
<h1>Run a scan</h1>
|
<h1>Run a scan</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Scans run in the background; only one at a time. You can leave this page and
|
Scans run in the background; only one at a time. You can leave this page and
|
||||||
come back.
|
come back.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<form id="scan-form" class="form-grid">
|
<form id="scan-form" class="form-grid">
|
||||||
<label
|
<label
|
||||||
>Type
|
>Type
|
||||||
<select id="kind">
|
<select id="kind">
|
||||||
<option value="consolidated">Consolidated (copyparty + npm)</option>
|
<option value="consolidated">Consolidated (copyparty + npm)</option>
|
||||||
<option value="copyparty">copyparty only</option>
|
<option value="copyparty">copyparty only</option>
|
||||||
<option value="npm">npm only</option>
|
<option value="npm">npm only</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Top IPs <input id="top" type="number" value="30" min="1" /></label>
|
<label>Top IPs <input id="top" type="number" value="30" min="1" /></label>
|
||||||
<label
|
<label
|
||||||
>Min denied (copyparty)
|
>Min denied (copyparty)
|
||||||
<input id="min_denied" type="number" value="1" min="0"
|
<input id="min_denied" type="number" value="1" min="0"
|
||||||
/></label>
|
/></label>
|
||||||
<label
|
<label
|
||||||
>Min errors (npm) <input id="min_errors" type="number" value="5" min="0"
|
>Min errors (npm) <input id="min_errors" type="number" value="5" min="0"
|
||||||
/></label>
|
/></label>
|
||||||
<label
|
<label
|
||||||
>Error ratio (npm)
|
>Error ratio (npm)
|
||||||
<input
|
<input
|
||||||
id="error_ratio"
|
id="error_ratio"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.05"
|
step="0.05"
|
||||||
value="0.5"
|
value="0.5"
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
/></label>
|
/></label>
|
||||||
<label class="check"
|
<label class="check"
|
||||||
><input id="update_firewall" type="checkbox" /> Update firewall after
|
><input id="update_firewall" type="checkbox" /> Update firewall after
|
||||||
scan</label
|
scan</label
|
||||||
>
|
>
|
||||||
<label class="check"
|
<label class="check"
|
||||||
><input id="dry_run" type="checkbox" /> Dry-run (preview, no
|
><input id="dry_run" type="checkbox" /> Dry-run (preview, no
|
||||||
changes)</label
|
changes)</label
|
||||||
>
|
>
|
||||||
<label class="check"
|
<label class="check"
|
||||||
><input id="full" type="checkbox" /> Full scan (ignore incremental
|
><input id="full" type="checkbox" /> Full scan (ignore incremental
|
||||||
state)</label
|
state)</label
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
<button class="btn primary" id="run-btn">Run scan</button>
|
<button class="btn primary" id="run-btn">Run scan</button>
|
||||||
<span class="muted" id="busy-hint"></span>
|
<span class="muted" id="busy-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="job-status" class="job-status"></div>
|
<div id="job-status" class="job-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head"><h2>Recent jobs</h2></div>
|
<div class="panel-head"><h2>Recent jobs</h2></div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Job</th>
|
<th>Job</th>
|
||||||
<th>Kind</th>
|
<th>Kind</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Finished</th>
|
<th>Finished</th>
|
||||||
<th class="num">Blocked</th>
|
<th class="num">Blocked</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="jobs"></tbody>
|
<tbody id="jobs"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
|
||||||
const form = document.getElementById("scan-form");
|
const form = document.getElementById("scan-form");
|
||||||
const runBtn = document.getElementById("run-btn");
|
const runBtn = document.getElementById("run-btn");
|
||||||
const hint = document.getElementById("busy-hint");
|
const hint = document.getElementById("busy-hint");
|
||||||
|
|
||||||
async function runScan() {
|
async function runScan() {
|
||||||
const kind = document.getElementById("kind").value;
|
const kind = document.getElementById("kind").value;
|
||||||
const params = {
|
const params = {
|
||||||
top: parseInt(document.getElementById("top").value, 10) || 30,
|
top: parseInt(document.getElementById("top").value, 10) || 30,
|
||||||
min_denied:
|
min_denied:
|
||||||
parseInt(document.getElementById("min_denied").value, 10) || 1,
|
parseInt(document.getElementById("min_denied").value, 10) || 1,
|
||||||
min_errors:
|
min_errors:
|
||||||
parseInt(document.getElementById("min_errors").value, 10) || 5,
|
parseInt(document.getElementById("min_errors").value, 10) || 5,
|
||||||
error_ratio:
|
error_ratio:
|
||||||
parseFloat(document.getElementById("error_ratio").value) || 0.5,
|
parseFloat(document.getElementById("error_ratio").value) || 0.5,
|
||||||
update_firewall: document.getElementById("update_firewall").checked,
|
update_firewall: document.getElementById("update_firewall").checked,
|
||||||
dry_run: document.getElementById("dry_run").checked,
|
dry_run: document.getElementById("dry_run").checked,
|
||||||
full: document.getElementById("full").checked,
|
full: document.getElementById("full").checked,
|
||||||
quiet: true,
|
quiet: true,
|
||||||
};
|
};
|
||||||
runBtn.disabled = true;
|
runBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const job = await startScan(kind, params);
|
const job = await startScan(kind, params);
|
||||||
setStatus(`Job ${job.job_id} started (${job.state}).`, "ok");
|
setStatus(`Job ${job.job_id} started (${job.state}).`, "ok");
|
||||||
poll(job.job_id);
|
poll(job.job_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(e.message, "error");
|
setStatus(e.message, "error");
|
||||||
runBtn.disabled = false;
|
runBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(msg, type) {
|
function setStatus(msg, type) {
|
||||||
const box = document.getElementById("job-status");
|
const box = document.getElementById("job-status");
|
||||||
box.textContent = msg;
|
box.textContent = msg;
|
||||||
box.style.color =
|
box.style.color =
|
||||||
type === "error"
|
type === "error"
|
||||||
? "var(--danger)"
|
? "var(--danger)"
|
||||||
: type === "ok"
|
: type === "ok"
|
||||||
? "var(--ok)"
|
? "var(--ok)"
|
||||||
: "inherit";
|
: "inherit";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll(jobId) {
|
async function poll(jobId) {
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
let job;
|
let job;
|
||||||
try {
|
try {
|
||||||
job = await api(`/api/scan/${jobId}`);
|
job = await api(`/api/scan/${jobId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(e.message, "error");
|
setStatus(e.message, "error");
|
||||||
runBtn.disabled = false;
|
runBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r = job.result || {};
|
const r = job.result || {};
|
||||||
setStatus(
|
setStatus(
|
||||||
`[${job.state}] ${job.kind} — started ${fmtTime(job.started)}` +
|
`[${job.state}] ${job.kind} — started ${fmtTime(job.started)}` +
|
||||||
(job.finished ? `, finished ${fmtTime(job.finished)}` : "") +
|
(job.finished ? `, finished ${fmtTime(job.finished)}` : "") +
|
||||||
(r.blocked_targets != null
|
(r.blocked_targets != null
|
||||||
? `, blocked targets: ${r.blocked_targets}`
|
? `, blocked targets: ${r.blocked_targets}`
|
||||||
: "") +
|
: "") +
|
||||||
(job.error ? `, error: ${job.error}` : ""),
|
(job.error ? `, error: ${job.error}` : ""),
|
||||||
job.state === "error" ? "error" : job.state === "done" ? "ok" : "",
|
job.state === "error" ? "error" : job.state === "done" ? "ok" : "",
|
||||||
);
|
);
|
||||||
if (job.state === "running" || job.state === "queued") {
|
if (job.state === "running" || job.state === "queued") {
|
||||||
pollTimer = setTimeout(() => poll(jobId), 2000);
|
pollTimer = setTimeout(() => poll(jobId), 2000);
|
||||||
} else {
|
} else {
|
||||||
runBtn.disabled = false;
|
runBtn.disabled = false;
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
const running = (await api("/api/scan/status")).running;
|
const running = (await api("/api/scan/status")).running;
|
||||||
hint.textContent = running ? "" : "";
|
hint.textContent = running ? "" : "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshJobs() {
|
async function refreshJobs() {
|
||||||
try {
|
try {
|
||||||
const s = await api("/api/scan/status");
|
const s = await api("/api/scan/status");
|
||||||
hint.textContent = s.running ? "A scan is currently running…" : "";
|
hint.textContent = s.running ? "A scan is currently running…" : "";
|
||||||
const body = document.getElementById("jobs");
|
const body = document.getElementById("jobs");
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
const jobs = s.latest_scans || [];
|
const jobs = s.latest_scans || [];
|
||||||
if (!jobs.length) {
|
if (!jobs.length) {
|
||||||
body.innerHTML =
|
body.innerHTML =
|
||||||
'<tr><td colspan="6" class="muted">No jobs yet.</td></tr>';
|
'<tr><td colspan="6" class="muted">No jobs yet.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const j of jobs) {
|
for (const j of jobs) {
|
||||||
const r = j.result || {};
|
const r = j.result || {};
|
||||||
body.append(
|
body.append(
|
||||||
el(
|
el(
|
||||||
"tr",
|
"tr",
|
||||||
el("td", { class: "mono", text: j.job_id }),
|
el("td", { class: "mono", text: j.job_id }),
|
||||||
el("td", { text: j.kind }),
|
el("td", { text: j.kind }),
|
||||||
el("td", {}, badge(j.state)),
|
el("td", {}, badge(j.state)),
|
||||||
el("td", { text: fmtTime(j.started) }),
|
el("td", { text: fmtTime(j.started) }),
|
||||||
el("td", { text: fmtTime(j.finished) }),
|
el("td", { text: fmtTime(j.finished) }),
|
||||||
el("td", { class: "num", text: r.blocked_targets ?? "—" }),
|
el("td", { class: "num", text: r.blocked_targets ?? "—" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runBtn.addEventListener("click", runScan);
|
runBtn.addEventListener("click", runScan);
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
// /api/scan/status is only polled every 15 s; the active job's own
|
// /api/scan/status is only polled every 15 s; the active job's own
|
||||||
// 2 s poll keeps live progress without hammering the status endpoint.
|
// 2 s poll keeps live progress without hammering the status endpoint.
|
||||||
setInterval(refreshJobs, 15000);
|
setInterval(refreshJobs, 15000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+110
-110
@@ -1,110 +1,110 @@
|
|||||||
{% extends "base.html" %} {% block title %}Whitelist — Intrusion Detection{%
|
{% extends "base.html" %} {% block title %}Whitelist — Intrusion Detection{%
|
||||||
endblock %} {% block content %}
|
endblock %} {% block content %}
|
||||||
<h1>Whitelist</h1>
|
<h1>Whitelist</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Provider whitelists (from the router snapshot). Never add a whole cloud
|
Provider whitelists (from the router snapshot). Never add a whole cloud
|
||||||
provider to the blocklist.
|
provider to the blocklist.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head"><h2>Add / remove CIDR</h2></div>
|
<div class="panel-head"><h2>Add / remove CIDR</h2></div>
|
||||||
<div class="panel-actions" style="margin-top: 0">
|
<div class="panel-actions" style="margin-top: 0">
|
||||||
<select id="wl-name">
|
<select id="wl-name">
|
||||||
{% for name in whitelist_names %}
|
{% for name in whitelist_names %}
|
||||||
<option value="{{ name }}">{{ name }}</option>
|
<option value="{{ name }}">{{ name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
id="wl-address"
|
id="wl-address"
|
||||||
placeholder="CIDR, e.g. 1.2.3.0/24"
|
placeholder="CIDR, e.g. 1.2.3.0/24"
|
||||||
style="min-width: 220px"
|
style="min-width: 220px"
|
||||||
/>
|
/>
|
||||||
<button class="btn primary" id="wl-add-btn">Add</button>
|
<button class="btn primary" id="wl-add-btn">Add</button>
|
||||||
<button class="btn danger" id="wl-remove-btn">Remove</button>
|
<button class="btn danger" id="wl-remove-btn">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="whitelists"></div>
|
<div id="whitelists"></div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
async function loadWhitelists() {
|
async function loadWhitelists() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/whitelist");
|
const data = await api("/api/whitelist");
|
||||||
const box = document.getElementById("whitelists");
|
const box = document.getElementById("whitelists");
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
for (const name of data.names || []) {
|
for (const name of data.names || []) {
|
||||||
const w = data.whitelists[name] || {};
|
const w = data.whitelists[name] || {};
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const version of ["ipv4", "ipv6"]) {
|
for (const version of ["ipv4", "ipv6"]) {
|
||||||
const entries = (w[version] || []).sort();
|
const entries = (w[version] || []).sort();
|
||||||
rows.push(
|
rows.push(
|
||||||
el(
|
el(
|
||||||
"tr",
|
"tr",
|
||||||
el("td", { text: version }),
|
el("td", { text: version }),
|
||||||
el("td", { class: "mono", text: entries.join(", ") || "—" }),
|
el("td", { class: "mono", text: entries.join(", ") || "—" }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
box.append(
|
box.append(
|
||||||
el(
|
el(
|
||||||
"div",
|
"div",
|
||||||
{ class: "panel" },
|
{ class: "panel" },
|
||||||
el(
|
el(
|
||||||
"div",
|
"div",
|
||||||
{ class: "panel-head" },
|
{ class: "panel-head" },
|
||||||
el("h2", {
|
el("h2", {
|
||||||
text: `${name} (${(w.ipv4 || []).length + (w.ipv6 || []).length})`,
|
text: `${name} (${(w.ipv4 || []).length + (w.ipv6 || []).length})`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
el(
|
el(
|
||||||
"table",
|
"table",
|
||||||
{ class: "table" },
|
{ class: "table" },
|
||||||
el(
|
el(
|
||||||
"thead",
|
"thead",
|
||||||
{},
|
{},
|
||||||
el(
|
el(
|
||||||
"tr",
|
"tr",
|
||||||
{},
|
{},
|
||||||
el("th", { text: "Version" }),
|
el("th", { text: "Version" }),
|
||||||
el("th", { text: "Addresses" }),
|
el("th", { text: "Addresses" }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
el("tbody", {}, ...rows),
|
el("tbody", {}, ...rows),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function act(action) {
|
async function act(action) {
|
||||||
const name = document.getElementById("wl-name").value;
|
const name = document.getElementById("wl-name").value;
|
||||||
const address = document.getElementById("wl-address").value.trim();
|
const address = document.getElementById("wl-address").value.trim();
|
||||||
if (!address) {
|
if (!address) {
|
||||||
toast("Enter a CIDR", "warn");
|
toast("Enter a CIDR", "warn");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api("/api/whitelist/update", {
|
await api("/api/whitelist/update", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { name, address, action },
|
body: { name, address, action },
|
||||||
});
|
});
|
||||||
toast(`${action} ${address} on ${name}`, "ok");
|
toast(`${action} ${address} on ${name}`, "ok");
|
||||||
document.getElementById("wl-address").value = "";
|
document.getElementById("wl-address").value = "";
|
||||||
loadWhitelists();
|
loadWhitelists();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
toast(e.message, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("wl-add-btn")
|
.getElementById("wl-add-btn")
|
||||||
.addEventListener("click", () => act("add"));
|
.addEventListener("click", () => act("add"));
|
||||||
document
|
document
|
||||||
.getElementById("wl-remove-btn")
|
.getElementById("wl-remove-btn")
|
||||||
.addEventListener("click", () => act("remove"));
|
.addEventListener("click", () => act("remove"));
|
||||||
loadWhitelists();
|
loadWhitelists();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Intrusion Detection System — shared utilities
|
||||||
|
#
|
||||||
|
# Modules:
|
||||||
|
# constants.py central configuration, paths, env vars
|
||||||
|
# files.py filesystem & I/O helpers
|
||||||
|
# ip_utils.py IP address parsing, validation, whitelist lookup
|
||||||
|
# models.py data models (Request, IPStats) + aggregate()
|
||||||
|
# reporting.py text rendering, table formatting
|
||||||
|
# utils.py shared CLI arg parser
|
||||||
|
# whitelists.py whitelist CIDR download, caching, validation
|
||||||
|
# whois.py WHOIS subnet queries, IP consolidation
|
||||||
|
# blocklist_history.py git-backed cumulative blocklist reconstruction
|
||||||
|
# retention.py report file retention & pruning
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Git-backed cumulative blocklist history.
|
||||||
|
|
||||||
|
The consolidated blocklist lives in git-tracked files
|
||||||
|
(``reports/app/data/blocklist_ipv4.txt`` / ``blocklist_ipv6.txt``). To make the
|
||||||
|
MikroTik blocklist grow-only, the "complete set of all blocked IPs" is
|
||||||
|
reconstructed from **every git revision** of those files (the union of all
|
||||||
|
lines ever present), plus the current working files and the latest scan
|
||||||
|
targets.
|
||||||
|
|
||||||
|
This keeps IPs blocked even after they leave the NPM ~30-day log window, and
|
||||||
|
only ever removes router entries that were never blocked by the system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Iterable
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from .files import write_json
|
||||||
|
from .reporting import timestamp_name
|
||||||
|
|
||||||
|
#: A ``+ 1.2.3.4/32`` added line in a git patch; ``+++ b/...`` is the file
|
||||||
|
#: header and is excluded by the negative lookahead.
|
||||||
|
_ADDED_LINE_RE = re.compile(r"^\+(?!\+)(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(*args: str) -> subprocess.CompletedProcess[str] | None:
|
||||||
|
"""Run a git command, returning None on any failure (never raises)."""
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args], capture_output=True, text=True, check=False)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_line(line: str) -> str | None:
|
||||||
|
"""Normalize a blocklist line to a canonical CIDR string, or None."""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(ipaddress.ip_network(line, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def committed_lines(path: str) -> set[str]:
|
||||||
|
"""Return the union of all lines ever present in ``path`` across git.
|
||||||
|
|
||||||
|
Uses a single ``git log --all -p -- <path>`` and collects the ``+`` added
|
||||||
|
lines (excluding the ``+++`` file header). A line present in any revision
|
||||||
|
appears as an added line in some commit's diff, so the union equals "all
|
||||||
|
lines ever present".
|
||||||
|
|
||||||
|
Returns an empty set when git is unavailable, the path is untracked, or the
|
||||||
|
repository has no history — callers then fall back to the working file.
|
||||||
|
"""
|
||||||
|
proc = _run_git("log", "--all", "-p", "--", path)
|
||||||
|
if proc is None or proc.returncode != 0:
|
||||||
|
return set()
|
||||||
|
lines: set[str] = set()
|
||||||
|
for raw in proc.stdout.splitlines():
|
||||||
|
m = _ADDED_LINE_RE.match(raw)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
clean = _valid_line(m.group(1))
|
||||||
|
if clean is not None:
|
||||||
|
lines.add(clean)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def current_file_lines(path: str) -> set[str]:
|
||||||
|
"""Return the validated lines currently in ``path`` (missing file -> {})."""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return {clean for clean in (_valid_line(x) for x in f)
|
||||||
|
if clean is not None}
|
||||||
|
except OSError:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def cumulative_blocklist(v4_path: str, v6_path: str,
|
||||||
|
desired_v4: Iterable[str],
|
||||||
|
desired_v6: Iterable[str],
|
||||||
|
) -> tuple[set[str], set[str]]:
|
||||||
|
"""Complete set of all blocked targets (history | working file | desired).
|
||||||
|
|
||||||
|
Returns ``(complete_v4, complete_v6)`` of canonical CIDR strings.
|
||||||
|
"""
|
||||||
|
complete_v4 = (
|
||||||
|
committed_lines(v4_path)
|
||||||
|
| current_file_lines(v4_path)
|
||||||
|
| {c for c in (_valid_line(x) for x in desired_v4) if c is not None})
|
||||||
|
complete_v6 = (
|
||||||
|
committed_lines(v6_path)
|
||||||
|
| current_file_lines(v6_path)
|
||||||
|
| {c for c in (_valid_line(x) for x in desired_v6) if c is not None})
|
||||||
|
return complete_v4, complete_v6
|
||||||
|
|
||||||
|
|
||||||
|
def compute_changes(current_v4: set[str], current_v6: set[str],
|
||||||
|
complete_v4: set[str], complete_v6: set[str],
|
||||||
|
whitelisted_v4: set[str], whitelisted_v6: set[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute the add/remove diff for a cumulative (grow-only) blocklist.
|
||||||
|
|
||||||
|
Entries are added when present in the complete set but not yet on the
|
||||||
|
router and not whitelisted. Entries are removed only when they sit on the
|
||||||
|
router but are absent from the complete set — i.e. they were never blocked
|
||||||
|
by the system (manual/rogue entries). Callers may attach extra keys (e.g.
|
||||||
|
``dry_run``).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"added_ipv4": sorted(complete_v4 - current_v4 - whitelisted_v4),
|
||||||
|
"added_ipv6": sorted(complete_v6 - current_v6 - whitelisted_v6),
|
||||||
|
"removed_ipv4": sorted(current_v4 - complete_v4),
|
||||||
|
"removed_ipv6": sorted(current_v6 - complete_v6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def blocklist_delta(history_v4: set[str], history_v6: set[str],
|
||||||
|
prev_v4: set[str], prev_v6: set[str],
|
||||||
|
complete_v4: set[str], complete_v6: set[str],
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Complete-set delta between the previous and current blocklist state.
|
||||||
|
|
||||||
|
``added`` are entries newly part of the blocklist (this run's targets not
|
||||||
|
blocked before); ``removed`` are entries no longer part of it — normally
|
||||||
|
empty in the cumulative model.
|
||||||
|
"""
|
||||||
|
before_v4 = history_v4 | prev_v4
|
||||||
|
before_v6 = history_v6 | prev_v6
|
||||||
|
return {
|
||||||
|
"added_ipv4": sorted(complete_v4 - before_v4),
|
||||||
|
"added_ipv6": sorted(complete_v6 - before_v6),
|
||||||
|
"removed_ipv4": sorted(before_v4 - complete_v4),
|
||||||
|
"removed_ipv6": sorted(before_v6 - complete_v6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_blocklist_report(report_dir: str,
|
||||||
|
*,
|
||||||
|
history_v4: set[str], history_v6: set[str],
|
||||||
|
prev_v4: set[str], prev_v6: set[str],
|
||||||
|
complete_v4: set[str], complete_v6: set[str],
|
||||||
|
firewall_changes: dict | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
errors: Iterable[str] = (),
|
||||||
|
scan_id: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Write the timestamped blocklist report to ``<report_dir>/app/``.
|
||||||
|
|
||||||
|
Records the complete-set delta (added/removed) plus the full cumulative
|
||||||
|
blocked list (git history | working files | scan targets), and — when
|
||||||
|
supplied — the router sync changes. Returns the written file path.
|
||||||
|
"""
|
||||||
|
delta = blocklist_delta(
|
||||||
|
history_v4, history_v6, prev_v4, prev_v6, complete_v4, complete_v6)
|
||||||
|
payload = {
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(
|
||||||
|
timespec="seconds"),
|
||||||
|
"report_dir": report_dir,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"summary": {
|
||||||
|
"added": len(delta["added_ipv4"]) + len(delta["added_ipv6"]),
|
||||||
|
"removed": len(delta["removed_ipv4"]) + len(delta["removed_ipv6"]),
|
||||||
|
"total_blocked": len(complete_v4) + len(complete_v6),
|
||||||
|
},
|
||||||
|
"added_ipv4": delta["added_ipv4"],
|
||||||
|
"added_ipv6": delta["added_ipv6"],
|
||||||
|
"removed_ipv4": delta["removed_ipv4"],
|
||||||
|
"removed_ipv6": delta["removed_ipv6"],
|
||||||
|
"blocked_ipv4": sorted(complete_v4),
|
||||||
|
"blocked_ipv6": sorted(complete_v6),
|
||||||
|
"firewall_changes": firewall_changes,
|
||||||
|
"errors": list(errors),
|
||||||
|
}
|
||||||
|
app_dir = os.path.join(report_dir, "app")
|
||||||
|
os.makedirs(app_dir, exist_ok=True)
|
||||||
|
path = os.path.join(app_dir, f"{timestamp_name()}.json")
|
||||||
|
write_json(path, payload)
|
||||||
|
try:
|
||||||
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
|
with open(path, "rb") as file:
|
||||||
|
content = file.read()
|
||||||
|
repo.insert_report_artifact(
|
||||||
|
get_connection(), source="app", report_type="blocklist_change",
|
||||||
|
generated_at=payload["generated_at"], payload=payload,
|
||||||
|
original_path=f"app/{os.path.basename(path)}",
|
||||||
|
content_sha256=hashlib.sha256(content).hexdigest(),
|
||||||
|
scan_id=scan_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"WARN: blocklist report DB write skipped ({exc})")
|
||||||
|
return path
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -62,12 +67,20 @@ SCAN_STATE_PATH = os.path.join(DATA_DIR, "scan_state.json")
|
|||||||
SCAN_JOBS_PATH = os.path.join(DATA_DIR, "scan_jobs.json")
|
SCAN_JOBS_PATH = os.path.join(DATA_DIR, "scan_jobs.json")
|
||||||
SCAN_JOBS_MAX = 50
|
SCAN_JOBS_MAX = 50
|
||||||
|
|
||||||
|
# Report retention: per-directory cleanup of generated files under /reports/.
|
||||||
|
# Each directory keeps at most REPORT_RETENTION_MAX_FILES files, all newer
|
||||||
|
# than REPORT_RETENTION_DAYS days (see app/retention.py).
|
||||||
|
REPORT_RETENTION_DAYS = int(os.environ.get("REPORT_RETENTION_DAYS", "30"))
|
||||||
|
REPORT_RETENTION_MAX_FILES = int(
|
||||||
|
os.environ.get("REPORT_RETENTION_MAX_FILES", "10"))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Log sources (configurable via environment)
|
# Log sources (configurable via environment)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
COPYPARTY_LOG_PATH = os.environ.get("COPYPARTY_LOG_PATH", r"P:\copyparty")
|
COPYPARTY_LOG_PATH = os.environ.get(
|
||||||
NPM_LOG_PATH = os.environ.get("NPM_LOG_PATH", r"Y:\npm\logs")
|
"COPYPARTY_LOG_PATH", "/srv/projects/copyparty")
|
||||||
|
NPM_LOG_PATH = os.environ.get("NPM_LOG_PATH", "/mnt/backups/npm/logs")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MikroTik router connection (configurable via environment)
|
# MikroTik router connection (configurable via environment)
|
||||||
@@ -84,7 +97,7 @@ BLOCKLIST_NAME = os.environ.get("BLOCKLIST_NAME", "Blocked")
|
|||||||
WHITELIST_NAMES = [
|
WHITELIST_NAMES = [
|
||||||
name.strip()
|
name.strip()
|
||||||
for name in os.environ.get(
|
for name in os.environ.get(
|
||||||
"WHITELIST_NAMES", "Cloudflare,Openfactory").split(",")
|
"WHITELIST_NAMES", "Cloudflare,Init7").split(",")
|
||||||
if name.strip()
|
if name.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -108,7 +121,7 @@ DEFAULT_MIN_DENIED = int(os.environ.get(
|
|||||||
"DEFAULT_MIN_DENIED", "1")) # copyparty
|
"DEFAULT_MIN_DENIED", "1")) # copyparty
|
||||||
DEFAULT_MIN_ERRORS = int(os.environ.get("DEFAULT_MIN_ERRORS", "5")) # npm
|
DEFAULT_MIN_ERRORS = int(os.environ.get("DEFAULT_MIN_ERRORS", "5")) # npm
|
||||||
DEFAULT_ERROR_RATIO = float(os.environ.get(
|
DEFAULT_ERROR_RATIO = float(os.environ.get(
|
||||||
"DEFAULT_ERROR_RATIO", "0.5")) # npm
|
"DEFAULT_ERROR_RATIO", "0.4")) # npm
|
||||||
DEFAULT_TOP_PATHS = int(os.environ.get("DEFAULT_TOP_PATHS", "20"))
|
DEFAULT_TOP_PATHS = int(os.environ.get("DEFAULT_TOP_PATHS", "20"))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -124,8 +137,6 @@ WEB_PORT = int(os.environ.get("WEB_PORT", "54666"))
|
|||||||
# Whitelist sources (configurable via environment)
|
# Whitelist sources (configurable via environment)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
OPENFACTORY_URL = os.environ.get(
|
|
||||||
"OPENFACTORY_URL", "https://openfactory.net/geofeed.csv")
|
|
||||||
CLOUDFLARE_URL_V4 = os.environ.get(
|
CLOUDFLARE_URL_V4 = os.environ.get(
|
||||||
"CLOUDFLARE_URL_V4", "https://www.cloudflare.com/ips-v4")
|
"CLOUDFLARE_URL_V4", "https://www.cloudflare.com/ips-v4")
|
||||||
CLOUDFLARE_URL_V6 = os.environ.get(
|
CLOUDFLARE_URL_V6 = os.environ.get(
|
||||||
@@ -136,7 +147,6 @@ PALOALTO_URL = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Local whitelist files (checked before remote download)
|
# Local whitelist files (checked before remote download)
|
||||||
OPENFACTORY_FILE = os.path.join(DATA_DIR, "openfactory", "geofeed.csv")
|
|
||||||
CLOUDFLARE_V4_FILE = os.path.join(DATA_DIR, "cloudflare", "ips-v4.txt")
|
CLOUDFLARE_V4_FILE = os.path.join(DATA_DIR, "cloudflare", "ips-v4.txt")
|
||||||
CLOUDFLARE_V6_FILE = os.path.join(DATA_DIR, "cloudflare", "ips-v6.txt")
|
CLOUDFLARE_V6_FILE = os.path.join(DATA_DIR, "cloudflare", "ips-v6.txt")
|
||||||
PALOALTO_FILE = os.path.join(DATA_DIR, "paloalto", "scanner-ips.txt")
|
PALOALTO_FILE = os.path.join(DATA_DIR, "paloalto", "scanner-ips.txt")
|
||||||
+104
-104
@@ -1,104 +1,104 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Filesystem and I/O helpers for the intrusion-detection system."""
|
"""Filesystem and I/O helpers for the intrusion-detection system."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import gzip
|
import gzip
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable, Iterator, Sequence
|
from typing import Any, Iterable, Iterator, Sequence
|
||||||
|
|
||||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
# -- directory --------------------------------------------------------------
|
# -- directory --------------------------------------------------------------
|
||||||
|
|
||||||
def validate_dir(path: str, need_write: bool = False) -> str:
|
def validate_dir(path: str, need_write: bool = False) -> str:
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
raise FileNotFoundError(f"path does not exist: {path}")
|
raise FileNotFoundError(f"path does not exist: {path}")
|
||||||
if not os.path.isdir(path):
|
if not os.path.isdir(path):
|
||||||
raise NotADirectoryError(f"path is not a directory: {path}")
|
raise NotADirectoryError(f"path is not a directory: {path}")
|
||||||
if not os.access(path, os.R_OK):
|
if not os.access(path, os.R_OK):
|
||||||
raise PermissionError(f"path not readable: {path}")
|
raise PermissionError(f"path not readable: {path}")
|
||||||
if need_write and not os.access(path, os.W_OK):
|
if need_write and not os.access(path, os.W_OK):
|
||||||
raise PermissionError(f"path not writable: {path}")
|
raise PermissionError(f"path not writable: {path}")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def ensure_dirs(*paths: str) -> None:
|
def ensure_dirs(*paths: str) -> None:
|
||||||
for p in paths:
|
for p in paths:
|
||||||
os.makedirs(p, exist_ok=True)
|
os.makedirs(p, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# -- log discovery ----------------------------------------------------------
|
# -- log discovery ----------------------------------------------------------
|
||||||
|
|
||||||
def find_logs(path: str, pattern: re.Pattern | None = None,
|
def find_logs(path: str, pattern: re.Pattern | None = None,
|
||||||
suffixes: Sequence[str] = (),
|
suffixes: Sequence[str] = (),
|
||||||
exclude_prefixes: Sequence[str] = ()) -> list[str]:
|
exclude_prefixes: Sequence[str] = ()) -> list[str]:
|
||||||
"""List matching log file names (not full paths), sorted."""
|
"""List matching log file names (not full paths), sorted."""
|
||||||
out = []
|
out = []
|
||||||
for name in os.listdir(path):
|
for name in os.listdir(path):
|
||||||
if not os.path.isfile(os.path.join(path, name)):
|
if not os.path.isfile(os.path.join(path, name)):
|
||||||
continue
|
continue
|
||||||
if any(name.startswith(p) for p in exclude_prefixes):
|
if any(name.startswith(p) for p in exclude_prefixes):
|
||||||
continue
|
continue
|
||||||
if pattern is not None and not pattern.match(name):
|
if pattern is not None and not pattern.match(name):
|
||||||
continue
|
continue
|
||||||
if suffixes and not name.endswith(tuple(suffixes)):
|
if suffixes and not name.endswith(tuple(suffixes)):
|
||||||
continue
|
continue
|
||||||
out.append(name)
|
out.append(name)
|
||||||
return sorted(out)
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
def read_lines(path: str) -> Iterator[str]:
|
def read_lines(path: str) -> Iterator[str]:
|
||||||
"""Yield ANSI-stripped, newline-stripped lines. Transparently gunzips."""
|
"""Yield ANSI-stripped, newline-stripped lines. Transparently gunzips."""
|
||||||
opener = gzip.open if path.endswith(".gz") else open
|
opener = gzip.open if path.endswith(".gz") else open
|
||||||
with opener(path, "rt", encoding="utf-8", errors="replace") as f:
|
with opener(path, "rt", encoding="utf-8", errors="replace") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
yield ANSI_RE.sub("", line.rstrip("\r\n"))
|
yield ANSI_RE.sub("", line.rstrip("\r\n"))
|
||||||
|
|
||||||
|
|
||||||
def read_lines_from(path: str, start: int = 0) -> Iterator[tuple[str, int]]:
|
def read_lines_from(path: str, start: int = 0) -> Iterator[tuple[str, int]]:
|
||||||
"""Yield ``(line, resume_offset)`` for a plain-text log from byte ``start``.
|
"""Yield ``(line, resume_offset)`` for a plain-text log from byte ``start``.
|
||||||
|
|
||||||
Only seekable (non-gzip) files can be resumed. ``resume_offset`` points
|
Only seekable (non-gzip) files can be resumed. ``resume_offset`` points
|
||||||
after the last complete line; a trailing partial line (no newline yet) is
|
after the last complete line; a trailing partial line (no newline yet) is
|
||||||
yielded but resets the resume offset to its start so it is re-read once
|
yielded but resets the resume offset to its start so it is re-read once
|
||||||
completed. The last yielded ``resume_offset`` is the value to store.
|
completed. The last yielded ``resume_offset`` is the value to store.
|
||||||
"""
|
"""
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
f.seek(start)
|
f.seek(start)
|
||||||
pos = start
|
pos = start
|
||||||
for raw in f:
|
for raw in f:
|
||||||
line_start = pos
|
line_start = pos
|
||||||
pos += len(raw)
|
pos += len(raw)
|
||||||
complete = raw.endswith(b"\n") or raw.endswith(b"\r")
|
complete = raw.endswith(b"\n") or raw.endswith(b"\r")
|
||||||
text = ANSI_RE.sub(
|
text = ANSI_RE.sub(
|
||||||
"", raw.decode("utf-8", errors="replace").rstrip("\r\n"))
|
"", raw.decode("utf-8", errors="replace").rstrip("\r\n"))
|
||||||
yield text, (pos if complete else line_start)
|
yield text, (pos if complete else line_start)
|
||||||
|
|
||||||
|
|
||||||
# -- writers ----------------------------------------------------------------
|
# -- writers ----------------------------------------------------------------
|
||||||
|
|
||||||
def write_csv(path: str, header: Sequence[str],
|
def write_csv(path: str, header: Sequence[str],
|
||||||
rows: Iterable[Sequence[Any]]) -> str:
|
rows: Iterable[Sequence[Any]]) -> str:
|
||||||
with open(path, "w", newline="", encoding="utf-8") as f:
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
||||||
w = csv.writer(f)
|
w = csv.writer(f)
|
||||||
w.writerow(header)
|
w.writerow(header)
|
||||||
w.writerows(rows)
|
w.writerows(rows)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def write_json(path: str, obj: Any) -> str:
|
def write_json(path: str, obj: Any) -> str:
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(obj, f, indent=2, default=str)
|
json.dump(obj, f, indent=2, default=str)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def write_lines(path: str, lines: Iterable[str]) -> str:
|
def write_lines(path: str, lines: Iterable[str]) -> str:
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
for line in lines:
|
for line in lines:
|
||||||
f.write(line + "\n")
|
f.write(line + "\n")
|
||||||
return path
|
return path
|
||||||
@@ -1,63 +1,63 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""IP helper functions for the intrusion-detection system."""
|
"""IP helper functions for the intrusion-detection system."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from .whitelists import WHITELIST_NETS as _WHITELIST_NETS
|
from .whitelists import WHITELIST_NETS as _WHITELIST_NETS
|
||||||
|
|
||||||
|
|
||||||
def parse_ip(value: str):
|
def parse_ip(value: str):
|
||||||
"""Return an ip_address object, or None if `value` is not an IP."""
|
"""Return an ip_address object, or None if `value` is not an IP."""
|
||||||
try:
|
try:
|
||||||
return ipaddress.ip_address(value)
|
return ipaddress.ip_address(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ip_version(value: str) -> int | None:
|
def ip_version(value: str) -> int | None:
|
||||||
addr = parse_ip(value)
|
addr = parse_ip(value)
|
||||||
return addr.version if addr else None
|
return addr.version if addr else None
|
||||||
|
|
||||||
|
|
||||||
def is_ipv6(value: str) -> bool:
|
def is_ipv6(value: str) -> bool:
|
||||||
return ip_version(value) == 6
|
return ip_version(value) == 6
|
||||||
|
|
||||||
|
|
||||||
def is_ipv4(value: str) -> bool:
|
def is_ipv4(value: str) -> bool:
|
||||||
return ip_version(value) == 4
|
return ip_version(value) == 4
|
||||||
|
|
||||||
|
|
||||||
def whitelist_of(value: str) -> str | None:
|
def whitelist_of(value: str) -> str | None:
|
||||||
"""Return the whitelist name an IP belongs to, else None."""
|
"""Return the whitelist name an IP belongs to, else None."""
|
||||||
addr = parse_ip(value)
|
addr = parse_ip(value)
|
||||||
if addr is None:
|
if addr is None:
|
||||||
return None
|
return None
|
||||||
for name, nets in _WHITELIST_NETS.items():
|
for name, nets in _WHITELIST_NETS.items():
|
||||||
for net in nets:
|
for net in nets:
|
||||||
if addr.version == net.version and addr in net:
|
if addr.version == net.version and addr in net:
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_whitelisted(value: str) -> bool:
|
def is_whitelisted(value: str) -> bool:
|
||||||
return whitelist_of(value) is not None
|
return whitelist_of(value) is not None
|
||||||
|
|
||||||
|
|
||||||
def sort_ips(ips: Iterable[str]) -> list[str]:
|
def sort_ips(ips: Iterable[str]) -> list[str]:
|
||||||
"""Numeric sort, IPv4 before IPv6, unparseable last."""
|
"""Numeric sort, IPv4 before IPv6, unparseable last."""
|
||||||
def key(ip: str):
|
def key(ip: str):
|
||||||
addr = parse_ip(ip)
|
addr = parse_ip(ip)
|
||||||
if addr is None:
|
if addr is None:
|
||||||
return (9, b"", ip)
|
return (9, b"", ip)
|
||||||
return (addr.version, addr.packed, "")
|
return (addr.version, addr.packed, "")
|
||||||
return sorted(ips, key=key)
|
return sorted(ips, key=key)
|
||||||
|
|
||||||
|
|
||||||
def split_versions(ips: Iterable[str]) -> tuple[list[str], list[str]]:
|
def split_versions(ips: Iterable[str]) -> tuple[list[str], list[str]]:
|
||||||
"""Split an IP iterable into (sorted IPv4, sorted IPv6)."""
|
"""Split an IP iterable into (sorted IPv4, sorted IPv6)."""
|
||||||
ips = list(ips)
|
ips = list(ips)
|
||||||
return (sort_ips(ip for ip in ips if is_ipv4(ip)),
|
return (sort_ips(ip for ip in ips if is_ipv4(ip)),
|
||||||
sort_ips(ip for ip in ips if is_ipv6(ip)))
|
sort_ips(ip for ip in ips if is_ipv6(ip)))
|
||||||
+150
-150
@@ -1,150 +1,150 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Data models for the intrusion-detection system.
|
"""Data models for the intrusion-detection system.
|
||||||
|
|
||||||
`Request` and `IPStats` plus the `aggregate()` fold function.
|
`Request` and `IPStats` plus the `aggregate()` fold function.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from . import bad_patterns
|
from .. import bad_patterns
|
||||||
from .ip_utils import ip_version, whitelist_of
|
from .ip_utils import ip_version, whitelist_of
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Request:
|
class Request:
|
||||||
"""One normalised request line, produced by any log parser."""
|
"""One normalised request line, produced by any log parser."""
|
||||||
|
|
||||||
ts: str = ""
|
ts: str = ""
|
||||||
ip: str = ""
|
ip: str = ""
|
||||||
method: str | None = None
|
method: str | None = None
|
||||||
path: str | None = None
|
path: str | None = None
|
||||||
status: int | None = None
|
status: int | None = None
|
||||||
upstream_status: int | None = None
|
upstream_status: int | None = None
|
||||||
host: str | None = None
|
host: str | None = None
|
||||||
user: str | None = None
|
user: str | None = None
|
||||||
ua: str | None = None
|
ua: str | None = None
|
||||||
referer: str | None = None
|
referer: str | None = None
|
||||||
denied: bool = False
|
denied: bool = False
|
||||||
banned: bool = False
|
banned: bool = False
|
||||||
extra: dict = field(default_factory=dict)
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_client_error(self) -> bool:
|
def is_client_error(self) -> bool:
|
||||||
return self.status is not None and 400 <= self.status < 500
|
return self.status is not None and 400 <= self.status < 500
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_server_error(self) -> bool:
|
def is_server_error(self) -> bool:
|
||||||
return self.status is not None and 500 <= self.status < 600
|
return self.status is not None and 500 <= self.status < 600
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_error(self) -> bool:
|
def is_error(self) -> bool:
|
||||||
return self.denied or self.is_client_error or self.is_server_error
|
return self.denied or self.is_client_error or self.is_server_error
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class IPStats:
|
class IPStats:
|
||||||
"""Aggregated per-IP counters."""
|
"""Aggregated per-IP counters."""
|
||||||
|
|
||||||
ip: str
|
ip: str
|
||||||
version: int = 0
|
version: int = 0
|
||||||
total: int = 0
|
total: int = 0
|
||||||
errors: int = 0
|
errors: int = 0
|
||||||
c4xx: int = 0
|
c4xx: int = 0
|
||||||
c5xx: int = 0
|
c5xx: int = 0
|
||||||
denied: int = 0
|
denied: int = 0
|
||||||
banned: int = 0
|
banned: int = 0
|
||||||
first: str = ""
|
first: str = ""
|
||||||
last: str = ""
|
last: str = ""
|
||||||
paths: Counter = field(default_factory=Counter)
|
paths: Counter = field(default_factory=Counter)
|
||||||
methods: Counter = field(default_factory=Counter)
|
methods: Counter = field(default_factory=Counter)
|
||||||
hosts: Counter = field(default_factory=Counter)
|
hosts: Counter = field(default_factory=Counter)
|
||||||
uas: Counter = field(default_factory=Counter)
|
uas: Counter = field(default_factory=Counter)
|
||||||
users: set[str] = field(default_factory=set)
|
users: set[str] = field(default_factory=set)
|
||||||
malicious_hits: int = 0
|
malicious_hits: int = 0
|
||||||
malicious_patterns: dict[str, int] = field(default_factory=dict)
|
malicious_patterns: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error_ratio(self) -> float:
|
def error_ratio(self) -> float:
|
||||||
return round(self.errors / self.total, 3) if self.total else 0.0
|
return round(self.errors / self.total, 3) if self.total else 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def authenticated(self) -> bool:
|
def authenticated(self) -> bool:
|
||||||
"""True if the IP ever presented a real (non-anonymous) user."""
|
"""True if the IP ever presented a real (non-anonymous) user."""
|
||||||
return any(u and u != "*" for u in self.users)
|
return any(u and u != "*" for u in self.users)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def whitelist(self) -> str:
|
def whitelist(self) -> str:
|
||||||
return whitelist_of(self.ip) or ""
|
return whitelist_of(self.ip) or ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def users_str(self) -> str:
|
def users_str(self) -> str:
|
||||||
return ",".join(sorted(self.users)) or "-"
|
return ",".join(sorted(self.users)) or "-"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def methods_str(self) -> str:
|
def methods_str(self) -> str:
|
||||||
return ",".join(sorted(self.methods)) or "-"
|
return ",".join(sorted(self.methods)) or "-"
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"ip": self.ip,
|
"ip": self.ip,
|
||||||
"version": self.version,
|
"version": self.version,
|
||||||
"total": self.total,
|
"total": self.total,
|
||||||
"errors": self.errors,
|
"errors": self.errors,
|
||||||
"error_ratio": self.error_ratio,
|
"error_ratio": self.error_ratio,
|
||||||
"status_4xx": self.c4xx,
|
"status_4xx": self.c4xx,
|
||||||
"status_5xx": self.c5xx,
|
"status_5xx": self.c5xx,
|
||||||
"denied": self.denied,
|
"denied": self.denied,
|
||||||
"banned": self.banned,
|
"banned": self.banned,
|
||||||
"first": self.first,
|
"first": self.first,
|
||||||
"last": self.last,
|
"last": self.last,
|
||||||
"whitelist": self.whitelist or None,
|
"whitelist": self.whitelist or None,
|
||||||
"users": sorted(self.users),
|
"users": sorted(self.users),
|
||||||
"methods": dict(self.methods),
|
"methods": dict(self.methods),
|
||||||
"hosts": dict(self.hosts),
|
"hosts": dict(self.hosts),
|
||||||
"paths": dict(self.paths.most_common()),
|
"paths": dict(self.paths.most_common()),
|
||||||
"uas": dict(self.uas.most_common(10)),
|
"uas": dict(self.uas.most_common(10)),
|
||||||
"malicious_hits": self.malicious_hits,
|
"malicious_hits": self.malicious_hits,
|
||||||
"malicious_patterns": dict(self.malicious_patterns),
|
"malicious_patterns": dict(self.malicious_patterns),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def aggregate(requests: Iterable[Request]) -> dict[str, IPStats]:
|
def aggregate(requests: Iterable[Request]) -> dict[str, IPStats]:
|
||||||
"""Fold request records into per-IP statistics."""
|
"""Fold request records into per-IP statistics."""
|
||||||
stats: dict[str, IPStats] = {}
|
stats: dict[str, IPStats] = {}
|
||||||
for r in requests:
|
for r in requests:
|
||||||
s = stats.get(r.ip)
|
s = stats.get(r.ip)
|
||||||
if s is None:
|
if s is None:
|
||||||
s = stats[r.ip] = IPStats(
|
s = stats[r.ip] = IPStats(
|
||||||
ip=r.ip, version=ip_version(r.ip) or 0, first=r.ts)
|
ip=r.ip, version=ip_version(r.ip) or 0, first=r.ts)
|
||||||
s.total += 1
|
s.total += 1
|
||||||
s.last = r.ts
|
s.last = r.ts
|
||||||
if r.denied:
|
if r.denied:
|
||||||
s.denied += 1
|
s.denied += 1
|
||||||
if r.banned:
|
if r.banned:
|
||||||
s.banned += 1
|
s.banned += 1
|
||||||
if r.is_client_error:
|
if r.is_client_error:
|
||||||
s.c4xx += 1
|
s.c4xx += 1
|
||||||
if r.is_server_error:
|
if r.is_server_error:
|
||||||
s.c5xx += 1
|
s.c5xx += 1
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
s.errors += 1
|
s.errors += 1
|
||||||
if r.path:
|
if r.path:
|
||||||
s.paths[r.path] += 1
|
s.paths[r.path] += 1
|
||||||
if r.method:
|
if r.method:
|
||||||
s.methods[r.method] += 1
|
s.methods[r.method] += 1
|
||||||
if r.host:
|
if r.host:
|
||||||
s.hosts[r.host] += 1
|
s.hosts[r.host] += 1
|
||||||
if r.ua and r.ua != "-":
|
if r.ua and r.ua != "-":
|
||||||
s.uas[r.ua] += 1
|
s.uas[r.ua] += 1
|
||||||
if r.user:
|
if r.user:
|
||||||
s.users.add(r.user)
|
s.users.add(r.user)
|
||||||
if r.path and bad_patterns.is_malicious(r.path):
|
if r.path and bad_patterns.is_malicious(r.path):
|
||||||
s.malicious_hits += 1
|
s.malicious_hits += 1
|
||||||
for p in bad_patterns.classify_path(r.path):
|
for p in bad_patterns.classify_path(r.path):
|
||||||
s.malicious_patterns[p.name] = s.malicious_patterns.get(
|
s.malicious_patterns[p.name] = s.malicious_patterns.get(
|
||||||
p.name, 0) + 1
|
p.name, 0) + 1
|
||||||
return stats
|
return stats
|
||||||
@@ -1,55 +1,55 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Text rendering helpers for the intrusion-detection system."""
|
"""Text rendering helpers for the intrusion-detection system."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Callable, Sequence
|
from typing import Any, Callable, Sequence
|
||||||
|
|
||||||
#: (header, getter) pair used for CSV + console tables
|
#: (header, getter) pair used for CSV + console tables
|
||||||
Column = tuple[str, Callable[[Any], Any]]
|
Column = tuple[str, Callable[[Any], Any]]
|
||||||
|
|
||||||
|
|
||||||
def columns_header(columns: Sequence[Column]) -> list[str]:
|
def columns_header(columns: Sequence[Column]) -> list[str]:
|
||||||
return [c[0] for c in columns]
|
return [c[0] for c in columns]
|
||||||
|
|
||||||
|
|
||||||
def columns_row(columns: Sequence[Column], obj: Any) -> list[Any]:
|
def columns_row(columns: Sequence[Column], obj: Any) -> list[Any]:
|
||||||
return [fn(obj) for _, fn in columns]
|
return [fn(obj) for _, fn in columns]
|
||||||
|
|
||||||
|
|
||||||
def render_table(headers: Sequence[str], rows: Sequence[Sequence[Any]],
|
def render_table(headers: Sequence[str], rows: Sequence[Sequence[Any]],
|
||||||
max_width: int = 48) -> str:
|
max_width: int = 48) -> str:
|
||||||
"""Aligned fixed-width text table. Numeric columns right-aligned."""
|
"""Aligned fixed-width text table. Numeric columns right-aligned."""
|
||||||
def cell(v: Any) -> str:
|
def cell(v: Any) -> str:
|
||||||
s = "" if v is None else str(v)
|
s = "" if v is None else str(v)
|
||||||
return s if len(s) <= max_width else s[:max_width - 1] + "~"
|
return s if len(s) <= max_width else s[:max_width - 1] + "~"
|
||||||
|
|
||||||
body = [[cell(v) for v in row] for row in rows]
|
body = [[cell(v) for v in row] for row in rows]
|
||||||
aligns = []
|
aligns = []
|
||||||
for i in range(len(headers)):
|
for i in range(len(headers)):
|
||||||
numeric = bool(rows) and all(
|
numeric = bool(rows) and all(
|
||||||
isinstance(row[i], (int, float)) and not isinstance(row[i], bool)
|
isinstance(row[i], (int, float)) and not isinstance(row[i], bool)
|
||||||
for row in rows)
|
for row in rows)
|
||||||
aligns.append(">" if numeric else "<")
|
aligns.append(">" if numeric else "<")
|
||||||
widths = [len(h) for h in headers]
|
widths = [len(h) for h in headers]
|
||||||
for row in body:
|
for row in body:
|
||||||
for i, v in enumerate(row):
|
for i, v in enumerate(row):
|
||||||
widths[i] = max(widths[i], len(v))
|
widths[i] = max(widths[i], len(v))
|
||||||
|
|
||||||
def line(values: Sequence[str]) -> str:
|
def line(values: Sequence[str]) -> str:
|
||||||
return " ".join(
|
return " ".join(
|
||||||
f"{v:{a}{w}}" for v, a, w in zip(values, aligns, widths)).rstrip()
|
f"{v:{a}{w}}" for v, a, w in zip(values, aligns, widths)).rstrip()
|
||||||
|
|
||||||
out = [line(list(headers)), "-" * (sum(widths) + 2 * (len(widths) - 1))]
|
out = [line(list(headers)), "-" * (sum(widths) + 2 * (len(widths) - 1))]
|
||||||
out += [line(row) for row in body]
|
out += [line(row) for row in body]
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
def render_kv(rows: Sequence[tuple[str, Any]]) -> str:
|
def render_kv(rows: Sequence[tuple[str, Any]]) -> str:
|
||||||
width = max((len(k) for k, _ in rows), default=0) + 1
|
width = max((len(k) for k, _ in rows), default=0) + 1
|
||||||
return "\n".join(f"{k + ':':<{width + 1}} {v}" for k, v in rows)
|
return "\n".join(f"{k + ':':<{width + 1}} {v}" for k, v in rows)
|
||||||
|
|
||||||
|
|
||||||
def timestamp_name() -> str:
|
def timestamp_name() -> str:
|
||||||
return datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
return datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report retention for generated files under ``/reports/``.
|
||||||
|
|
||||||
|
Every subdirectory keeps at most ``REPORT_RETENTION_MAX_FILES`` files, all
|
||||||
|
newer than ``REPORT_RETENTION_DAYS`` days. Older timestamped reports (e.g.
|
||||||
|
``reports/copyparty/*.txt``) are pruned automatically after each consolidated
|
||||||
|
scan so the tree does not grow without bound.
|
||||||
|
|
||||||
|
The git-tracked blocklist source of truth
|
||||||
|
(``<report_dir>/app/data/blocklist_ipv4.txt`` / ``blocklist_ipv6.txt``) is
|
||||||
|
always protected and never pruned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
REPORT_RETENTION_DAYS,
|
||||||
|
REPORT_RETENTION_MAX_FILES,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: default protected files (never pruned): the cumulative blocklist
|
||||||
|
BLOCKLIST_FILES = ("blocklist_ipv4.txt", "blocklist_ipv6.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def _age_days(mtime: float) -> float:
|
||||||
|
return (time.time() - mtime) / 86400.0
|
||||||
|
|
||||||
|
|
||||||
|
def prune_report_tree(root: str,
|
||||||
|
*,
|
||||||
|
max_age_days: int = REPORT_RETENTION_DAYS,
|
||||||
|
max_files: int = REPORT_RETENTION_MAX_FILES,
|
||||||
|
dry_run: bool = False,
|
||||||
|
protected: Iterable[str] = ()) -> dict[str, list[str]]:
|
||||||
|
"""Prune a report tree so each directory keeps ≤ ``max_files`` recent files.
|
||||||
|
|
||||||
|
A file is kept only if it is both among the ``max_files`` most recently
|
||||||
|
modified files in its directory AND newer than ``max_age_days`` days.
|
||||||
|
Protected paths are never touched. Returns ``{dir: [deleted paths]}``.
|
||||||
|
"""
|
||||||
|
protected_abs = {os.path.abspath(p) for p in protected}
|
||||||
|
removed: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(root):
|
||||||
|
files: list[tuple[str, float]] = []
|
||||||
|
for name in filenames:
|
||||||
|
path = os.path.join(dirpath, name)
|
||||||
|
if os.path.abspath(path) in protected_abs:
|
||||||
|
continue # protected files are neither kept-counted nor pruned
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
files.append((path, mtime))
|
||||||
|
|
||||||
|
# Newest first; only these (within the age window) survive.
|
||||||
|
files.sort(key=lambda t: t[1], reverse=True)
|
||||||
|
newest = {path for path, _ in files[:max_files]}
|
||||||
|
cutoff_days = max_age_days
|
||||||
|
|
||||||
|
deleted: list[str] = []
|
||||||
|
for path, mtime in files:
|
||||||
|
if path in newest and _age_days(mtime) <= cutoff_days:
|
||||||
|
continue # keep: recent and within the file cap
|
||||||
|
deleted.append(path)
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
# e.g. file in use on Windows — skip and leave it.
|
||||||
|
continue
|
||||||
|
if deleted:
|
||||||
|
removed[dirpath] = deleted
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def prune_report_dirs(*roots: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
max_age_days: int = REPORT_RETENTION_DAYS,
|
||||||
|
max_files: int = REPORT_RETENTION_MAX_FILES,
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Prune each of the given report roots (deduplicated by real path).
|
||||||
|
|
||||||
|
The blocklist source-of-truth files are always protected.
|
||||||
|
Returns ``{dir: [deleted paths]}`` across all roots.
|
||||||
|
"""
|
||||||
|
protected = [
|
||||||
|
os.path.join(root, "app", "data", name)
|
||||||
|
for root in roots
|
||||||
|
for name in BLOCKLIST_FILES
|
||||||
|
]
|
||||||
|
seen: set[str] = set()
|
||||||
|
summary: dict[str, list[str]] = {}
|
||||||
|
for root in roots:
|
||||||
|
real = os.path.realpath(root)
|
||||||
|
if real in seen or not os.path.isdir(root):
|
||||||
|
continue
|
||||||
|
seen.add(real)
|
||||||
|
summary.update(prune_report_tree(
|
||||||
|
root, max_age_days=max_age_days, max_files=max_files,
|
||||||
|
dry_run=dry_run, protected=protected))
|
||||||
|
return summary
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Shared core for the local-dev intrusion-detection scripts.
|
"""Shared core for the local-dev intrusion-detection scripts.
|
||||||
|
|
||||||
`copyparty.py` and `npm.py` are thin adapters. They only provide:
|
`copyparty.py` and `npm.py` are thin adapters. They only provide:
|
||||||
|
|
||||||
* a log-file matcher (glob-ish pattern / suffixes)
|
* a log-file matcher (glob-ish pattern / suffixes)
|
||||||
* a line parser producing `Request` objects
|
* a line parser producing `Request` objects
|
||||||
* a flagging rule (which IPs look hostile)
|
* a flagging rule (which IPs look hostile)
|
||||||
* column definitions for their CSV output
|
* column definitions for their CSV output
|
||||||
|
|
||||||
Everything else -- IP aggregation, whitelisting, console + file reports,
|
Everything else -- IP aggregation, whitelisting, console + file reports,
|
||||||
MikroTik RouterOS export -- lives in this module.
|
MikroTik RouterOS export -- lives in this module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from .constants import DEFAULT_TOP
|
from .constants import DEFAULT_TOP
|
||||||
|
|
||||||
|
|
||||||
def arg_parser(description: str, default_log_path: str,
|
def arg_parser(description: str, default_log_path: str,
|
||||||
default_report_dir: str) -> argparse.ArgumentParser:
|
default_report_dir: str) -> argparse.ArgumentParser:
|
||||||
p = argparse.ArgumentParser(description=description)
|
p = argparse.ArgumentParser(description=description)
|
||||||
p.add_argument("--log-path", default=default_log_path,
|
p.add_argument("--log-path", default=default_log_path,
|
||||||
help=f"log directory (default: {default_log_path})")
|
help=f"log directory (default: {default_log_path})")
|
||||||
p.add_argument("--report-dir", default=default_report_dir,
|
p.add_argument("--report-dir", default=default_report_dir,
|
||||||
help=f"report output dir (default: {default_report_dir})")
|
help=f"report output dir (default: {default_report_dir})")
|
||||||
p.add_argument("--top", type=int, default=DEFAULT_TOP,
|
p.add_argument("--top", type=int, default=DEFAULT_TOP,
|
||||||
help=f"rows in the top-IP table (default: {DEFAULT_TOP})")
|
help=f"rows in the top-IP table (default: {DEFAULT_TOP})")
|
||||||
p.add_argument("--quiet", action="store_true",
|
p.add_argument("--quiet", action="store_true",
|
||||||
help="write files, print nothing but the paths")
|
help="write files, print nothing but the paths")
|
||||||
p.add_argument("--no-rsc", action="store_true",
|
p.add_argument("--no-rsc", action="store_true",
|
||||||
help="skip MikroTik .rsc generation")
|
help="skip MikroTik .rsc generation")
|
||||||
return p
|
return p
|
||||||
@@ -1,277 +1,294 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Whitelist management for the intrusion-detection system.
|
"""Whitelist management for the intrusion-detection system.
|
||||||
|
|
||||||
Owns provider CIDR constants, remote download/refresh logic, and
|
Owns provider CIDR constants, remote download/refresh logic, and
|
||||||
CIDR extraction/validation. Imported by `utils.py` which provides
|
CIDR extraction/validation. Imported by `utils.py` which provides
|
||||||
the convenience lookups `whitelist_of()` and `is_whitelisted()`.
|
the convenience lookups `whitelist_of()` and `is_whitelisted()`.
|
||||||
|
|
||||||
Whitelist loading order (per provider):
|
Whitelist loading order (per provider):
|
||||||
1. Local file in `data/<provider>/` (if present and readable)
|
1. Local file in `data/<provider>/` (if present and readable)
|
||||||
2. Remote download (if network available)
|
2. Remote download (if network available)
|
||||||
3. Built-in hardcoded fallback
|
3. Built-in hardcoded fallback
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
CLOUDFLARE_URL_V4,
|
CLOUDFLARE_URL_V4,
|
||||||
CLOUDFLARE_URL_V6,
|
CLOUDFLARE_URL_V6,
|
||||||
CLOUDFLARE_V4_FILE,
|
CLOUDFLARE_V4_FILE,
|
||||||
CLOUDFLARE_V6_FILE,
|
CLOUDFLARE_V6_FILE,
|
||||||
OPENFACTORY_FILE,
|
PALOALTO_FILE,
|
||||||
OPENFACTORY_URL,
|
PALOALTO_URL,
|
||||||
PALOALTO_FILE,
|
)
|
||||||
PALOALTO_URL,
|
|
||||||
)
|
# ---------------------------------------------------------------------------
|
||||||
|
# CIDR extraction regex
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CIDR extraction regex
|
|
||||||
# ---------------------------------------------------------------------------
|
CIDR_RE = re.compile(
|
||||||
|
r"(?<![0-9A-Fa-f:.])([0-9A-Fa-f:.]+/\d{1,3})(?![0-9A-Fa-f:.])")
|
||||||
CIDR_RE = re.compile(
|
|
||||||
r"(?<![0-9A-Fa-f:.])([0-9A-Fa-f:.]+/\d{1,3})(?![0-9A-Fa-f:.])")
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local file paths (checked first, before remote download)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Local file paths (checked first, before remote download)
|
|
||||||
# ---------------------------------------------------------------------------
|
# (paths live in app/constants.py as CLOUDFLARE_V4_FILE,
|
||||||
|
# CLOUDFLARE_V6_FILE, PALOALTO_FILE; URLs as CLOUDFLARE_URL_V4, ...)
|
||||||
# (paths live in app/constants.py as OPENFACTORY_FILE, CLOUDFLARE_V4_FILE,
|
|
||||||
# CLOUDFLARE_V6_FILE, PALOALTO_FILE; URLs as OPENFACTORY_URL, ...)
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in fallback CIDR lists
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Built-in fallback CIDR lists
|
|
||||||
# ---------------------------------------------------------------------------
|
#: Init7 (Switzerland) Ltd. ranges — AS13030, verified via RIPE RDAP 2026-09-08.
|
||||||
|
#: Includes the Hybrid7 WAN /24 (router WAN 77.74.80.159), init7 DNS
|
||||||
#: Openfactory AS58299 colocation IP range (https://openfactory.net/geofeed.csv)
|
#: infrastructure, and the PPPoE gateway. init7 publishes no geofeed, so
|
||||||
OPENFACTORY_IPV4 = ["5.226.144.0/21"]
|
#: these are maintained here rather than downloaded.
|
||||||
|
INIT7_IPV4 = [
|
||||||
#: https://www.cloudflare.com/ips-v4
|
"77.74.80.0/24", # Hybrid7 WAN /24
|
||||||
CLOUDFLARE_IPV4 = [
|
"77.109.128.0/18", # init7 DNS / infrastructure
|
||||||
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
|
"213.144.128.0/19", # init7 DNS / infrastructure
|
||||||
"103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
|
"5.180.132.0/22", # init7 PPPoE gateway
|
||||||
"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",
|
#: init7 IPv6 allocation (contains delegated /48 2001:1620:549f::/48)
|
||||||
]
|
INIT7_IPV6 = ["2001:1620::/30"]
|
||||||
|
|
||||||
#: https://www.cloudflare.com/ips-v6
|
#: https://www.cloudflare.com/ips-v4
|
||||||
CLOUDFLARE_IPV6 = [
|
CLOUDFLARE_IPV4 = [
|
||||||
"2400:cb00::/32", "2606:4700::/32", "2803:f800::/32",
|
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
|
||||||
"2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29",
|
"103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
|
||||||
"2c0f:f248::/32",
|
"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",
|
||||||
#: https://docs-cortex.paloaltonetworks.com/r/1/Cortex-Xpanse/Scanning-activity
|
]
|
||||||
PALOALTO_IPV4 = [
|
|
||||||
"35.203.210.0/23", "144.86.173.0/24", "147.185.132.0/23",
|
#: https://www.cloudflare.com/ips-v6
|
||||||
"162.216.149.0/24", "162.216.150.0/24", "172.105.147.0/24",
|
CLOUDFLARE_IPV6 = [
|
||||||
"198.235.24.0/24", "205.210.31.0/24", "216.25.88.0/21",
|
"2400:cb00::/32", "2606:4700::/32", "2803:f800::/32",
|
||||||
]
|
"2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29",
|
||||||
|
"2c0f:f248::/32",
|
||||||
PALOALTO_IPV6 = [
|
]
|
||||||
"2604:a940:300:5b6::/64",
|
|
||||||
"2604:a940:301:225::/64",
|
#: https://docs-cortex.paloaltonetworks.com/r/1/Cortex-Xpanse/Scanning-activity
|
||||||
"2604:a940:302:118::/64",
|
PALOALTO_IPV4 = [
|
||||||
]
|
"35.203.210.0/23", "144.86.173.0/24", "147.185.132.0/23",
|
||||||
|
"162.216.149.0/24", "162.216.150.0/24", "172.105.147.0/24",
|
||||||
# ---------------------------------------------------------------------------
|
"198.235.24.0/24", "205.210.31.0/24", "216.25.88.0/21",
|
||||||
# Active whitelist state (mutated by refresh)
|
]
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
PALOALTO_IPV6 = [
|
||||||
#: address-list name -> CIDR ranges (v4 + v6 mixed, split at render time)
|
"2604:a940:300:5b6::/64",
|
||||||
WHITELISTS: dict[str, list[str]] = {
|
"2604:a940:301:225::/64",
|
||||||
"Openfactory": OPENFACTORY_IPV4,
|
"2604:a940:302:118::/64",
|
||||||
"Cloudflare": CLOUDFLARE_IPV4 + CLOUDFLARE_IPV6,
|
]
|
||||||
"PaloAlto": PALOALTO_IPV4 + PALOALTO_IPV6,
|
|
||||||
}
|
# ---------------------------------------------------------------------------
|
||||||
|
# Active whitelist state (mutated by refresh)
|
||||||
#: pre-parsed networks, so matching does not re-parse CIDRs per IP
|
# ---------------------------------------------------------------------------
|
||||||
WHITELIST_NETS: dict[str, tuple[Any, ...]] = {
|
|
||||||
name: tuple(ipaddress.ip_network(c) for c in cidrs)
|
#: address-list name -> CIDR ranges (v4 + v6 mixed, split at render time)
|
||||||
for name, cidrs in WHITELISTS.items()
|
WHITELISTS: dict[str, list[str]] = {
|
||||||
}
|
"Init7": INIT7_IPV4 + INIT7_IPV6,
|
||||||
|
"Cloudflare": CLOUDFLARE_IPV4 + CLOUDFLARE_IPV6,
|
||||||
#: guard so refresh runs at most once per process
|
"PaloAlto": PALOALTO_IPV4 + PALOALTO_IPV6,
|
||||||
_refreshed = False
|
}
|
||||||
|
|
||||||
|
#: pre-parsed networks, so matching does not re-parse CIDRs per IP
|
||||||
# ---------------------------------------------------------------------------
|
WHITELIST_NETS: dict[str, tuple[Any, ...]] = {
|
||||||
# CIDR extraction / validation
|
name: tuple(ipaddress.ip_network(c) for c in cidrs)
|
||||||
# ---------------------------------------------------------------------------
|
for name, cidrs in WHITELISTS.items()
|
||||||
|
}
|
||||||
def extract_cidrs(text: str) -> list[str]:
|
|
||||||
"""Extract valid CIDR strings from plain text, CSV, or HTML."""
|
#: guard so refresh runs at most once per process
|
||||||
seen: set[str] = set()
|
_refreshed = False
|
||||||
cidrs: list[str] = []
|
|
||||||
for match in CIDR_RE.finditer(text):
|
|
||||||
candidate = match.group(1).strip()
|
# ---------------------------------------------------------------------------
|
||||||
try:
|
# CIDR extraction / validation
|
||||||
network = ipaddress.ip_network(candidate, strict=False)
|
# ---------------------------------------------------------------------------
|
||||||
except ValueError:
|
|
||||||
continue
|
def extract_cidrs(text: str) -> list[str]:
|
||||||
rendered = str(network)
|
"""Extract valid CIDR strings from plain text, CSV, or HTML."""
|
||||||
if rendered not in seen:
|
seen: set[str] = set()
|
||||||
seen.add(rendered)
|
cidrs: list[str] = []
|
||||||
cidrs.append(rendered)
|
for match in CIDR_RE.finditer(text):
|
||||||
return cidrs
|
candidate = match.group(1).strip()
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(candidate, strict=False)
|
||||||
# ---------------------------------------------------------------------------
|
except ValueError:
|
||||||
# Local file loading
|
continue
|
||||||
# ---------------------------------------------------------------------------
|
rendered = str(network)
|
||||||
|
if rendered not in seen:
|
||||||
def _read_local_file(path: str) -> list[str]:
|
seen.add(rendered)
|
||||||
"""Read a local whitelist file and extract valid CIDRs."""
|
cidrs.append(rendered)
|
||||||
try:
|
return cidrs
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
return extract_cidrs(f.read())
|
|
||||||
except Exception:
|
# ---------------------------------------------------------------------------
|
||||||
return []
|
# Local file loading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _load_local_whitelists() -> dict[str, list[str]]:
|
def _read_local_file(path: str) -> list[str]:
|
||||||
"""Load whitelist CIDRs from local data/ files.
|
"""Read a local whitelist file and extract valid CIDRs."""
|
||||||
|
try:
|
||||||
Returns a dict of {name: [CIDR strings]}. Only keys with
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
successfully parsed CIDRs are included.
|
return extract_cidrs(f.read())
|
||||||
"""
|
except Exception:
|
||||||
out: dict[str, list[str]] = {}
|
return []
|
||||||
cidrs = _read_local_file(OPENFACTORY_FILE)
|
|
||||||
if cidrs:
|
|
||||||
out["Openfactory"] = cidrs
|
def _load_local_whitelists() -> dict[str, list[str]]:
|
||||||
cf_v4 = _read_local_file(CLOUDFLARE_V4_FILE)
|
"""Load whitelist CIDRs from local data/ files.
|
||||||
cf_v6 = _read_local_file(CLOUDFLARE_V6_FILE)
|
|
||||||
if cf_v4 or cf_v6:
|
Returns a dict of {name: [CIDR strings]}. Only keys with
|
||||||
out["Cloudflare"] = cf_v4 + cf_v6
|
successfully parsed CIDRs are included.
|
||||||
cidrs = _read_local_file(PALOALTO_FILE)
|
"""
|
||||||
if cidrs:
|
out: dict[str, list[str]] = {}
|
||||||
out["PaloAlto"] = cidrs
|
# Init7 has no published geofeed; its ranges are built-in (INIT7_IPV4/6).
|
||||||
return out
|
cf_v4 = _read_local_file(CLOUDFLARE_V4_FILE)
|
||||||
|
cf_v6 = _read_local_file(CLOUDFLARE_V6_FILE)
|
||||||
|
if cf_v4 or cf_v6:
|
||||||
# ---------------------------------------------------------------------------
|
out["Cloudflare"] = cf_v4 + cf_v6
|
||||||
# HTTP download helpers
|
cidrs = _read_local_file(PALOALTO_FILE)
|
||||||
# ---------------------------------------------------------------------------
|
if cidrs:
|
||||||
|
out["PaloAlto"] = cidrs
|
||||||
def _download_text(url: str) -> str:
|
return out
|
||||||
"""Download a text file from a URL, return as string."""
|
|
||||||
import urllib.request
|
|
||||||
request = urllib.request.Request(
|
# ---------------------------------------------------------------------------
|
||||||
url,
|
# HTTP download helpers
|
||||||
headers={
|
# ---------------------------------------------------------------------------
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; intrusion-detection/1.0)",
|
|
||||||
"Accept": "text/plain,text/csv,text/html;q=0.9,*/*;q=0.8",
|
def _download_text(url: str) -> str:
|
||||||
},
|
"""Download a text file from a URL, return as string."""
|
||||||
)
|
import urllib.request
|
||||||
with urllib.request.urlopen(request) as response:
|
request = urllib.request.Request(
|
||||||
return response.read().decode("utf-8", errors="replace")
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; intrusion-detection/1.0)",
|
||||||
def _download_cidrs(url: str) -> list[str]:
|
"Accept": "text/plain,text/csv,text/html;q=0.9,*/*;q=0.8",
|
||||||
"""Download a whitelist source and extract valid CIDRs from it."""
|
},
|
||||||
return extract_cidrs(_download_text(url))
|
)
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
return response.read().decode("utf-8", errors="replace")
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Refresh pipeline
|
|
||||||
# ---------------------------------------------------------------------------
|
def _download_cidrs(url: str) -> list[str]:
|
||||||
|
"""Download a whitelist source and extract valid CIDRs from it."""
|
||||||
def _download_whitelists() -> dict[str, list[str]]:
|
return extract_cidrs(_download_text(url))
|
||||||
"""Download the latest whitelists from the Internet and save to local files.
|
|
||||||
|
|
||||||
Returns a dict of {name: [CIDR strings]}. Only keys with
|
# ---------------------------------------------------------------------------
|
||||||
successfully parsed CIDRs are included; failures are logged
|
# Refresh pipeline
|
||||||
and skipped so built-in fallbacks remain untouched.
|
# ---------------------------------------------------------------------------
|
||||||
Saves each download to the corresponding local file on success.
|
|
||||||
"""
|
def _download_whitelists() -> dict[str, list[str]]:
|
||||||
out: dict[str, list[str]] = {}
|
"""Download the latest whitelists from the Internet and save to local files.
|
||||||
try:
|
|
||||||
text = _download_text(OPENFACTORY_URL)
|
Returns a dict of {name: [CIDR strings]}. Only keys with
|
||||||
cidrs = extract_cidrs(text)
|
successfully parsed CIDRs are included; failures are logged
|
||||||
if cidrs:
|
and skipped so built-in fallbacks remain untouched.
|
||||||
out["Openfactory"] = cidrs
|
Saves each download to the corresponding local file on success.
|
||||||
_save_local_file(OPENFACTORY_FILE, text)
|
"""
|
||||||
except Exception as e:
|
out: dict[str, list[str]] = {}
|
||||||
print(f"Error downloading Openfactory whitelist: {e}")
|
# Init7 has no published geofeed; its ranges are built-in (INIT7_IPV4/6).
|
||||||
try:
|
try:
|
||||||
text_v4 = _download_text(CLOUDFLARE_URL_V4)
|
text_v4 = _download_text(CLOUDFLARE_URL_V4)
|
||||||
text_v6 = _download_text(CLOUDFLARE_URL_V6)
|
text_v6 = _download_text(CLOUDFLARE_URL_V6)
|
||||||
cidrs = extract_cidrs(text_v4) + extract_cidrs(text_v6)
|
cidrs = extract_cidrs(text_v4) + extract_cidrs(text_v6)
|
||||||
if cidrs:
|
if cidrs:
|
||||||
out["Cloudflare"] = cidrs
|
out["Cloudflare"] = cidrs
|
||||||
_save_local_file(CLOUDFLARE_V4_FILE, text_v4)
|
_save_local_file(CLOUDFLARE_V4_FILE, text_v4)
|
||||||
_save_local_file(CLOUDFLARE_V6_FILE, text_v6)
|
_save_local_file(CLOUDFLARE_V6_FILE, text_v6)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error downloading Cloudflare whitelist: {e}")
|
print(f"Error downloading Cloudflare whitelist: {e}")
|
||||||
# Do not download PaloAlto whitelist for now, as it is not a reliable source of CIDRs and often returns HTML instead of plain text.
|
# Do not download PaloAlto whitelist for now, as it is not a reliable source of CIDRs and often returns HTML instead of plain text.
|
||||||
# try:
|
# try:
|
||||||
# text = _download_text(PALOALTO_URL)
|
# text = _download_text(PALOALTO_URL)
|
||||||
# cidrs = extract_cidrs(text)
|
# cidrs = extract_cidrs(text)
|
||||||
# if cidrs and len(cidrs) > 0:
|
# if cidrs and len(cidrs) > 0:
|
||||||
# out["PaloAlto"] = cidrs
|
# out["PaloAlto"] = cidrs
|
||||||
# _save_local_file(PALOALTO_FILE, text)
|
# _save_local_file(PALOALTO_FILE, text)
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# print(f"Error downloading PaloAlto whitelist: {e}")
|
# print(f"Error downloading PaloAlto whitelist: {e}")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _save_local_file(path: str, text: str) -> None:
|
def _save_local_file(path: str, text: str) -> None:
|
||||||
"""Save downloaded text to a local file, creating parent dirs as needed."""
|
"""Save downloaded text to a local file, creating parent dirs as needed."""
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving whitelist file {path}: {e}")
|
print(f"Error saving whitelist file {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def update_whitelists() -> None:
|
def update_whitelists() -> None:
|
||||||
"""Load whitelists (local files first, then remote, then built-in fallback).
|
"""Load whitelists (local files first, then remote, then built-in fallback).
|
||||||
|
|
||||||
Priority per provider:
|
Priority per provider:
|
||||||
1. Local file in data/<provider>/
|
1. Local file in data/<provider>/
|
||||||
2. Remote download (saves to local file on success)
|
2. Remote download (saves to local file on success)
|
||||||
3. Built-in hardcoded constants (always available)
|
3. Built-in hardcoded constants (always available)
|
||||||
"""
|
"""
|
||||||
global WHITELISTS, WHITELIST_NETS
|
global WHITELISTS, WHITELIST_NETS
|
||||||
|
|
||||||
# Start from built-in fallbacks, then layer local, then remote
|
# Start from built-in fallbacks, then layer local, then remote
|
||||||
WHITELISTS = {
|
WHITELISTS = {
|
||||||
"Openfactory": OPENFACTORY_IPV4,
|
"Init7": INIT7_IPV4 + INIT7_IPV6,
|
||||||
"Cloudflare": CLOUDFLARE_IPV4 + CLOUDFLARE_IPV6,
|
"Cloudflare": CLOUDFLARE_IPV4 + CLOUDFLARE_IPV6,
|
||||||
"PaloAlto": PALOALTO_IPV4 + PALOALTO_IPV6,
|
"PaloAlto": PALOALTO_IPV4 + PALOALTO_IPV6,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Layer 1: local files
|
# Layer 1: local files
|
||||||
local = _load_local_whitelists()
|
local = _load_local_whitelists()
|
||||||
if local:
|
if local:
|
||||||
WHITELISTS.update(local)
|
WHITELISTS.update(local)
|
||||||
|
|
||||||
# Layer 2: remote download (highest priority, saves local)
|
# Layer 2: remote download (highest priority, saves local)
|
||||||
remote = _download_whitelists()
|
remote = _download_whitelists()
|
||||||
if remote:
|
if remote:
|
||||||
WHITELISTS.update(remote)
|
WHITELISTS.update(remote)
|
||||||
|
|
||||||
# Rebuild parsed networks
|
# Rebuild parsed networks
|
||||||
WHITELIST_NETS = {
|
WHITELIST_NETS = {
|
||||||
name: tuple(ipaddress.ip_network(c) for c in cidrs)
|
name: tuple(ipaddress.ip_network(c) for c in cidrs)
|
||||||
for name, cidrs in WHITELISTS.items()
|
for name, cidrs in WHITELISTS.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def refresh_whitelists(force: bool = False) -> bool:
|
def refresh_whitelists(force: bool = False) -> bool:
|
||||||
"""Refresh provider whitelists once per process, keeping built-in fallbacks.
|
"""Refresh provider whitelists once per process, keeping built-in fallbacks.
|
||||||
|
|
||||||
Returns True if a refresh was attempted, False if already done.
|
Returns True if a refresh was attempted, False if already done.
|
||||||
"""
|
"""
|
||||||
global _refreshed
|
global _refreshed
|
||||||
if _refreshed and not force:
|
if _refreshed and not force:
|
||||||
return False
|
return False
|
||||||
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})")
|
||||||
+128
-128
@@ -1,128 +1,128 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""WHOIS subnet querying and IP consolidation."""
|
"""WHOIS subnet querying and IP consolidation."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
SUBNET_THRESHOLD,
|
SUBNET_THRESHOLD,
|
||||||
WHOIS_CACHE_PATH,
|
WHOIS_CACHE_PATH,
|
||||||
WHOIS_PORT,
|
WHOIS_PORT,
|
||||||
WHOIS_SERVER,
|
WHOIS_SERVER,
|
||||||
WHOIS_TIMEOUT,
|
WHOIS_TIMEOUT,
|
||||||
)
|
)
|
||||||
from .ip_utils import ip_version, parse_ip
|
from .ip_utils import ip_version, parse_ip
|
||||||
|
|
||||||
_whois_cache: dict[str, str] = {}
|
_whois_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def load_whois_cache() -> None:
|
def load_whois_cache() -> None:
|
||||||
global _whois_cache
|
global _whois_cache
|
||||||
if not _whois_cache and os.path.exists(WHOIS_CACHE_PATH):
|
if not _whois_cache and os.path.exists(WHOIS_CACHE_PATH):
|
||||||
try:
|
try:
|
||||||
with open(WHOIS_CACHE_PATH, "r", encoding="utf-8") as f:
|
with open(WHOIS_CACHE_PATH, "r", encoding="utf-8") as f:
|
||||||
_whois_cache = json.load(f)
|
_whois_cache = json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
_whois_cache = {}
|
_whois_cache = {}
|
||||||
|
|
||||||
|
|
||||||
def save_whois_cache() -> None:
|
def save_whois_cache() -> None:
|
||||||
if _whois_cache:
|
if _whois_cache:
|
||||||
os.makedirs(os.path.dirname(WHOIS_CACHE_PATH), exist_ok=True)
|
os.makedirs(os.path.dirname(WHOIS_CACHE_PATH), exist_ok=True)
|
||||||
try:
|
try:
|
||||||
with open(WHOIS_CACHE_PATH, "w", encoding="utf-8") as f:
|
with open(WHOIS_CACHE_PATH, "w", encoding="utf-8") as f:
|
||||||
json.dump(_whois_cache, f, indent=2)
|
json.dump(_whois_cache, f, indent=2)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def query_whois_subnet(ip: str) -> str | None:
|
def query_whois_subnet(ip: str) -> str | None:
|
||||||
"""Query whois.radb.net for the route/subnet of an IP address."""
|
"""Query whois.radb.net for the route/subnet of an IP address."""
|
||||||
addr = parse_ip(ip)
|
addr = parse_ip(ip)
|
||||||
if addr is None:
|
if addr is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
load_whois_cache()
|
load_whois_cache()
|
||||||
if ip in _whois_cache:
|
if ip in _whois_cache:
|
||||||
return _whois_cache[ip] or None
|
return _whois_cache[ip] or None
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
try:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s.settimeout(WHOIS_TIMEOUT)
|
s.settimeout(WHOIS_TIMEOUT)
|
||||||
s.connect((WHOIS_SERVER, WHOIS_PORT))
|
s.connect((WHOIS_SERVER, WHOIS_PORT))
|
||||||
s.sendall(f"{ip}\r\n".encode())
|
s.sendall(f"{ip}\r\n".encode())
|
||||||
res = ""
|
res = ""
|
||||||
while True:
|
while True:
|
||||||
chunk = s.recv(4096).decode(errors="ignore")
|
chunk = s.recv(4096).decode(errors="ignore")
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
res += chunk
|
res += chunk
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
routes = re.findall(r"^(?:route|route6):\s*(\S+)",
|
routes = re.findall(r"^(?:route|route6):\s*(\S+)",
|
||||||
res, re.IGNORECASE | re.MULTILINE)
|
res, re.IGNORECASE | re.MULTILINE)
|
||||||
if routes:
|
if routes:
|
||||||
routes_parsed = []
|
routes_parsed = []
|
||||||
for r in routes:
|
for r in routes:
|
||||||
try:
|
try:
|
||||||
routes_parsed.append(ipaddress.ip_network(r))
|
routes_parsed.append(ipaddress.ip_network(r))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
if routes_parsed:
|
if routes_parsed:
|
||||||
best_route = max(routes_parsed, key=lambda n: n.prefixlen)
|
best_route = max(routes_parsed, key=lambda n: n.prefixlen)
|
||||||
subnet_str = str(best_route)
|
subnet_str = str(best_route)
|
||||||
_whois_cache[ip] = subnet_str
|
_whois_cache[ip] = subnet_str
|
||||||
save_whois_cache()
|
save_whois_cache()
|
||||||
return subnet_str
|
return subnet_str
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
subnet_str = f"{ip}/32" if addr.version == 4 else f"{ip}/128"
|
subnet_str = f"{ip}/32" if addr.version == 4 else f"{ip}/128"
|
||||||
_whois_cache[ip] = subnet_str
|
_whois_cache[ip] = subnet_str
|
||||||
save_whois_cache()
|
save_whois_cache()
|
||||||
return subnet_str
|
return subnet_str
|
||||||
|
|
||||||
|
|
||||||
def consolidate_by_subnet(ips: Iterable[str],
|
def consolidate_by_subnet(ips: Iterable[str],
|
||||||
threshold: int = SUBNET_THRESHOLD) -> set[str]:
|
threshold: int = SUBNET_THRESHOLD) -> set[str]:
|
||||||
"""Group IPs by WHOIS subnet; if >= threshold IPs in a subnet, block the subnet.
|
"""Group IPs by WHOIS subnet; if >= threshold IPs in a subnet, block the subnet.
|
||||||
|
|
||||||
Otherwise, block individual IPs.
|
Otherwise, block individual IPs.
|
||||||
"""
|
"""
|
||||||
ip_list = list(ips)
|
ip_list = list(ips)
|
||||||
subnet_to_ips: dict[str, set[str]] = {}
|
subnet_to_ips: dict[str, set[str]] = {}
|
||||||
ip_to_subnet: dict[str, str] = {}
|
ip_to_subnet: dict[str, str] = {}
|
||||||
|
|
||||||
for ip in ip_list:
|
for ip in ip_list:
|
||||||
subnet = query_whois_subnet(ip)
|
subnet = query_whois_subnet(ip)
|
||||||
if subnet:
|
if subnet:
|
||||||
try:
|
try:
|
||||||
net = ipaddress.ip_network(subnet, strict=False)
|
net = ipaddress.ip_network(subnet, strict=False)
|
||||||
addr = ipaddress.ip_address(ip)
|
addr = ipaddress.ip_address(ip)
|
||||||
if addr in net:
|
if addr in net:
|
||||||
subnet_to_ips.setdefault(subnet, set()).add(ip)
|
subnet_to_ips.setdefault(subnet, set()).add(ip)
|
||||||
ip_to_subnet[ip] = subnet
|
ip_to_subnet[ip] = subnet
|
||||||
continue
|
continue
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
fallback_net = f"{ip}/32" if ip_version(ip) == 4 else f"{ip}/128"
|
fallback_net = f"{ip}/32" if ip_version(ip) == 4 else f"{ip}/128"
|
||||||
subnet_to_ips.setdefault(fallback_net, set()).add(ip)
|
subnet_to_ips.setdefault(fallback_net, set()).add(ip)
|
||||||
ip_to_subnet[ip] = fallback_net
|
ip_to_subnet[ip] = fallback_net
|
||||||
|
|
||||||
blocked_targets: set[str] = set()
|
blocked_targets: set[str] = set()
|
||||||
for subnet, mapped_ips in subnet_to_ips.items():
|
for subnet, mapped_ips in subnet_to_ips.items():
|
||||||
if len(mapped_ips) >= threshold:
|
if len(mapped_ips) >= threshold:
|
||||||
blocked_targets.add(subnet)
|
blocked_targets.add(subnet)
|
||||||
else:
|
else:
|
||||||
for ip in mapped_ips:
|
for ip in mapped_ips:
|
||||||
blocked_targets.add(
|
blocked_targets.add(
|
||||||
f"{ip}/32" if ip_version(ip) == 4 else f"{ip}/128")
|
f"{ip}/32" if ip_version(ip) == 4 else f"{ip}/128")
|
||||||
|
|
||||||
return blocked_targets
|
return blocked_targets
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Flask web UI for the intrusion-detection system.
|
||||||
|
|
||||||
|
A localhost-only web server (``127.0.0.1:<high port>``) 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, jsonify, request
|
||||||
|
|
||||||
|
from ..utils.constants import (
|
||||||
|
APP_REPORT_DATA_DIR,
|
||||||
|
APP_REPORT_DIR,
|
||||||
|
COPYPARTY_LOG_PATH,
|
||||||
|
COPYPARTY_REPORT_DIR,
|
||||||
|
DEFAULT_ERROR_RATIO,
|
||||||
|
DEFAULT_MIN_DENIED,
|
||||||
|
DEFAULT_MIN_ERRORS,
|
||||||
|
DEFAULT_TOP,
|
||||||
|
MIKROTIK_REPORT_DIR,
|
||||||
|
NPM_LOG_PATH,
|
||||||
|
NPM_REPORT_DIR,
|
||||||
|
REPORT_DIR,
|
||||||
|
WEB_HOST,
|
||||||
|
WEB_PORT,
|
||||||
|
)
|
||||||
|
from ..firewall import MikrotikFirewall
|
||||||
|
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)),
|
||||||
|
full=bool(data.get("full", 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)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
"""Build and configure the Flask application."""
|
||||||
|
_app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
app = Flask(__name__,
|
||||||
|
template_folder=os.path.join(_app_dir, "templates"),
|
||||||
|
static_folder=os.path.join(_app_dir, "static"))
|
||||||
|
|
||||||
|
# Seed the in-memory job store from disk so the dashboard keeps showing
|
||||||
|
# recent scans across server restarts.
|
||||||
|
web_scan.load_persisted()
|
||||||
|
|
||||||
|
from .routes import register_routes
|
||||||
|
|
||||||
|
helpers = {
|
||||||
|
"_count_lines": _count_lines,
|
||||||
|
"_read_lines": _read_lines,
|
||||||
|
"_read_json": _read_json,
|
||||||
|
"_params_from_request": _params_from_request,
|
||||||
|
"_with_router": _with_router,
|
||||||
|
"_sync_firewall": _sync_firewall,
|
||||||
|
"_analysis": _analysis_helpers(),
|
||||||
|
}
|
||||||
|
register_routes(app, REPORT_SOURCES, SCAN_KINDS, web_scan, helpers)
|
||||||
|
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()
|
||||||
+418
-414
@@ -1,414 +1,418 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Flask web UI for the intrusion-detection system.
|
"""Route definitions for the intrusion-detection web UI."""
|
||||||
|
|
||||||
A localhost-only web server (``127.0.0.1:<high port>``) for running scans,
|
from __future__ import annotations
|
||||||
browsing reports, and managing the MikroTik blocklist / firewall.
|
|
||||||
|
import csv
|
||||||
Run directly: ``python -m app.web``
|
import io
|
||||||
Or via the entry point: ``python app.py --web`` (see ``serve()``).
|
import json
|
||||||
"""
|
import os
|
||||||
|
|
||||||
from __future__ import annotations
|
from flask import Flask, Response, abort, jsonify, render_template, request
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
import json
|
|
||||||
import os
|
from ..utils.constants import (
|
||||||
|
APP_REPORT_DATA_DIR,
|
||||||
from flask import Flask, abort, jsonify, render_template, request
|
BLOCKLIST_NAME,
|
||||||
|
MIKROTIK_DATA_DIR,
|
||||||
from .constants import (
|
WEB_HOST,
|
||||||
APP_REPORT_DATA_DIR,
|
WEB_PORT,
|
||||||
APP_REPORT_DIR,
|
WHITELIST_NAMES,
|
||||||
BLOCKLIST_NAME,
|
)
|
||||||
COPYPARTY_LOG_PATH,
|
from ..utils.ip_utils import ip_version
|
||||||
COPYPARTY_REPORT_DIR,
|
|
||||||
DEFAULT_ERROR_RATIO,
|
|
||||||
DEFAULT_MIN_DENIED,
|
def register_routes(app: Flask,
|
||||||
DEFAULT_MIN_ERRORS,
|
report_sources: dict,
|
||||||
DEFAULT_TOP,
|
scan_kinds: tuple,
|
||||||
MIKROTIK_DATA_DIR,
|
web_scan,
|
||||||
MIKROTIK_REPORT_DIR,
|
helpers) -> None:
|
||||||
NPM_LOG_PATH,
|
"""Register all web UI routes on the Flask application."""
|
||||||
NPM_REPORT_DIR,
|
|
||||||
REPORT_DIR,
|
_count_lines = helpers["_count_lines"]
|
||||||
WEB_HOST,
|
_read_lines = helpers["_read_lines"]
|
||||||
WEB_PORT,
|
_read_json = helpers["_read_json"]
|
||||||
WHITELIST_NAMES,
|
_params_from_request = helpers["_params_from_request"]
|
||||||
)
|
_with_router = helpers["_with_router"]
|
||||||
from .firewall import MikrotikFirewall
|
_sync_firewall = helpers["_sync_firewall"]
|
||||||
from .ip_utils import ip_version
|
|
||||||
from .scan import ScanParams
|
# --- log analysis (SQLite-backed) --------------------------------------
|
||||||
from . import web_scan
|
|
||||||
|
_analysis = helpers.get("_analysis")
|
||||||
#: report sources exposed by the API -> report directory
|
|
||||||
REPORT_SOURCES = {
|
@app.get("/analysis")
|
||||||
"copyparty": COPYPARTY_REPORT_DIR,
|
def analysis_page():
|
||||||
"npm": NPM_REPORT_DIR,
|
return render_template("analysis.html")
|
||||||
"app": APP_REPORT_DIR,
|
|
||||||
"mikrotik": MIKROTIK_REPORT_DIR,
|
@app.get("/api/analysis/top-ips")
|
||||||
}
|
def analysis_top_ips():
|
||||||
|
if _analysis is None:
|
||||||
SCAN_KINDS = ("copyparty", "npm", "consolidated")
|
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)})
|
||||||
# helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
@app.get("/api/analysis/ip/<ip>")
|
||||||
|
def analysis_ip(ip):
|
||||||
def _count_lines(path: str) -> int | None:
|
if _analysis is None:
|
||||||
"""Return the line count of a file, or None if it cannot be read."""
|
return jsonify({"error": "analysis unavailable"}), 503
|
||||||
try:
|
return jsonify({"ip": ip,
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
"timeline": _analysis["ip_timeline"](ip)})
|
||||||
return sum(1 for _ in f)
|
|
||||||
except OSError:
|
@app.get("/api/analysis/cross-source")
|
||||||
return None
|
def analysis_cross_source():
|
||||||
|
if _analysis is None:
|
||||||
|
return jsonify({"error": "analysis unavailable"}), 503
|
||||||
def _read_lines(path: str) -> list[str]:
|
limit = request.args.get("limit", 100, type=int)
|
||||||
"""Return non-empty lines of a file, or [] if it cannot be read."""
|
return jsonify({"ips": _analysis["cross_source"](limit=limit)})
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
@app.get("/api/analysis/manifest")
|
||||||
return [line.strip() for line in f if line.strip()]
|
def analysis_manifest():
|
||||||
except OSError:
|
return jsonify({"ok": _analysis is not None})
|
||||||
return []
|
|
||||||
|
# --- pages -------------------------------------------------------------
|
||||||
|
|
||||||
def _read_json(path: str, default: list | dict | None = None) -> list | dict:
|
@app.get("/")
|
||||||
"""Read a JSON file, returning ``default`` (or []) on failure."""
|
def index():
|
||||||
try:
|
return render_template("dashboard.html")
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
@app.get("/scan")
|
||||||
except (OSError, ValueError):
|
def scan_page():
|
||||||
return default if default is not None else []
|
return render_template("scan.html")
|
||||||
|
|
||||||
|
@app.get("/reports")
|
||||||
def _params_from_request() -> ScanParams:
|
def reports_page():
|
||||||
"""Build ScanParams from the JSON request body, falling back to defaults."""
|
return render_template("reports.html")
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
|
@app.get("/blocklist")
|
||||||
def _pick(key: str, default):
|
def blocklist_page():
|
||||||
value = data.get(key)
|
return render_template("blocklist.html")
|
||||||
return default if value is None or value == "" else value
|
|
||||||
|
@app.get("/firewall")
|
||||||
return ScanParams(
|
def firewall_page():
|
||||||
copyparty_log_path=str(
|
return render_template("firewall.html")
|
||||||
_pick("copyparty_log_path", COPYPARTY_LOG_PATH)),
|
|
||||||
copyparty_report_dir=str(
|
@app.get("/whitelist")
|
||||||
_pick("copyparty_report_dir", COPYPARTY_REPORT_DIR)),
|
def whitelist_page():
|
||||||
min_denied=int(_pick("min_denied", DEFAULT_MIN_DENIED)),
|
return render_template("whitelist.html", whitelist_names=WHITELIST_NAMES)
|
||||||
npm_log_path=str(_pick("npm_log_path", NPM_LOG_PATH)),
|
|
||||||
npm_report_dir=str(_pick("npm_report_dir", NPM_REPORT_DIR)),
|
@app.get("/graph")
|
||||||
min_errors=int(_pick("min_errors", DEFAULT_MIN_ERRORS)),
|
def graph_page():
|
||||||
error_ratio=float(_pick("error_ratio", DEFAULT_ERROR_RATIO)),
|
return render_template("graph.html")
|
||||||
report_dir=str(_pick("report_dir", REPORT_DIR)),
|
|
||||||
top=int(_pick("top", DEFAULT_TOP)),
|
# --- status ------------------------------------------------------------
|
||||||
quiet=bool(data.get("quiet", False)),
|
|
||||||
full=bool(data.get("full", False)),
|
@app.get("/api/status")
|
||||||
update_firewall=bool(data.get("update_firewall", False)),
|
def status():
|
||||||
dry_run=bool(data.get("dry_run", False)),
|
v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")
|
||||||
)
|
v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")
|
||||||
|
analysis = {}
|
||||||
|
if _analysis is not None:
|
||||||
def _with_router(fn):
|
try:
|
||||||
"""Connect to the router, run ``fn(fw)``, always disconnect.
|
conn = _analysis["conn"]()
|
||||||
|
analysis = {
|
||||||
Returns a JSON error response (502) if the router is unreachable.
|
"available": True,
|
||||||
"""
|
"request_count": _analysis["request_count"](conn),
|
||||||
fw = MikrotikFirewall()
|
"scan_count": _analysis["scan_count"](conn),
|
||||||
try:
|
}
|
||||||
fw.connect()
|
except Exception:
|
||||||
return fn(fw)
|
analysis = {"available": False}
|
||||||
except Exception as e:
|
return jsonify({
|
||||||
return jsonify({"error": str(e)}), 502
|
"host": WEB_HOST,
|
||||||
finally:
|
"port": WEB_PORT,
|
||||||
fw.disconnect()
|
"scan_running": web_scan.is_scan_running(),
|
||||||
|
"latest_scans": web_scan.latest_scans(5),
|
||||||
|
"blocklist_ipv4_count": _count_lines(v4_path),
|
||||||
def _sync_firewall(dry_run: bool):
|
"blocklist_ipv6_count": _count_lines(v6_path),
|
||||||
"""Sync the latest consolidated blocklist to the router (preview or apply)."""
|
"firewall_snapshot": {
|
||||||
v4 = _read_lines(os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt"))
|
"exists": os.path.isdir(MIKROTIK_DATA_DIR),
|
||||||
v6 = _read_lines(os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt"))
|
"path": MIKROTIK_DATA_DIR,
|
||||||
|
},
|
||||||
def _sync(fw):
|
"analysis": analysis,
|
||||||
changes = fw.sync_blocklist(v4, v6, dry_run=dry_run)
|
})
|
||||||
return jsonify({**changes, "dry_run": dry_run})
|
|
||||||
|
# --- scans -------------------------------------------------------------
|
||||||
return _with_router(_sync)
|
|
||||||
|
@app.post("/api/scan/<kind>")
|
||||||
|
def start_scan(kind):
|
||||||
# ---------------------------------------------------------------------------
|
if kind not in scan_kinds:
|
||||||
# app factory
|
abort(404, f"unknown scan kind: {kind}")
|
||||||
# ---------------------------------------------------------------------------
|
job = web_scan.start_scan(kind, _params_from_request())
|
||||||
|
if job.get("error"):
|
||||||
def create_app() -> Flask:
|
return jsonify(job), 409
|
||||||
"""Build and configure the Flask application."""
|
return jsonify(job), 202
|
||||||
app = Flask(__name__)
|
|
||||||
|
@app.get("/api/scan/status")
|
||||||
# Seed the in-memory job store from disk so the dashboard keeps showing
|
def scan_status():
|
||||||
# recent scans across server restarts.
|
return jsonify({
|
||||||
web_scan.load_persisted()
|
"running": web_scan.is_scan_running(),
|
||||||
|
"latest_scans": web_scan.latest_scans(10),
|
||||||
@app.get("/")
|
})
|
||||||
def index():
|
|
||||||
return render_template("dashboard.html")
|
@app.get("/api/scan/<job_id>")
|
||||||
|
def scan_job(job_id):
|
||||||
@app.get("/scan")
|
job = web_scan.get_scan(job_id)
|
||||||
def scan_page():
|
if job is None:
|
||||||
return render_template("scan.html")
|
abort(404, f"unknown job: {job_id}")
|
||||||
|
return jsonify(job)
|
||||||
@app.get("/reports")
|
|
||||||
def reports_page():
|
# --- reports -----------------------------------------------------------
|
||||||
return render_template("reports.html")
|
|
||||||
|
@app.get("/api/reports")
|
||||||
@app.get("/blocklist")
|
def list_report_artifacts():
|
||||||
def blocklist_page():
|
source = (request.args.get("source") or "").strip() or None
|
||||||
return render_template("blocklist.html")
|
if source is not None and source not in report_sources:
|
||||||
|
return jsonify({"error": "unknown report source"}), 400
|
||||||
@app.get("/firewall")
|
limit = request.args.get("limit", default=50, type=int)
|
||||||
def firewall_page():
|
try:
|
||||||
return render_template("firewall.html")
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
@app.get("/whitelist")
|
result = repo.list_report_artifacts(
|
||||||
def whitelist_page():
|
get_connection(), source=source,
|
||||||
return render_template("whitelist.html", whitelist_names=WHITELIST_NAMES)
|
report_type=(request.args.get("type") or "").strip() or None,
|
||||||
|
since=(request.args.get("since") or "").strip() or None,
|
||||||
@app.get("/graph")
|
until=(request.args.get("until") or "").strip() or None,
|
||||||
def graph_page():
|
limit=limit,
|
||||||
return render_template("graph.html")
|
cursor=(request.args.get("cursor") or "").strip() or None,
|
||||||
|
)
|
||||||
# --- status ------------------------------------------------------------
|
return jsonify(result)
|
||||||
|
except ValueError as exc:
|
||||||
@app.get("/api/status")
|
return jsonify({"error": str(exc)}), 400
|
||||||
def status():
|
except Exception as exc:
|
||||||
v4_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")
|
return jsonify({"error": f"report database unavailable: {exc}"}), 503
|
||||||
v6_path = os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")
|
|
||||||
return jsonify({
|
@app.get("/api/report-artifacts/<int:artifact_id>")
|
||||||
"host": WEB_HOST,
|
def get_report_artifact(artifact_id: int):
|
||||||
"port": WEB_PORT,
|
try:
|
||||||
"scan_running": web_scan.is_scan_running(),
|
from ..db import get_connection
|
||||||
"latest_scans": web_scan.latest_scans(5),
|
from ..db import repositories as repo
|
||||||
"blocklist_ipv4_count": _count_lines(v4_path),
|
artifact = repo.get_report_artifact(get_connection(), artifact_id)
|
||||||
"blocklist_ipv6_count": _count_lines(v6_path),
|
except Exception as exc:
|
||||||
"firewall_snapshot": {
|
return jsonify({"error": f"report database unavailable: {exc}"}), 503
|
||||||
"exists": os.path.isdir(MIKROTIK_DATA_DIR),
|
if artifact is None:
|
||||||
"path": MIKROTIK_DATA_DIR,
|
abort(404, "report artifact not found")
|
||||||
},
|
return jsonify(artifact)
|
||||||
})
|
|
||||||
|
@app.get("/api/report-artifacts/<int:artifact_id>/export")
|
||||||
# --- scans -------------------------------------------------------------
|
def export_report_artifact(artifact_id: int):
|
||||||
|
try:
|
||||||
@app.post("/api/scan/<kind>")
|
from ..db import get_connection
|
||||||
def start_scan(kind):
|
from ..db import repositories as repo
|
||||||
if kind not in SCAN_KINDS:
|
artifact = repo.get_report_artifact(get_connection(), artifact_id)
|
||||||
abort(404, f"unknown scan kind: {kind}")
|
except Exception as exc:
|
||||||
job = web_scan.start_scan(kind, _params_from_request())
|
return jsonify({"error": f"report database unavailable: {exc}"}), 503
|
||||||
# A busy-lock rejection is a bare {"error": ...}; a started job has
|
if artifact is None:
|
||||||
# "error" == None, so test for a truthy value, not key presence.
|
abort(404, "report artifact not found")
|
||||||
if job.get("error"):
|
|
||||||
return jsonify(job), 409
|
payload = artifact["payload"]
|
||||||
return jsonify(job), 202
|
export_format = (request.args.get("format") or "json").lower()
|
||||||
|
filename = secure_filename(os.path.basename(
|
||||||
@app.get("/api/scan/status")
|
artifact["original_path"])) or f"report-{artifact_id}"
|
||||||
def scan_status():
|
if export_format == "json":
|
||||||
return jsonify({
|
content = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
"running": web_scan.is_scan_running(),
|
mimetype = "application/json"
|
||||||
"latest_scans": web_scan.latest_scans(10),
|
extension = ".json"
|
||||||
})
|
elif export_format == "txt" and payload.get("format") == "text":
|
||||||
|
content = payload.get("content", "")
|
||||||
@app.get("/api/scan/<job_id>")
|
mimetype = "text/plain; charset=utf-8"
|
||||||
def scan_job(job_id):
|
extension = ".txt"
|
||||||
job = web_scan.get_scan(job_id)
|
elif export_format == "csv" and payload.get("format") == "csv":
|
||||||
if job is None:
|
output = io.StringIO(newline="")
|
||||||
abort(404, f"unknown job: {job_id}")
|
writer = csv.DictWriter(
|
||||||
return jsonify(job)
|
output, fieldnames=payload.get("columns", []),
|
||||||
|
extrasaction="ignore")
|
||||||
# --- reports -----------------------------------------------------------
|
writer.writeheader()
|
||||||
|
writer.writerows(payload.get("rows", []))
|
||||||
@app.get("/api/reports/<source>")
|
content = output.getvalue()
|
||||||
def list_reports(source):
|
mimetype = "text/csv; charset=utf-8"
|
||||||
base = REPORT_SOURCES.get(source)
|
extension = ".csv"
|
||||||
if base is None:
|
else:
|
||||||
abort(404, f"unknown report source: {source}")
|
return jsonify({"error": "unsupported export format"}), 406
|
||||||
names = sorted(
|
|
||||||
n for n in os.listdir(base)
|
download_name = (filename if filename.lower().endswith(extension)
|
||||||
if os.path.isfile(os.path.join(base, n)))
|
else f"{filename}{extension}")
|
||||||
return jsonify({"source": source, "files": names})
|
response = Response(content, mimetype=mimetype)
|
||||||
|
response.headers["Content-Disposition"] = (
|
||||||
@app.get("/api/reports/<source>/<path:name>")
|
f'attachment; filename="{download_name}"')
|
||||||
def get_report(source, name):
|
return response
|
||||||
base = REPORT_SOURCES.get(source)
|
|
||||||
if base is None:
|
@app.get("/api/reports/<source>")
|
||||||
abort(404, f"unknown report source: {source}")
|
def list_reports(source):
|
||||||
full = os.path.realpath(os.path.join(base, name))
|
base = report_sources.get(source)
|
||||||
if not full.startswith(os.path.realpath(base)):
|
if base is None:
|
||||||
abort(403, "invalid report path")
|
abort(404, f"unknown report source: {source}")
|
||||||
if not os.path.isfile(full):
|
names = sorted(
|
||||||
abort(404, "report not found")
|
n for n in os.listdir(base)
|
||||||
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
if os.path.isfile(os.path.join(base, n)))
|
||||||
content = f.read()
|
return jsonify({"source": source, "files": names})
|
||||||
return jsonify({"source": source, "name": name, "content": content})
|
|
||||||
|
@app.get("/api/reports/<source>/<path:name>")
|
||||||
# --- blocklist ---------------------------------------------------------
|
def get_report(source, name):
|
||||||
|
base = report_sources.get(source)
|
||||||
@app.get("/api/blocklist")
|
if base is None:
|
||||||
def blocklist():
|
abort(404, f"unknown report source: {source}")
|
||||||
return jsonify({
|
full = os.path.realpath(os.path.join(base, name))
|
||||||
"ipv4": _read_lines(
|
if not full.startswith(os.path.realpath(base)):
|
||||||
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")),
|
abort(403, "invalid report path")
|
||||||
"ipv6": _read_lines(
|
if not os.path.isfile(full):
|
||||||
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")),
|
abort(404, "report not found")
|
||||||
})
|
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
content = f.read()
|
||||||
@app.post("/api/blocklist/add")
|
return jsonify({"source": source, "name": name, "content": content})
|
||||||
def blocklist_add():
|
|
||||||
data = request.get_json(silent=True) or {}
|
# --- blocklist ---------------------------------------------------------
|
||||||
ip = (data.get("ip") or "").strip()
|
|
||||||
version = ip_version(ip)
|
@app.get("/api/blocklist")
|
||||||
if not ip or version not in (4, 6):
|
def blocklist():
|
||||||
return jsonify({"error": "missing or invalid 'ip' field"}), 400
|
return jsonify({
|
||||||
|
"ipv4": _read_lines(
|
||||||
def _add(fw):
|
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt")),
|
||||||
if version == 4:
|
"ipv6": _read_lines(
|
||||||
fw.write_address_list_ipv4(BLOCKLIST_NAME, [ip])
|
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv6.txt")),
|
||||||
else:
|
})
|
||||||
fw.write_address_list_ipv6(BLOCKLIST_NAME, [ip])
|
|
||||||
return jsonify({"ok": True, "action": "add", "ip": ip})
|
@app.post("/api/blocklist/add")
|
||||||
|
def blocklist_add():
|
||||||
return _with_router(_add)
|
data = request.get_json(silent=True) or {}
|
||||||
|
ip = (data.get("ip") or "").strip()
|
||||||
@app.post("/api/blocklist/remove")
|
version = ip_version(ip)
|
||||||
def blocklist_remove():
|
if not ip or version not in (4, 6):
|
||||||
data = request.get_json(silent=True) or {}
|
return jsonify({"error": "missing or invalid 'ip' field"}), 400
|
||||||
ip = (data.get("ip") or "").strip()
|
|
||||||
version = ip_version(ip)
|
def _add(fw):
|
||||||
if not ip or version not in (4, 6):
|
if version == 4:
|
||||||
return jsonify({"error": "missing or invalid 'ip' field"}), 400
|
fw.write_address_list_ipv4(BLOCKLIST_NAME, [ip])
|
||||||
|
else:
|
||||||
def _remove(fw):
|
fw.write_address_list_ipv6(BLOCKLIST_NAME, [ip])
|
||||||
path = "/ip" if version == 4 else "/ipv6"
|
return jsonify({"ok": True, "action": "add", "ip": ip})
|
||||||
fw.execute_command(
|
|
||||||
f"{path} firewall address-list remove "
|
return _with_router(_add)
|
||||||
f"[find list={BLOCKLIST_NAME} address={ip}]")
|
|
||||||
return jsonify({"ok": True, "action": "remove", "ip": ip})
|
@app.post("/api/blocklist/remove")
|
||||||
|
def blocklist_remove():
|
||||||
return _with_router(_remove)
|
data = request.get_json(silent=True) or {}
|
||||||
|
ip = (data.get("ip") or "").strip()
|
||||||
# --- graph --------------------------------------------------------------
|
version = ip_version(ip)
|
||||||
|
if not ip or version not in (4, 6):
|
||||||
@app.get("/api/graph/ipv4")
|
return jsonify({"error": "missing or invalid 'ip' field"}), 400
|
||||||
def graph_ipv4():
|
|
||||||
"""Data for the IPv4 /8 tile map.
|
def _remove(fw):
|
||||||
|
path = "/ip" if version == 4 else "/ipv6"
|
||||||
Blocked targets come from the consolidated blocklist
|
fw.execute_command(
|
||||||
(``reports/app/data/blocklist_ipv4.txt``); whitelisted ranges from the
|
f"{path} firewall address-list remove "
|
||||||
router snapshot (``data/mikrotik/address_list_ipv4.json``) filtered to
|
f"[find list={BLOCKLIST_NAME} address={ip}]")
|
||||||
the configured whitelist address-list names.
|
return jsonify({"ok": True, "action": "remove", "ip": ip})
|
||||||
"""
|
|
||||||
blocked = _read_lines(
|
return _with_router(_remove)
|
||||||
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt"))
|
|
||||||
addr_v4 = _read_json(
|
# --- graph -------------------------------------------------------------
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json"))
|
|
||||||
whitelisted = sorted({e.get("address") for e in addr_v4
|
@app.get("/api/graph/ipv4")
|
||||||
if e.get("list") in WHITELIST_NAMES
|
def graph_ipv4():
|
||||||
and e.get("address")})
|
blocked = _read_lines(
|
||||||
return jsonify({
|
os.path.join(APP_REPORT_DATA_DIR, "blocklist_ipv4.txt"))
|
||||||
"blocked": blocked,
|
addr_v4 = _read_json(
|
||||||
"whitelisted": whitelisted,
|
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json"))
|
||||||
"counts": {
|
whitelisted = sorted({e.get("address") for e in addr_v4
|
||||||
"blocked": len(blocked),
|
if e.get("list") in WHITELIST_NAMES
|
||||||
"whitelisted": len(whitelisted),
|
and e.get("address")})
|
||||||
},
|
return jsonify({
|
||||||
"blocklist_name": BLOCKLIST_NAME,
|
"blocked": blocked,
|
||||||
"whitelist_names": WHITELIST_NAMES,
|
"whitelisted": whitelisted,
|
||||||
})
|
"counts": {
|
||||||
|
"blocked": len(blocked),
|
||||||
# --- firewall ----------------------------------------------------------
|
"whitelisted": len(whitelisted),
|
||||||
|
},
|
||||||
@app.get("/api/firewall/state")
|
"blocklist_name": BLOCKLIST_NAME,
|
||||||
def firewall_state():
|
"whitelist_names": WHITELIST_NAMES,
|
||||||
return jsonify({
|
})
|
||||||
"address_lists": {
|
|
||||||
"ipv4": _read_json(
|
# --- firewall ----------------------------------------------------------
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json")),
|
|
||||||
"ipv6": _read_json(
|
@app.get("/api/firewall/state")
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json")),
|
def firewall_state():
|
||||||
},
|
return jsonify({
|
||||||
"firewall_rules": {
|
"address_lists": {
|
||||||
"ipv4": _read_json(
|
"ipv4": _read_json(
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv4.json")),
|
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json")),
|
||||||
"ipv6": _read_json(
|
"ipv6": _read_json(
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv6.json")),
|
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json")),
|
||||||
},
|
},
|
||||||
"snapshot_dir": MIKROTIK_DATA_DIR,
|
"firewall_rules": {
|
||||||
})
|
"ipv4": _read_json(
|
||||||
|
os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv4.json")),
|
||||||
@app.post("/api/firewall/sync")
|
"ipv6": _read_json(
|
||||||
def firewall_sync():
|
os.path.join(MIKROTIK_DATA_DIR, "firewall_ipv6.json")),
|
||||||
"""Preview the blocklist sync (dry-run, no changes applied)."""
|
},
|
||||||
return _sync_firewall(dry_run=True)
|
"snapshot_dir": MIKROTIK_DATA_DIR,
|
||||||
|
})
|
||||||
@app.post("/api/firewall/apply")
|
|
||||||
def firewall_apply():
|
@app.get("/api/firewall/snapshots")
|
||||||
"""Apply the blocklist sync (add new / remove stale entries)."""
|
def firewall_snapshots():
|
||||||
return _sync_firewall(dry_run=False)
|
"""Recent firewall snapshots persisted in the SQLite store (F001)."""
|
||||||
|
try:
|
||||||
# --- whitelist ---------------------------------------------------------
|
from ..db import get_connection
|
||||||
|
from ..db import repositories as repo
|
||||||
@app.get("/api/whitelist")
|
conn = get_connection()
|
||||||
def whitelist():
|
limit = request.args.get("limit", 10, type=int)
|
||||||
addr_v4 = _read_json(
|
return jsonify({"snapshots": repo.recent_firewall_snapshots(
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json"))
|
conn, limit=limit)})
|
||||||
addr_v6 = _read_json(
|
except Exception as exc:
|
||||||
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json"))
|
return jsonify({"error": str(exc), "snapshots": []}), 503
|
||||||
result: dict[str, dict[str, list[str]]] = {}
|
|
||||||
for name in WHITELIST_NAMES:
|
@app.post("/api/firewall/sync")
|
||||||
result[name] = {
|
def firewall_sync():
|
||||||
"ipv4": sorted(e.get("address") for e in addr_v4
|
return _sync_firewall(dry_run=True)
|
||||||
if e.get("list") == name),
|
|
||||||
"ipv6": sorted(e.get("address") for e in addr_v6
|
@app.post("/api/firewall/apply")
|
||||||
if e.get("list") == name),
|
def firewall_apply():
|
||||||
}
|
return _sync_firewall(dry_run=False)
|
||||||
return jsonify({"names": WHITELIST_NAMES, "whitelists": result})
|
|
||||||
|
# --- whitelist ---------------------------------------------------------
|
||||||
@app.post("/api/whitelist/update")
|
|
||||||
def whitelist_update():
|
@app.get("/api/whitelist")
|
||||||
data = request.get_json(silent=True) or {}
|
def whitelist():
|
||||||
name = (data.get("name") or "").strip()
|
addr_v4 = _read_json(
|
||||||
address = (data.get("address") or "").strip()
|
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv4.json"))
|
||||||
action = (data.get("action") or "add").strip().lower()
|
addr_v6 = _read_json(
|
||||||
version = ip_version(address)
|
os.path.join(MIKROTIK_DATA_DIR, "address_list_ipv6.json"))
|
||||||
|
result: dict[str, dict[str, list[str]]] = {}
|
||||||
if name not in WHITELIST_NAMES:
|
for name in WHITELIST_NAMES:
|
||||||
return jsonify({"error": f"unknown whitelist: {name}"}), 400
|
result[name] = {
|
||||||
if not address or version not in (4, 6):
|
"ipv4": sorted(e.get("address") for e in addr_v4
|
||||||
return jsonify({"error": "missing or invalid 'address' field"}), 400
|
if e.get("list") == name),
|
||||||
if action not in ("add", "remove"):
|
"ipv6": sorted(e.get("address") for e in addr_v6
|
||||||
return jsonify({"error": "action must be 'add' or 'remove'"}), 400
|
if e.get("list") == name),
|
||||||
|
}
|
||||||
def _update(fw):
|
return jsonify({"names": WHITELIST_NAMES, "whitelists": result})
|
||||||
path = "/ip" if version == 4 else "/ipv6"
|
|
||||||
if action == "add":
|
@app.post("/api/whitelist/update")
|
||||||
if version == 4:
|
def whitelist_update():
|
||||||
fw.write_address_list_ipv4(name, [address])
|
data = request.get_json(silent=True) or {}
|
||||||
else:
|
name = (data.get("name") or "").strip()
|
||||||
fw.write_address_list_ipv6(name, [address])
|
address = (data.get("address") or "").strip()
|
||||||
else:
|
action = (data.get("action") or "add").strip().lower()
|
||||||
fw.execute_command(
|
version = ip_version(address)
|
||||||
f"{path} firewall address-list remove "
|
|
||||||
f"[find list={name} address={address}]")
|
if name not in WHITELIST_NAMES:
|
||||||
return jsonify({"ok": True, "action": action,
|
return jsonify({"error": f"unknown whitelist: {name}"}), 400
|
||||||
"name": name, "address": address})
|
if not address or version not in (4, 6):
|
||||||
|
return jsonify({"error": "missing or invalid 'address' field"}), 400
|
||||||
return _with_router(_update)
|
if action not in ("add", "remove"):
|
||||||
|
return jsonify({"error": "action must be 'add' or 'remove'"}), 400
|
||||||
return app
|
|
||||||
|
def _update(fw):
|
||||||
|
path = "/ip" if version == 4 else "/ipv6"
|
||||||
def serve(host: str = WEB_HOST, port: int = WEB_PORT) -> None:
|
if action == "add":
|
||||||
"""Run the web server on localhost at the configured high port."""
|
if version == 4:
|
||||||
app = create_app()
|
fw.write_address_list_ipv4(name, [address])
|
||||||
print(f"Intrusion Detection web UI running at http://{host}:{port}")
|
else:
|
||||||
app.run(host=host, port=port, use_reloader=False)
|
fw.write_address_list_ipv6(name, [address])
|
||||||
|
else:
|
||||||
|
fw.execute_command(
|
||||||
if __name__ == "__main__":
|
f"{path} firewall address-list remove "
|
||||||
serve()
|
f"[find list={name} address={address}]")
|
||||||
|
return jsonify({"ok": True, "action": action,
|
||||||
|
"name": name, "address": address})
|
||||||
|
|
||||||
|
return _with_router(_update)
|
||||||
@@ -1,360 +1,408 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Background scan runner for the web UI.
|
"""Background scan runner for the web UI.
|
||||||
|
|
||||||
Runs scans in a daemon thread so HTTP requests never block, keeps an
|
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
|
in-memory job store, and serializes scans with a busy lock (WHOIS and
|
||||||
whitelist refresh must not run concurrently).
|
whitelist refresh must not run concurrently).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from .constants import (
|
from ..utils.constants import (
|
||||||
APP_REPORT_DATA_DIR,
|
APP_REPORT_DATA_DIR,
|
||||||
COPYPARTY_REPORT_DIR,
|
COPYPARTY_REPORT_DIR,
|
||||||
MIKROTIK_REPORT_DIR,
|
MIKROTIK_REPORT_DIR,
|
||||||
NPM_REPORT_DIR,
|
NPM_REPORT_DIR,
|
||||||
SCAN_JOBS_MAX,
|
SCAN_JOBS_MAX,
|
||||||
SCAN_JOBS_PATH,
|
SCAN_JOBS_PATH,
|
||||||
)
|
)
|
||||||
from .copyparty import CopypartyAnalyzer
|
from ..copyparty import CopypartyAnalyzer
|
||||||
from .npm import NpmAnalyzer
|
from ..npm import NpmAnalyzer
|
||||||
from .scan import ScanParams, ScanResult, run_scan
|
from ..scan import ScanParams, ScanResult, run_scan
|
||||||
|
|
||||||
ScanKind = Literal["copyparty", "npm", "consolidated"]
|
ScanKind = Literal["copyparty", "npm", "consolidated"]
|
||||||
|
|
||||||
#: serializes scans — only one runs at a time
|
#: serializes scans — only one runs at a time
|
||||||
_scan_lock = threading.Lock()
|
_scan_lock = threading.Lock()
|
||||||
|
|
||||||
#: in-memory job store (job_id -> ScanJob)
|
#: in-memory job store (job_id -> ScanJob)
|
||||||
_jobs: dict[str, "ScanJob"] = {}
|
_jobs: dict[str, "ScanJob"] = {}
|
||||||
_jobs_lock = threading.Lock()
|
_jobs_lock = threading.Lock()
|
||||||
|
|
||||||
#: True once persisted jobs have been loaded from disk
|
#: True once persisted jobs have been loaded from disk
|
||||||
_loaded = False
|
_loaded = False
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ScanJob:
|
class ScanJob:
|
||||||
"""One background scan job."""
|
"""One background scan job."""
|
||||||
|
|
||||||
job_id: str
|
job_id: str
|
||||||
kind: ScanKind
|
kind: ScanKind
|
||||||
params: ScanParams
|
params: ScanParams
|
||||||
state: str = "queued" # queued | running | done | error
|
state: str = "queued" # queued | running | done | error
|
||||||
started: str | None = None
|
started: str | None = None
|
||||||
finished: str | None = None
|
finished: str | None = None
|
||||||
result: ScanResult | None = None
|
result: ScanResult | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
busy: bool = False
|
busy: bool = False
|
||||||
details: dict = field(default_factory=dict)
|
details: dict = field(default_factory=dict)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
result = self.result
|
result = self.result
|
||||||
return {
|
return {
|
||||||
"job_id": self.job_id,
|
"job_id": self.job_id,
|
||||||
"kind": self.kind,
|
"kind": self.kind,
|
||||||
"params": asdict(self.params),
|
"params": asdict(self.params),
|
||||||
"state": self.state,
|
"state": self.state,
|
||||||
"started": self.started,
|
"started": self.started,
|
||||||
"finished": self.finished,
|
"finished": self.finished,
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
"busy": self.busy,
|
"busy": self.busy,
|
||||||
"details": self.details,
|
"details": self.details,
|
||||||
"result": None if result is None else {
|
"result": None if result is None else {
|
||||||
"ok": result.ok,
|
"ok": result.ok,
|
||||||
"cp_exists": result.cp_exists,
|
"cp_exists": result.cp_exists,
|
||||||
"npm_exists": result.npm_exists,
|
"npm_exists": result.npm_exists,
|
||||||
"cp_candidates": result.cp_candidates,
|
"cp_candidates": result.cp_candidates,
|
||||||
"npm_candidates": result.npm_candidates,
|
"npm_candidates": result.npm_candidates,
|
||||||
"blocked_ips": result.blocked_ips,
|
"blocked_ips": result.blocked_ips,
|
||||||
"blocked_targets": result.blocked_targets,
|
"blocked_targets": result.blocked_targets,
|
||||||
"blocked_ipv4": result.blocked_ipv4,
|
"blocked_ipv4": result.blocked_ipv4,
|
||||||
"blocked_ipv6": result.blocked_ipv6,
|
"blocked_ipv6": result.blocked_ipv6,
|
||||||
"blocklist_v4_path": result.blocklist_v4_path,
|
"blocklist_v4_path": result.blocklist_v4_path,
|
||||||
"blocklist_v6_path": result.blocklist_v6_path,
|
"blocklist_v6_path": result.blocklist_v6_path,
|
||||||
"firewall_connected": result.firewall_connected,
|
"firewall_connected": result.firewall_connected,
|
||||||
"firewall_changes": result.firewall_changes,
|
"firewall_changes": result.firewall_changes,
|
||||||
"errors": result.errors,
|
"errors": result.errors,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]:
|
def start_scan(kind: ScanKind, params: ScanParams) -> dict[str, Any]:
|
||||||
"""Start a scan in the background and return its job dict.
|
"""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 another scan is already running, returns a dict with an ``error`` key.
|
||||||
"""
|
"""
|
||||||
if not _scan_lock.acquire(blocking=False):
|
if not _scan_lock.acquire(blocking=False):
|
||||||
return {"error": "a scan is already running; try again when it finishes"}
|
return {"error": "a scan is already running; try again when it finishes"}
|
||||||
|
|
||||||
job = ScanJob(job_id=uuid.uuid4().hex[:12], kind=kind,
|
job = ScanJob(job_id=uuid.uuid4().hex[:12], kind=kind,
|
||||||
params=params, busy=True)
|
params=params, busy=True)
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
_jobs[job.job_id] = job
|
_jobs[job.job_id] = job
|
||||||
_save_jobs()
|
_save_jobs()
|
||||||
|
|
||||||
def _run() -> None:
|
def _run() -> None:
|
||||||
job.started = _now()
|
job.started = _now()
|
||||||
job.state = "running"
|
job.state = "running"
|
||||||
try:
|
try:
|
||||||
if kind == "consolidated":
|
if kind == "consolidated":
|
||||||
job.result = run_scan(params)
|
job.result = run_scan(params)
|
||||||
elif kind == "copyparty":
|
elif kind == "copyparty":
|
||||||
info = CopypartyAnalyzer(
|
info = CopypartyAnalyzer(
|
||||||
log_path=params.copyparty_log_path,
|
log_path=params.copyparty_log_path,
|
||||||
report_dir=params.copyparty_report_dir,
|
report_dir=params.copyparty_report_dir,
|
||||||
top=params.top,
|
top=params.top,
|
||||||
quiet=params.quiet,
|
quiet=params.quiet,
|
||||||
full=params.full,
|
full=params.full,
|
||||||
min_denied=params.min_denied,
|
min_denied=params.min_denied,
|
||||||
).run()
|
).run()
|
||||||
job.result = ScanResult(ok=True)
|
job.result = ScanResult(ok=True)
|
||||||
job.details = {"report": info.get("report"),
|
job.details = {"report": info.get("report"),
|
||||||
"data_dir": info.get("data_dir")}
|
"data_dir": info.get("data_dir")}
|
||||||
elif kind == "npm":
|
elif kind == "npm":
|
||||||
info = NpmAnalyzer(
|
info = NpmAnalyzer(
|
||||||
log_path=params.npm_log_path,
|
log_path=params.npm_log_path,
|
||||||
report_dir=params.npm_report_dir,
|
report_dir=params.npm_report_dir,
|
||||||
top=params.top,
|
top=params.top,
|
||||||
quiet=params.quiet,
|
quiet=params.quiet,
|
||||||
full=params.full,
|
full=params.full,
|
||||||
min_errors=params.min_errors,
|
min_errors=params.min_errors,
|
||||||
error_ratio=params.error_ratio,
|
error_ratio=params.error_ratio,
|
||||||
).run()
|
).run()
|
||||||
job.result = ScanResult(ok=True)
|
job.result = ScanResult(ok=True)
|
||||||
job.details = {"report": info.get("report"),
|
job.details = {"report": info.get("report"),
|
||||||
"data_dir": info.get("data_dir")}
|
"data_dir": info.get("data_dir")}
|
||||||
else: # pragma: no cover - guarded by type hint
|
else: # pragma: no cover - guarded by type hint
|
||||||
raise ValueError(f"unknown scan kind: {kind}")
|
raise ValueError(f"unknown scan kind: {kind}")
|
||||||
job.state = "done"
|
job.state = "done"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
job.error = str(e)
|
job.error = str(e)
|
||||||
job.state = "error"
|
job.state = "error"
|
||||||
finally:
|
finally:
|
||||||
job.finished = _now()
|
job.finished = _now()
|
||||||
job.busy = False
|
job.busy = False
|
||||||
_scan_lock.release()
|
_scan_lock.release()
|
||||||
_save_jobs()
|
_save_jobs()
|
||||||
|
|
||||||
threading.Thread(target=_run, daemon=True,
|
threading.Thread(target=_run, daemon=True,
|
||||||
name=f"scan-{job.job_id}").start()
|
name=f"scan-{job.job_id}").start()
|
||||||
return job.to_dict()
|
return job.to_dict()
|
||||||
|
|
||||||
|
|
||||||
def get_scan(job_id: str) -> dict[str, Any] | None:
|
def get_scan(job_id: str) -> dict[str, Any] | None:
|
||||||
"""Return a single job as a dict, or None if unknown."""
|
"""Return a single job as a dict, or None if unknown."""
|
||||||
load_persisted()
|
load_persisted()
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
job = _jobs.get(job_id)
|
job = _jobs.get(job_id)
|
||||||
return job.to_dict() if job else None
|
return job.to_dict() if job else None
|
||||||
|
|
||||||
|
|
||||||
def _iso_key(value: str | None) -> float:
|
def _iso_key(value: str | None) -> float:
|
||||||
"""ISO-8601 string -> epoch seconds (0.0 when unparseable/empty)."""
|
"""ISO-8601 string -> epoch seconds (0.0 when unparseable/empty)."""
|
||||||
if not value:
|
if not value:
|
||||||
return 0.0
|
return 0.0
|
||||||
try:
|
try:
|
||||||
return datetime.fromisoformat(value).timestamp()
|
return datetime.fromisoformat(value).timestamp()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def latest_file_scan() -> dict[str, Any] | None:
|
def latest_file_scan() -> dict[str, Any] | None:
|
||||||
"""Build a ``done`` scan entry from the newest report files on disk.
|
"""Build a ``done`` scan entry from the newest report files on disk.
|
||||||
|
|
||||||
Lets the dashboard show the latest completed scan even when it was not
|
Lets the dashboard show the latest completed scan even when it was not
|
||||||
run through the web UI (server restart, CLI-run scans). Returns None when
|
run through the web UI (server restart, CLI-run scans). Returns None when
|
||||||
no scan outputs exist yet.
|
no scan outputs exist yet.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def newest(dirpath: str, suffix: str = "") -> str | None:
|
def newest(dirpath: str, suffix: str = "") -> str | None:
|
||||||
"""Newest file in ``dirpath`` (optionally ending in ``suffix``)."""
|
"""Newest file in ``dirpath`` (optionally ending in ``suffix``)."""
|
||||||
try:
|
try:
|
||||||
names = [n for n in os.listdir(dirpath)
|
names = [n for n in os.listdir(dirpath)
|
||||||
if os.path.isfile(os.path.join(dirpath, n))
|
if os.path.isfile(os.path.join(dirpath, n))
|
||||||
and (not suffix or n.endswith(suffix))]
|
and (not suffix or n.endswith(suffix))]
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
return os.path.join(dirpath, max(names)) if names else None
|
return os.path.join(dirpath, max(names)) if names else None
|
||||||
|
|
||||||
mikrotik = newest(MIKROTIK_REPORT_DIR, ".json")
|
mikrotik = newest(MIKROTIK_REPORT_DIR, ".json")
|
||||||
cp_report = newest(COPYPARTY_REPORT_DIR, ".txt")
|
cp_report = newest(COPYPARTY_REPORT_DIR, ".txt")
|
||||||
npm_report = newest(NPM_REPORT_DIR, ".txt")
|
npm_report = newest(NPM_REPORT_DIR, ".txt")
|
||||||
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")
|
||||||
have_outputs = (mikrotik is not None or cp_report is not None
|
have_outputs = (mikrotik is not None or cp_report is not None
|
||||||
or npm_report is not None
|
or npm_report is not None
|
||||||
or os.path.exists(v4_path) or os.path.exists(v6_path))
|
or os.path.exists(v4_path) or os.path.exists(v6_path))
|
||||||
if not have_outputs:
|
if not have_outputs:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Timestamp: prefer the newest snapshot's collected_at, else the newest
|
# Timestamp: prefer the newest snapshot's collected_at, else the newest
|
||||||
# report file's mtime, else now.
|
# report file's mtime, else now.
|
||||||
timestamp: str | None = None
|
timestamp: str | None = None
|
||||||
if mikrotik:
|
if mikrotik:
|
||||||
try:
|
try:
|
||||||
with open(mikrotik, "r", encoding="utf-8") as f:
|
with open(mikrotik, "r", encoding="utf-8") as f:
|
||||||
timestamp = json.load(f).get("collected_at")
|
timestamp = json.load(f).get("collected_at")
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
timestamp = None
|
timestamp = None
|
||||||
if not timestamp:
|
if not timestamp:
|
||||||
candidates = [p for p in (mikrotik, cp_report, npm_report,
|
candidates = [p for p in (mikrotik, cp_report, npm_report,
|
||||||
v4_path, v6_path)
|
v4_path, v6_path)
|
||||||
if p and os.path.exists(p)]
|
if p and os.path.exists(p)]
|
||||||
if candidates:
|
if candidates:
|
||||||
tz = datetime.now().astimezone().tzinfo
|
tz = datetime.now().astimezone().tzinfo
|
||||||
timestamp = datetime.fromtimestamp(
|
timestamp = datetime.fromtimestamp(
|
||||||
max(os.path.getmtime(p) for p in candidates),
|
max(os.path.getmtime(p) for p in candidates),
|
||||||
tz=tz).isoformat(timespec="seconds")
|
tz=tz).isoformat(timespec="seconds")
|
||||||
if not timestamp:
|
if not timestamp:
|
||||||
timestamp = _now()
|
timestamp = _now()
|
||||||
|
|
||||||
blocked_targets = 0
|
blocked_targets = 0
|
||||||
for path in (v4_path, v6_path):
|
for path in (v4_path, v6_path):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
blocked_targets += sum(1 for _ in f)
|
blocked_targets += sum(1 for _ in f)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"job_id": "files-latest",
|
"job_id": "files-latest",
|
||||||
"kind": "consolidated",
|
"kind": "consolidated",
|
||||||
"state": "done",
|
"state": "done",
|
||||||
"started": timestamp,
|
"started": timestamp,
|
||||||
"finished": timestamp,
|
"finished": timestamp,
|
||||||
"error": None,
|
"error": None,
|
||||||
"busy": False,
|
"busy": False,
|
||||||
"details": {},
|
"details": {},
|
||||||
"result": {
|
"result": {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"cp_exists": cp_report is not None,
|
"cp_exists": cp_report is not None,
|
||||||
"npm_exists": npm_report is not None,
|
"npm_exists": npm_report is not None,
|
||||||
"cp_candidates": 0,
|
"cp_candidates": 0,
|
||||||
"npm_candidates": 0,
|
"npm_candidates": 0,
|
||||||
"blocked_ips": blocked_targets,
|
"blocked_ips": blocked_targets,
|
||||||
"blocked_targets": blocked_targets,
|
"blocked_targets": blocked_targets,
|
||||||
"blocked_ipv4": [],
|
"blocked_ipv4": [],
|
||||||
"blocked_ipv6": [],
|
"blocked_ipv6": [],
|
||||||
"blocklist_v4_path": v4_path,
|
"blocklist_v4_path": v4_path,
|
||||||
"blocklist_v6_path": v6_path,
|
"blocklist_v6_path": v6_path,
|
||||||
"firewall_connected": mikrotik is not None,
|
"firewall_connected": mikrotik is not None,
|
||||||
"firewall_changes": None,
|
"firewall_changes": None,
|
||||||
"errors": [],
|
"errors": [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def latest_scans(limit: int = 10) -> list[dict[str, Any]]:
|
def _db_scans(limit: int = 10) -> list[dict[str, Any]]:
|
||||||
"""Return the most recently started jobs, newest first.
|
"""Recent scan records from the SQLite ``scans`` table (S001).
|
||||||
|
|
||||||
Appends a file-derived "latest scan" entry when the newest report files on
|
Converts DB rows into the web job-dict shape so the dashboard can surface
|
||||||
disk reflect a scan newer than any known job (e.g. after a restart or a
|
scans that were persisted by ``run_scan`` / analyzers even if they were
|
||||||
CLI run), so the dashboard always shows the latest completed scan.
|
never run through the in-memory job store (e.g. CLI scans or restarts).
|
||||||
"""
|
Returns [] when the DB is unavailable.
|
||||||
load_persisted()
|
"""
|
||||||
with _jobs_lock:
|
try:
|
||||||
jobs = sorted(_jobs.values(),
|
from ..db import get_connection
|
||||||
key=lambda j: j.started or "", reverse=True)
|
from ..db import repositories as repo
|
||||||
entries = [j.to_dict() for j in jobs]
|
conn = get_connection()
|
||||||
file_entry = latest_file_scan()
|
rows = repo.latest_scans(conn, limit=limit)
|
||||||
if file_entry is not None:
|
except Exception:
|
||||||
newest = jobs[0] if jobs else None
|
return []
|
||||||
newest_ts = _iso_key(
|
out = []
|
||||||
(newest.finished or newest.started) if newest else None)
|
for r in rows:
|
||||||
# Only surface the file-derived entry when it is genuinely newer than
|
try:
|
||||||
# every known job (tolerance avoids duplicating the run that produced
|
result_json = r.get("result_json")
|
||||||
# it, where collected_at lands a moment after the job finished).
|
result = json.loads(result_json) if result_json else {}
|
||||||
if newest_ts == 0 or _iso_key(file_entry["started"]) - newest_ts > 2.0:
|
except (TypeError, ValueError):
|
||||||
entries.append(file_entry)
|
result = {}
|
||||||
entries.sort(key=lambda e: _iso_key(e.get("started")), reverse=True)
|
out.append({
|
||||||
return entries[:limit]
|
"job_id": f"db-scan-{r['id']}",
|
||||||
|
"kind": r.get("kind") or "consolidated",
|
||||||
|
"state": r.get("status") or "done",
|
||||||
# ---------------------------------------------------------------------------
|
"started": r.get("started_at"),
|
||||||
# persistence
|
"finished": r.get("finished_at"),
|
||||||
# ---------------------------------------------------------------------------
|
"error": r.get("error"),
|
||||||
|
"busy": False,
|
||||||
def _save_jobs() -> None:
|
"details": {},
|
||||||
"""Persist the newest jobs to ``SCAN_JOBS_PATH`` (atomic tmp+rename)."""
|
"result": result or None,
|
||||||
with _jobs_lock:
|
})
|
||||||
jobs = sorted(_jobs.values(),
|
return out
|
||||||
key=lambda j: j.started or "", reverse=True)
|
|
||||||
payload = [j.to_dict() for j in jobs[:SCAN_JOBS_MAX]]
|
|
||||||
os.makedirs(os.path.dirname(SCAN_JOBS_PATH), exist_ok=True)
|
def latest_scans(limit: int = 10) -> list[dict[str, Any]]:
|
||||||
tmp = SCAN_JOBS_PATH + ".tmp"
|
"""Return the most recently started jobs, newest first.
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(payload, f, indent=2, sort_keys=True)
|
Merges three sources, newest-first:
|
||||||
os.replace(tmp, SCAN_JOBS_PATH)
|
1. the in-memory job store (currently running / last-run jobs),
|
||||||
|
2. the SQLite ``scans`` table (persisted by run_scan/analyzers — S001),
|
||||||
|
3. a file-derived "latest scan" fallback for pre-DB runs.
|
||||||
def _load_jobs() -> None:
|
Any DB-backed scan not already represented in memory is appended so the
|
||||||
"""Rebuild the in-memory job store from ``SCAN_JOBS_PATH``."""
|
dashboard shows scans from CLI runs and across restarts.
|
||||||
try:
|
"""
|
||||||
with open(SCAN_JOBS_PATH, "r", encoding="utf-8") as f:
|
load_persisted()
|
||||||
payload = json.load(f)
|
with _jobs_lock:
|
||||||
except (OSError, ValueError, TypeError):
|
jobs = sorted(_jobs.values(),
|
||||||
payload = []
|
key=lambda j: j.started or "", reverse=True)
|
||||||
if not isinstance(payload, list):
|
entries = [j.to_dict() for j in jobs]
|
||||||
return
|
|
||||||
|
# Add DB scan records that aren't already in the in-memory store. Key on
|
||||||
jobs: dict[str, ScanJob] = {}
|
# (kind, started_at) to avoid duplicating a job that run_scan also logged.
|
||||||
for item in payload:
|
known = {(e.get("kind"), e.get("started")) for e in entries}
|
||||||
if not isinstance(item, dict):
|
for db_row in _db_scans(limit=limit * 2):
|
||||||
continue
|
if (db_row.get("kind"), db_row.get("started")) not in known:
|
||||||
try:
|
entries.append(db_row)
|
||||||
params = ScanParams(**item.get("params", {}))
|
|
||||||
except (TypeError, ValueError):
|
file_entry = latest_file_scan()
|
||||||
continue
|
if file_entry is not None:
|
||||||
try:
|
newest = (entries or [None])[0]
|
||||||
job = ScanJob(
|
newest_key = (
|
||||||
job_id=str(item["job_id"]),
|
(newest.get("finished") or newest.get("started")) if newest else None)
|
||||||
kind=item.get("kind", "consolidated"),
|
newest_ts = _iso_key(newest_key)
|
||||||
params=params,
|
# Only surface the file-derived entry when it is genuinely newer than
|
||||||
state=item.get("state", "done"),
|
# every known job (tolerance avoids duplicating the run that produced
|
||||||
started=item.get("started"),
|
# it, where collected_at lands a moment after the job finished).
|
||||||
finished=item.get("finished"),
|
if newest_ts == 0 or _iso_key(file_entry["started"]) - newest_ts > 2.0:
|
||||||
error=item.get("error"),
|
entries.append(file_entry)
|
||||||
busy=False,
|
entries.sort(key=lambda e: _iso_key(e.get("started")), reverse=True)
|
||||||
details=item.get("details", {}) or {},
|
return entries[:limit]
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
continue
|
# ---------------------------------------------------------------------------
|
||||||
result = item.get("result")
|
# persistence
|
||||||
if isinstance(result, dict):
|
# ---------------------------------------------------------------------------
|
||||||
try:
|
|
||||||
job.result = ScanResult(**result)
|
def _save_jobs() -> None:
|
||||||
except TypeError:
|
"""Persist the newest jobs to ``SCAN_JOBS_PATH`` (atomic tmp+rename)."""
|
||||||
job.result = None
|
with _jobs_lock:
|
||||||
jobs[job.job_id] = job
|
jobs = sorted(_jobs.values(),
|
||||||
|
key=lambda j: j.started or "", reverse=True)
|
||||||
with _jobs_lock:
|
payload = [j.to_dict() for j in jobs[:SCAN_JOBS_MAX]]
|
||||||
_jobs.clear()
|
os.makedirs(os.path.dirname(SCAN_JOBS_PATH), exist_ok=True)
|
||||||
_jobs.update(jobs)
|
tmp = SCAN_JOBS_PATH + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2, sort_keys=True)
|
||||||
def load_persisted() -> None:
|
os.replace(tmp, SCAN_JOBS_PATH)
|
||||||
"""Load persisted jobs from disk once (idempotent)."""
|
|
||||||
global _loaded
|
|
||||||
if _loaded:
|
def _load_jobs() -> None:
|
||||||
return
|
"""Rebuild the in-memory job store from ``SCAN_JOBS_PATH``."""
|
||||||
_load_jobs()
|
try:
|
||||||
_loaded = True
|
with open(SCAN_JOBS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
payload = json.load(f)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
def is_scan_running() -> bool:
|
payload = []
|
||||||
"""True if any job currently holds the scan lock."""
|
if not isinstance(payload, list):
|
||||||
return _scan_lock.locked()
|
return
|
||||||
|
|
||||||
|
jobs: dict[str, ScanJob] = {}
|
||||||
|
for item in payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
params = ScanParams(**item.get("params", {}))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = ScanJob(
|
||||||
|
job_id=str(item["job_id"]),
|
||||||
|
kind=item.get("kind", "consolidated"),
|
||||||
|
params=params,
|
||||||
|
state=item.get("state", "done"),
|
||||||
|
started=item.get("started"),
|
||||||
|
finished=item.get("finished"),
|
||||||
|
error=item.get("error"),
|
||||||
|
busy=False,
|
||||||
|
details=item.get("details", {}) or {},
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
result = item.get("result")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
try:
|
||||||
|
job.result = ScanResult(**result)
|
||||||
|
except TypeError:
|
||||||
|
job.result = None
|
||||||
|
jobs[job.job_id] = job
|
||||||
|
|
||||||
|
with _jobs_lock:
|
||||||
|
_jobs.clear()
|
||||||
|
_jobs.update(jobs)
|
||||||
|
|
||||||
|
|
||||||
|
def load_persisted() -> None:
|
||||||
|
"""Load persisted jobs from disk once (idempotent)."""
|
||||||
|
global _loaded
|
||||||
|
if _loaded:
|
||||||
|
return
|
||||||
|
_load_jobs()
|
||||||
|
_loaded = True
|
||||||
|
|
||||||
|
|
||||||
|
def is_scan_running() -> bool:
|
||||||
|
"""True if any job currently holds the scan lock."""
|
||||||
|
return _scan_lock.locked()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Shell script to be run as cron job to copy npm logs to backup location.
|
||||||
|
|
||||||
|
# Stop npm service to ensure logs are not being written to while copying.
|
||||||
|
systemctl stop npm
|
||||||
|
|
||||||
|
# Only copy new/changed log files to avoid re-copying existing backups.
|
||||||
|
# --ignore-existing: never overwrite files already present in the destination.
|
||||||
|
# -c: compare files by checksum so matching files are not re-written.
|
||||||
|
# -P: preserve permissions + show progress.
|
||||||
|
rsync -qcP --ignore-existing /data/logs/*access* /data/logs/*error* /mnt/backups/npm/
|
||||||
|
|
||||||
|
# Start npm service again after copying logs.
|
||||||
|
systemctl start npm
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Data directory for allucanget-ids
|
|
||||||
|
|
||||||
This directory contains data files used by the `allucanget-ids` Intrusion Detection System application, including whitelists, blocklists, and other configuration files. The data files are organized into subdirectories for easier management and maintenance.
|
|
||||||
|
|
||||||
## Data files
|
|
||||||
|
|
||||||
- [whois cache](./whois_cache.json): Local cache of WHOIS queries to reduce network requests and improve performance. The cache is automatically updated when new IP addresses are queried.
|
|
||||||
- [Cloudflare IPv4 list](./cloudflare/ips-v4.txt): Contains a list of Cloudflare IPv4 addresses from `https://www.cloudflare.com/ips-v4`, used for whitelisting traffic from Cloudflare services.
|
|
||||||
- [Cloudflare IPv6 list](./cloudflare/ips-v6.txt): Contains a list of Cloudflare IPv6 addresses from `https://www.cloudflare.com/ips-v6`, used for whitelisting traffic from Cloudflare services.
|
|
||||||
- [MikroTik RouterOS IPv4 Firewall rules](./mikrotik/firewall_ipv4.json): Current MikroTik RouterOS IPv4 firewall rules.
|
|
||||||
- [MikroTik RouterOS IPv6 Firewall rules](./mikrotik/firewall_ipv6.json): Current MikroTik RouterOS IPv6 firewall rules.
|
|
||||||
- [MikroTik RouterOS IPv4 address list](./mikrotik/address_list_ipv4.json): Current MikroTik RouterOS IPv4 address list.
|
|
||||||
- [MikroTik RouterOS IPv6 address list](./mikrotik/address_list_ipv6.json): Current MikroTik RouterOS IPv6 address list.
|
|
||||||
- [Openfactory Geofeed](./openfactory/geofeed.csv): Contains Openfactory IP addresses and associated geolocation data from `https://openfactory.net/geofeed.csv`. Used for identifying and whitelisting traffic from known Openfactory sources.
|
|
||||||
- [PaloAlto Scanner IP list](./paloalto/scanner-ips.txt): List of known PaloAlto scanner IP addresses from `https://docs-cortex.paloaltonetworks.com/r/1/Cortex-Xpanse/Scanning-activity`, used for whitelisting traffic from PaloAlto scanners. !NOTE: This list can not be scraped easily (Javascript-based), so it is maintained manually and may be outdated. Please verify the list against the official source if needed.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
$ = cloudflare
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
2400:cb00::/32
|
|
||||||
2606:4700::/32
|
|
||||||
2803:f800::/32
|
|
||||||
2405:b500::/32
|
|
||||||
2405:8100::/32
|
|
||||||
2a06:98c0::/29
|
|
||||||
2c0f:f248::/32
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,353 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"address": "::/128",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "::1/128",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "fec0::/10",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "::ffff:0.0.0.0/96",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "::/96",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "100::/64",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2001:db8::/32",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2001:10::/28",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "3ffe::/16",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"creation-time": "2025-11-21",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a0e:8f04::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a0e:8f05::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a0e:8f06::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a0e:8f07::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2080::/48",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2001:67c:2648::/48",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2044:200::/56",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2044:600::/56",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2044:800::/56",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2400:cb00::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2606:4700::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2803:f800::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2405:b500::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2405:8100::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a06:98c0::/29",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2c0f:f248::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2604:a940:300:5b6::/64",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "PaloAlto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2604:a940:301:225::/64",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "PaloAlto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2604:a940:302:118::/64",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "PaloAlto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2001:4ca0:108:42::24/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2400:8901::2000:62ff:fe9a:e1c2/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2600:3000::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2600:3c09::2000:5cff:fe99:c802/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2600:3c15::2000:53ff:fe31:8c0b/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2602:fa59:10:a3a::1/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a01:2a8:a0dd:1::1012/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a04:4e40:e000:0:d09:97ab:43b1:a59a/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a04:c300::/29",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a09:bac5:9443:3af::5e:4d/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a09:bac5:9443:3af::5e:88/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a0a:4cc0:2000:8528:c4e8:3ff:febb:3ee9/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2001:ba0:24c:a500::1/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2406:da18:525:0:a23e:e8f6:3ea1:4c2c/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2600:3c15::2000:9cff:fef0:8ad6/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2602:fa59::/40",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2008:ec80:64cc:9116:beb2:1931/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2008:ec80:be24:11ff:fe3b:4058/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a00:6340:2008:ec80:be24:11ff:fe82:2775/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a01:4f8:c014:7f9e::1/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a04:4e40:4400:0:537:cdd2:7250:95db/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a04:4e40:e000::/48",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a05:4140:3::37cd/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a06:a880:5:7fb3::1/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a09:bac1:76c0:460::5e:6a/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a09:bac5::/32",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2a13:adc0::2ed:89ff:fe58:19c5/128",
|
|
||||||
"creation-time": "2026-08-03",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"address": "2400:8901::2000:beff:fea3:5a44/128",
|
|
||||||
"creation-time": "2026-08-05",
|
|
||||||
"dynamic": "no",
|
|
||||||
"list": "Blocked"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"action": "passthrough",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "special"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "fasttrack-connection",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "established,related",
|
|
||||||
"hw-offload": "yes",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "established,related",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "invalid",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "Allow",
|
|
||||||
"connection-nat-state": "dstnat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "allow",
|
|
||||||
"connection-state": "new",
|
|
||||||
"dst-address": "192.168.88.25",
|
|
||||||
"dst-port": "6667",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": "",
|
|
||||||
"protocol": "tcp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-nat-state": "!dstnat",
|
|
||||||
"connection-state": "new",
|
|
||||||
"in-interface-list": "WAN",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": "",
|
|
||||||
"protocol": "icmp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"connection-state": "established",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "allow",
|
|
||||||
"dst-port": "500",
|
|
||||||
"protocol": "udp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "Block",
|
|
||||||
"dst-port": "80,443",
|
|
||||||
"in-interface-list": "WAN",
|
|
||||||
"protocol": "tcp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "Allow",
|
|
||||||
"dst-port": "80,443",
|
|
||||||
"in-interface-list": "LAN",
|
|
||||||
"protocol": "tcp",
|
|
||||||
"src-address": "192.168.88.0/24"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "PaloAlto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "drop",
|
|
||||||
"in-interface": "pppoe-out1",
|
|
||||||
"log": "no",
|
|
||||||
"log-prefix": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "established,related,untracked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "invalid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "icmpv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"port": "33434-33534",
|
|
||||||
"protocol": "udp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"dst-port": "546",
|
|
||||||
"protocol": "udp",
|
|
||||||
"src-address": "fe80::/10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"dst-port": "500,4500",
|
|
||||||
"protocol": "udp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "ipsec-ah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "ipsec-esp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"ipsec-policy": "in,ipsec"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"in-interface-list": "!LAN"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "established,related,untracked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"connection-state": "invalid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"src-address-list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"dst-address-list": "bad_ipv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"hop-limit": "equal:1",
|
|
||||||
"protocol": "icmpv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "icmpv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "139"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"dst-port": "500,4500",
|
|
||||||
"protocol": "udp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "ipsec-ah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"protocol": "ipsec-esp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"ipsec-policy": "in,ipsec"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "defconf:",
|
|
||||||
"in-interface-list": "!LAN"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "Allow",
|
|
||||||
"protocol": "icmpv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "Allow",
|
|
||||||
"protocol": "icmpv6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Openfactory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Cloudflare"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "PaloAlto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "input",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Blocked"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "drop",
|
|
||||||
"chain": "forward",
|
|
||||||
"comment": "ids",
|
|
||||||
"src-address-list": "Blocked"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
$ = openfactory
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
45.11.248.0/23,DE,DE-HE,Frankfurt Am Main,
|
|
||||||
45.11.250.0/24,NL,NL-NH,Amsterdam,
|
|
||||||
45.11.251.0/24,AT,AT-9,Vienna,
|
|
||||||
2a0e:8f04::/32,DE,DE-HE,Frankfurt Am Main,
|
|
||||||
2a0e:8f05::/32,GB,GB-LND,London,
|
|
||||||
2a0e:8f06::/32,NL,NL-NH,Amsterdam,
|
|
||||||
2a0e:8f07::/32,AT,AT-9,Vienna,
|
|
||||||
5.226.144.0/21,CH,CH-TG,Frauenfeld,
|
|
||||||
5.226.144.192/26,CH,CH-ZH,Winterthur,
|
|
||||||
5.226.145.0/26,CH,CH-ZH,Wangen-Bruettisellen,
|
|
||||||
5.226.145.64/26,LI,LI-07,Schaan,
|
|
||||||
5.226.145.128/25,CH,CH-ZH,Kloten,
|
|
||||||
5.226.148.0/29,CH,CH-ZH,Zurich,
|
|
||||||
5.226.148.8/29,CH,CH-ZH,Kloten,
|
|
||||||
5.226.148.16/28,CH,CH-ZH,Zurich,
|
|
||||||
5.226.148.32/27,CH,CH-ZH,Kloten,
|
|
||||||
5.226.148.64/26,CH,CH-ZH,Zurich,
|
|
||||||
5.226.148.96/27,CH,CH-ZH,Zurich,
|
|
||||||
5.226.148.128/25,CH,CH-ZH,Zurich,
|
|
||||||
5.226.149.0/26,CH,CH-ZH,Zurich,
|
|
||||||
5.226.149.64/28,CH,CH-LU,Lutzern,
|
|
||||||
5.226.149.128/26,CH,CH-ZH,Glattfelden,
|
|
||||||
5.226.149.240/30,CH,CH-SZ,Wollerau,
|
|
||||||
5.226.150.0/27,CH,CH-ZH,Glattfelden,
|
|
||||||
5.226.150.32/27,CH,CH-BE,Biel,
|
|
||||||
5.226.150.152/29,LI,LI-07,Schaan,
|
|
||||||
5.226.150.160/28,CH,CH-ZH,Winterthur,
|
|
||||||
5.226.150.224/27,CH,CH-BE,Bern,
|
|
||||||
5.226.151.0/26,NL,NL-NH,Amsterdam,
|
|
||||||
5.226.151.64/27,CH,CH-ZH,Ruemlang,
|
|
||||||
185.86.228.0/23,CH,CH-ZH,Zurich,
|
|
||||||
195.138.203.0/24,CH,CH-ZH,Zurich,
|
|
||||||
2a00:6340::/32,CH,CH-TG,Frauenfeld,
|
|
||||||
2a00:6340:2080::/48,NL,NL-NH,Amsterdam,
|
|
||||||
2001:67c:2648::/48,CH,CH-ZH,Winterthur,
|
|
||||||
|
|
||||||
# Customer geofeed details on request
|
|
||||||
# Having issues with your geolocation? send us a ticket!
|
|
||||||
|
|
||||||
45.11.248.94/32,DE,DE-NW,Dortmund,
|
|
||||||
2a00:6340:2044:200::/56,DE,DE-NW,Dortmund,
|
|
||||||
2a00:6340:2044:600::/56,DE,DE-BW,Stuttgart,
|
|
||||||
45.11.248.96/32,DE,DE-BW,Stuttgart,
|
|
||||||
45.11.248.37/32,DE,DE-BE,Berlin,
|
|
||||||
2a00:6340:2044:800::/56,DE,DE-BE,Berlin,
|
|
||||||
5.226.146.49/32,CH,CH-ZH,Winterthur,8405,47.472502,8.779930
|
|
||||||
|
@@ -1 +0,0 @@
|
|||||||
$ = paloalto
|
|
||||||
@@ -1,974 +0,0 @@
|
|||||||
{
|
|
||||||
"185.177.72.31": "185.177.72.0/24",
|
|
||||||
"195.178.110.155": "195.178.110.0/24",
|
|
||||||
"66.249.74.165": "66.249.64.0/20",
|
|
||||||
"64.225.75.246": "64.225.64.0/20",
|
|
||||||
"193.19.109.151": "193.19.109.0/24",
|
|
||||||
"92.118.39.126": "92.118.39.0/24",
|
|
||||||
"54.179.129.22": "54.179.128.0/18",
|
|
||||||
"20.220.227.237": "20.192.0.0/10",
|
|
||||||
"199.16.157.180": "199.16.157.0/24",
|
|
||||||
"74.249.245.134": "74.248.0.0/15",
|
|
||||||
"136.107.208.33": "136.107.128.0/17",
|
|
||||||
"104.28.222.131": "104.28.222.0/24",
|
|
||||||
"45.148.10.62": "45.148.10.0/24",
|
|
||||||
"136.70.149.147": "136.70.128.0/17",
|
|
||||||
"158.158.32.229": "158.158.0.0/16",
|
|
||||||
"216.73.216.244": "216.73.216.0/22",
|
|
||||||
"211.175.22.56": "211.175.0.0/16",
|
|
||||||
"8.229.130.50": "8.229.0.0/16",
|
|
||||||
"107.170.9.103": "107.170.0.0/17",
|
|
||||||
"192.34.61.236": "192.34.56.0/21",
|
|
||||||
"194.5.82.61": "194.5.82.0/24",
|
|
||||||
"194.5.82.104": "194.5.82.0/24",
|
|
||||||
"104.28.247.220": "104.28.247.0/24",
|
|
||||||
"64.23.218.208": "64.23.192.0/19",
|
|
||||||
"157.245.204.205": "157.245.192.0/20",
|
|
||||||
"54.169.103.174": "54.169.0.0/17",
|
|
||||||
"20.199.183.73": "20.192.0.0/10",
|
|
||||||
"52.238.192.78": "52.224.0.0/11",
|
|
||||||
"199.16.157.182": "199.16.157.0/24",
|
|
||||||
"20.226.90.242": "20.192.0.0/10",
|
|
||||||
"168.144.106.212": "168.144.96.0/20",
|
|
||||||
"34.175.82.8": "34.175.0.0/17",
|
|
||||||
"20.170.16.216": "20.160.0.0/12",
|
|
||||||
"65.0.122.218": "65.0.0.0/14",
|
|
||||||
"51.116.233.22": "51.116.0.0/16",
|
|
||||||
"31.58.51.184": "31.58.51.0/24",
|
|
||||||
"191.235.102.147": "191.232.0.0/13",
|
|
||||||
"20.9.44.210": "20.0.0.0/11",
|
|
||||||
"123.160.175.254": "123.160.0.0/14",
|
|
||||||
"195.178.110.135": "195.178.110.0/24",
|
|
||||||
"45.61.187.30": "45.61.184.0/22",
|
|
||||||
"8.231.78.19": "8.231.64.0/18",
|
|
||||||
"20.226.114.112": "20.192.0.0/10",
|
|
||||||
"146.190.63.48": "146.190.32.0/19",
|
|
||||||
"216.73.217.54": "216.73.216.0/22",
|
|
||||||
"3.107.107.18": "3.104.0.0/14",
|
|
||||||
"69.164.220.85": "69.164.208.0/20",
|
|
||||||
"191.235.105.17": "191.232.0.0/13",
|
|
||||||
"139.28.219.70": "139.28.219.0/24",
|
|
||||||
"185.177.72.49": "185.177.72.0/24",
|
|
||||||
"20.206.67.15": "20.192.0.0/10",
|
|
||||||
"35.237.178.50": "35.237.0.0/16",
|
|
||||||
"158.158.36.150": "158.158.0.0/16",
|
|
||||||
"136.64.51.224": "136.64.0.0/15",
|
|
||||||
"94.154.43.186": "94.154.43.0/24",
|
|
||||||
"3.101.54.4": "3.101.0.0/16",
|
|
||||||
"23.234.98.171": "23.234.64.0/18",
|
|
||||||
"45.67.85.190": "45.67.85.0/24",
|
|
||||||
"199.16.157.181": "199.16.157.0/24",
|
|
||||||
"93.123.109.165": "93.123.109.0/24",
|
|
||||||
"20.79.29.209": "20.64.0.0/10",
|
|
||||||
"162.141.167.14": "162.141.167.0/24",
|
|
||||||
"163.7.11.71": "163.7.11.0/24",
|
|
||||||
"66.187.6.102": "66.187.6.0/24",
|
|
||||||
"45.153.34.16": "45.153.34.0/24",
|
|
||||||
"74.7.227.43": "74.7.0.0/16",
|
|
||||||
"188.166.245.173": "188.166.240.0/20",
|
|
||||||
"72.146.20.230": "72.144.0.0/14",
|
|
||||||
"216.73.217.145": "216.73.216.0/22",
|
|
||||||
"158.158.76.106": "158.158.0.0/16",
|
|
||||||
"44.248.34.34": "44.224.0.0/11",
|
|
||||||
"4.194.24.143": "4.192.0.0/12",
|
|
||||||
"124.88.113.10": "124.88.0.0/16",
|
|
||||||
"172.213.244.85": "172.208.0.0/13",
|
|
||||||
"170.64.144.116": "170.64.128.0/18",
|
|
||||||
"143.244.57.120": "143.244.56.0/23",
|
|
||||||
"135.119.63.61": "135.119.0.0/16",
|
|
||||||
"45.154.98.52": "45.154.98.0/24",
|
|
||||||
"112.94.252.15": "112.94.128.0/17",
|
|
||||||
"2600:3c09::2000:5cff:fe99:c802": "2600:3c09::/32",
|
|
||||||
"35.243.173.211": "35.243.160.0/20",
|
|
||||||
"206.189.233.36": "206.189.224.0/20",
|
|
||||||
"45.148.10.95": "45.148.10.0/24",
|
|
||||||
"168.62.180.116": "168.62.0.0/15",
|
|
||||||
"23.180.120.134": "23.180.120.0/24",
|
|
||||||
"31.59.129.193": "31.59.129.0/24",
|
|
||||||
"203.159.90.217": "203.159.90.0/24",
|
|
||||||
"194.5.82.153": "194.5.82.0/24",
|
|
||||||
"143.244.57.121": "143.244.56.0/23",
|
|
||||||
"216.73.217.121": "216.73.216.0/22",
|
|
||||||
"91.134.35.95": "91.134.0.0/16",
|
|
||||||
"135.225.93.215": "135.224.0.0/15",
|
|
||||||
"103.221.220.62": "103.221.220.0/22",
|
|
||||||
"20.116.62.111": "20.64.0.0/10",
|
|
||||||
"2.57.122.173": "2.57.122.0/24",
|
|
||||||
"20.203.221.142": "20.192.0.0/10",
|
|
||||||
"2a04:c300:400::99": "2a04:c300::/29",
|
|
||||||
"45.148.10.67": "45.148.10.0/24",
|
|
||||||
"34.187.200.119": "34.187.128.0/17",
|
|
||||||
"4.223.65.51": "4.208.0.0/12",
|
|
||||||
"5.226.149.150": "5.226.144.0/21",
|
|
||||||
"20.220.150.193": "20.192.0.0/10",
|
|
||||||
"201.78.128.89": "201.78.128.0/24",
|
|
||||||
"34.91.243.226": "34.91.240.0/20",
|
|
||||||
"85.204.70.90": "85.204.70.0/24",
|
|
||||||
"20.100.187.246": "20.64.0.0/10",
|
|
||||||
"185.177.72.67": "185.177.72.0/24",
|
|
||||||
"180.95.231.78": "180.95.128.0/17",
|
|
||||||
"34.186.83.134": "34.186.64.0/18",
|
|
||||||
"4.223.77.241": "4.208.0.0/12",
|
|
||||||
"146.190.83.166": "146.190.80.0/20",
|
|
||||||
"3.77.92.222": "3.64.0.0/12",
|
|
||||||
"185.177.72.205": "185.177.72.0/24",
|
|
||||||
"193.47.62.168": "193.47.62.0/24",
|
|
||||||
"20.208.40.114": "20.192.0.0/10",
|
|
||||||
"20.100.175.1": "20.64.0.0/10",
|
|
||||||
"192.133.77.14": "192.133.76.0/23",
|
|
||||||
"38.248.95.248": "38.248.95.0/24",
|
|
||||||
"136.108.11.195": "136.108.0.0/16",
|
|
||||||
"123.178.210.228": "123.178.0.0/15",
|
|
||||||
"194.5.82.59": "194.5.82.0/24",
|
|
||||||
"20.197.192.193": "20.192.0.0/10",
|
|
||||||
"64.227.32.66": "64.227.32.0/20",
|
|
||||||
"132.196.11.13": "132.196.0.0/16",
|
|
||||||
"136.110.80.8": "136.110.64.0/18",
|
|
||||||
"85.204.70.116": "85.204.70.0/24",
|
|
||||||
"81.171.72.93": "81.171.72.0/24",
|
|
||||||
"118.81.207.202": "118.80.0.0/15",
|
|
||||||
"20.226.26.5": "20.192.0.0/10",
|
|
||||||
"20.203.219.190": "20.192.0.0/10",
|
|
||||||
"158.158.32.226": "158.158.0.0/16",
|
|
||||||
"20.215.211.95": "20.192.0.0/10",
|
|
||||||
"2600:3c15::2000:53ff:fe31:8c0b": "2600:3c15::/32",
|
|
||||||
"23.178.112.107": "23.178.112.0/24",
|
|
||||||
"95.85.238.18": "95.85.238.0/24",
|
|
||||||
"35.231.100.82": "35.231.0.0/17",
|
|
||||||
"108.130.204.104": "108.128.0.0/13",
|
|
||||||
"206.81.12.187": "206.81.0.0/20",
|
|
||||||
"206.189.19.19": "206.189.16.0/20",
|
|
||||||
"20.230.87.222": "20.192.0.0/10",
|
|
||||||
"58.212.237.231": "58.208.0.0/12",
|
|
||||||
"185.177.72.9": "185.177.72.0/24",
|
|
||||||
"85.204.70.114": "85.204.70.0/24",
|
|
||||||
"2a09:bac5:9443:3af::5e:88": "2a09:bac5::/32",
|
|
||||||
"94.154.43.188": "94.154.43.0/24",
|
|
||||||
"34.95.239.96": "34.95.224.0/20",
|
|
||||||
"4.204.201.85": "4.192.0.0/12",
|
|
||||||
"2a0a:4cc0:2000:8528:c4e8:3ff:febb:3ee9": "2a0a:4cc0:2000::/48",
|
|
||||||
"220.202.112.100": "220.202.96.0/19",
|
|
||||||
"20.104.96.117": "20.64.0.0/10",
|
|
||||||
"35.192.232.168": "35.192.224.0/20",
|
|
||||||
"34.59.187.134": "34.59.0.0/16",
|
|
||||||
"216.73.216.24": "216.73.216.0/22",
|
|
||||||
"139.59.118.17": "139.59.112.0/20",
|
|
||||||
"171.104.81.195": "171.104.0.0/14",
|
|
||||||
"52.184.96.217": "52.160.0.0/11",
|
|
||||||
"216.73.216.133": "216.73.216.0/22",
|
|
||||||
"199.16.157.183": "199.16.157.0/24",
|
|
||||||
"110.179.80.63": "110.176.0.0/13",
|
|
||||||
"194.5.82.141": "194.5.82.0/24",
|
|
||||||
"216.73.216.25": "216.73.216.0/22",
|
|
||||||
"104.28.247.219": "104.28.247.0/24",
|
|
||||||
"146.70.194.230": "146.70.194.0/24",
|
|
||||||
"136.109.87.37": "136.109.0.0/17",
|
|
||||||
"93.123.109.152": "93.123.109.0/24",
|
|
||||||
"23.161.169.62": "23.161.169.0/24",
|
|
||||||
"2600:3000:2710:200::86": "2600:3000::/32",
|
|
||||||
"64.89.161.85": "64.89.161.0/24",
|
|
||||||
"185.93.89.147": "185.93.89.0/24",
|
|
||||||
"45.154.138.232": "45.154.138.0/24",
|
|
||||||
"13.57.15.41": "13.57.0.0/16",
|
|
||||||
"94.154.43.185": "94.154.43.0/24",
|
|
||||||
"20.113.56.22": "20.64.0.0/10",
|
|
||||||
"209.99.189.160": "209.99.184.0/21",
|
|
||||||
"34.86.126.198": "34.86.112.0/20",
|
|
||||||
"74.7.241.170": "74.7.0.0/16",
|
|
||||||
"136.115.17.125": "136.115.0.0/16",
|
|
||||||
"103.153.183.126": "103.153.182.0/23",
|
|
||||||
"20.104.16.169": "20.64.0.0/10",
|
|
||||||
"23.178.112.213": "23.178.112.0/24",
|
|
||||||
"194.5.82.100": "194.5.82.0/24",
|
|
||||||
"20.206.89.130": "20.192.0.0/10",
|
|
||||||
"69.30.85.194": "69.30.85.0/24",
|
|
||||||
"35.95.56.230": "35.80.0.0/12",
|
|
||||||
"68.183.180.73": "68.183.176.0/20",
|
|
||||||
"213.111.158.220": "213.111.158.0/23",
|
|
||||||
"185.177.72.54": "185.177.72.0/24",
|
|
||||||
"18.227.73.7": "18.224.0.0/14",
|
|
||||||
"73.116.194.16": "73.116.0.0/16",
|
|
||||||
"146.70.194.252": "146.70.194.0/24",
|
|
||||||
"23.178.112.104": "23.178.112.0/24",
|
|
||||||
"35.237.138.104": "35.237.0.0/16",
|
|
||||||
"124.117.194.69": "124.117.0.0/16",
|
|
||||||
"20.9.85.55": "20.0.0.0/11",
|
|
||||||
"51.79.208.246": "51.79.128.0/17",
|
|
||||||
"20.196.209.159": "20.192.0.0/10",
|
|
||||||
"185.93.89.167": "185.93.89.0/24",
|
|
||||||
"45.148.10.21": "45.148.10.0/24",
|
|
||||||
"20.220.15.170": "20.192.0.0/10",
|
|
||||||
"74.248.24.145": "74.248.0.0/15",
|
|
||||||
"209.99.189.152": "209.99.184.0/21",
|
|
||||||
"45.154.98.101": "45.154.98.0/24",
|
|
||||||
"216.73.217.176": "216.73.217.176/32",
|
|
||||||
"45.134.20.116": "45.134.20.116/32",
|
|
||||||
"2a00:6340:2008:ec80:be24:11ff:fe3b:4058": "2a00:6340:2008:ec80:be24:11ff:fe3b:4058/128",
|
|
||||||
"35.199.183.190": "35.199.183.190/32",
|
|
||||||
"91.192.10.86": "91.192.10.0/24",
|
|
||||||
"45.148.10.120": "45.148.10.0/24",
|
|
||||||
"159.89.174.87": "159.89.160.0/20",
|
|
||||||
"23.178.112.219": "23.178.112.0/24",
|
|
||||||
"52.253.111.48": "52.224.0.0/11",
|
|
||||||
"51.103.152.96": "51.103.152.96/32",
|
|
||||||
"193.19.109.122": "193.19.109.0/24",
|
|
||||||
"35.243.223.147": "35.243.208.0/20",
|
|
||||||
"158.101.144.198": "158.101.128.0/19",
|
|
||||||
"149.56.150.98": "149.56.0.0/16",
|
|
||||||
"20.119.205.224": "20.64.0.0/10",
|
|
||||||
"2600:3000:2710:200::83": "2600:3000::/32",
|
|
||||||
"194.233.91.21": "194.233.88.0/22",
|
|
||||||
"185.177.72.17": "185.177.72.0/24",
|
|
||||||
"185.151.21.124": "185.151.21.0/24",
|
|
||||||
"35.196.22.146": "35.196.16.0/20",
|
|
||||||
"4.185.41.66": "4.176.0.0/12",
|
|
||||||
"192.253.248.142": "192.253.248.142/32",
|
|
||||||
"5.253.247.16": "5.253.247.0/24",
|
|
||||||
"2602:fa59:10:a3a::1": "2602:fa59::/40",
|
|
||||||
"216.73.217.13": "216.73.216.0/22",
|
|
||||||
"23.180.120.147": "23.180.120.0/24",
|
|
||||||
"161.118.251.181": "161.118.192.0/18",
|
|
||||||
"51.103.133.211": "51.103.0.0/16",
|
|
||||||
"74.248.33.172": "74.248.33.172/32",
|
|
||||||
"157.143.3.35": "157.143.0.0/17",
|
|
||||||
"20.226.60.151": "20.192.0.0/10",
|
|
||||||
"159.65.144.72": "159.65.144.0/20",
|
|
||||||
"158.158.105.63": "158.158.0.0/16",
|
|
||||||
"51.120.83.160": "51.120.0.0/16",
|
|
||||||
"185.177.72.29": "185.177.72.29/32",
|
|
||||||
"144.76.19.27": "144.76.0.0/16",
|
|
||||||
"45.45.237.214": "45.45.237.0/24",
|
|
||||||
"172.213.8.106": "172.208.0.0/13",
|
|
||||||
"45.139.104.172": "45.139.104.0/24",
|
|
||||||
"23.161.169.127": "23.161.169.0/24",
|
|
||||||
"103.215.75.19": "103.215.75.19/32",
|
|
||||||
"82.102.18.188": "82.102.18.0/24",
|
|
||||||
"36.106.166.223": "36.106.166.0/23",
|
|
||||||
"130.12.180.126": "130.12.180.0/24",
|
|
||||||
"185.177.72.38": "185.177.72.0/24",
|
|
||||||
"82.102.18.190": "82.102.18.190/32",
|
|
||||||
"172.171.198.214": "172.160.0.0/11",
|
|
||||||
"43.228.157.102": "43.228.157.0/24",
|
|
||||||
"173.249.34.197": "173.249.34.0/23",
|
|
||||||
"193.148.57.33": "193.148.57.0/24",
|
|
||||||
"192.133.77.15": "192.133.77.15/32",
|
|
||||||
"195.178.110.48": "195.178.110.0/24",
|
|
||||||
"217.217.252.197": "217.217.252.0/22",
|
|
||||||
"3.237.185.144": "3.224.0.0/12",
|
|
||||||
"216.73.217.173": "216.73.216.0/22",
|
|
||||||
"3.250.52.216": "3.250.0.0/15",
|
|
||||||
"91.239.79.146": "91.239.78.0/23",
|
|
||||||
"146.70.194.220": "146.70.194.0/24",
|
|
||||||
"123.158.61.179": "123.158.0.0/16",
|
|
||||||
"172.213.225.181": "172.208.0.0/13",
|
|
||||||
"20.215.189.213": "20.215.189.213/32",
|
|
||||||
"20.200.216.112": "20.192.0.0/10",
|
|
||||||
"74.7.230.46": "74.7.0.0/16",
|
|
||||||
"185.177.72.52": "185.177.72.0/24",
|
|
||||||
"104.28.215.219": "104.28.215.0/24",
|
|
||||||
"147.93.171.188": "147.93.171.188/32",
|
|
||||||
"91.124.88.36": "91.124.88.0/24",
|
|
||||||
"34.118.184.2": "34.118.128.0/18",
|
|
||||||
"168.144.100.227": "168.144.96.0/20",
|
|
||||||
"45.148.10.247": "45.148.10.0/24",
|
|
||||||
"94.154.43.187": "94.154.43.0/24",
|
|
||||||
"13.221.106.198": "13.221.106.198/32",
|
|
||||||
"104.28.215.220": "104.28.215.0/24",
|
|
||||||
"2a04:4e40:e000:0:d09:97ab:43b1:a59a": "2a04:4e40:e000::/48",
|
|
||||||
"194.5.82.98": "194.5.82.0/24",
|
|
||||||
"54.163.186.39": "54.163.128.0/18",
|
|
||||||
"223.199.187.171": "223.199.0.0/16",
|
|
||||||
"143.244.57.123": "143.244.57.123/32",
|
|
||||||
"167.99.181.249": "167.99.176.0/20",
|
|
||||||
"216.73.216.229": "216.73.216.0/22",
|
|
||||||
"34.47.183.174": "34.47.128.0/17",
|
|
||||||
"2600:3000:2710:200::81": "2600:3000::/32",
|
|
||||||
"43.228.157.191": "43.228.157.0/24",
|
|
||||||
"104.208.94.19": "104.208.94.19/32",
|
|
||||||
"2600:3000:2710:200::84": "2600:3000::/32",
|
|
||||||
"146.70.40.68": "146.70.40.0/24",
|
|
||||||
"2600:3000:1305:4212::84": "2600:3000::/32",
|
|
||||||
"157.245.105.107": "157.245.96.0/20",
|
|
||||||
"68.221.69.72": "68.220.0.0/15",
|
|
||||||
"195.178.110.211": "195.178.110.211/32",
|
|
||||||
"103.168.66.237": "103.168.66.0/23",
|
|
||||||
"162.243.79.174": "162.243.0.0/17",
|
|
||||||
"172.213.144.209": "172.208.0.0/13",
|
|
||||||
"138.197.191.87": "138.197.176.0/20",
|
|
||||||
"54.216.227.171": "54.216.192.0/18",
|
|
||||||
"172.213.216.126": "172.213.216.126/32",
|
|
||||||
"209.99.190.126": "209.99.184.0/21",
|
|
||||||
"134.199.148.147": "134.199.144.0/20",
|
|
||||||
"110.179.81.90": "110.176.0.0/13",
|
|
||||||
"134.209.162.128": "134.209.160.0/20",
|
|
||||||
"195.178.110.223": "195.178.110.223/32",
|
|
||||||
"93.123.109.166": "93.123.109.0/24",
|
|
||||||
"185.177.72.12": "185.177.72.0/24",
|
|
||||||
"165.245.185.158": "165.245.176.0/20",
|
|
||||||
"172.213.27.135": "172.208.0.0/13",
|
|
||||||
"77.90.185.230": "77.90.185.0/24",
|
|
||||||
"20.9.4.9": "20.0.0.0/11",
|
|
||||||
"74.248.139.103": "74.248.0.0/15",
|
|
||||||
"117.14.153.168": "117.8.0.0/13",
|
|
||||||
"45.153.34.43": "45.153.34.0/24",
|
|
||||||
"191.237.255.150": "191.232.0.0/13",
|
|
||||||
"194.5.82.92": "194.5.82.0/24",
|
|
||||||
"118.212.120.78": "118.212.0.0/16",
|
|
||||||
"20.151.205.204": "20.150.0.0/15",
|
|
||||||
"185.177.72.10": "185.177.72.0/24",
|
|
||||||
"35.230.52.27": "35.230.0.0/17",
|
|
||||||
"31.56.58.134": "31.56.58.0/24",
|
|
||||||
"20.230.47.94": "20.192.0.0/10",
|
|
||||||
"44.245.216.107": "44.224.0.0/11",
|
|
||||||
"52.167.144.160": "52.160.0.0/11",
|
|
||||||
"34.11.127.22": "34.11.0.0/17",
|
|
||||||
"85.204.70.96": "85.204.70.0/24",
|
|
||||||
"35.157.251.190": "35.156.0.0/14",
|
|
||||||
"20.100.178.92": "20.64.0.0/10",
|
|
||||||
"4.184.121.217": "4.176.0.0/12",
|
|
||||||
"35.198.180.224": "35.198.176.0/20",
|
|
||||||
"44.251.113.120": "44.224.0.0/11",
|
|
||||||
"91.230.225.189": "91.230.225.0/24",
|
|
||||||
"34.47.194.183": "34.47.128.0/17",
|
|
||||||
"82.102.18.182": "82.102.18.0/24",
|
|
||||||
"172.213.155.173": "172.208.0.0/13",
|
|
||||||
"82.102.18.180": "82.102.18.0/24",
|
|
||||||
"23.178.112.106": "23.178.112.0/24",
|
|
||||||
"5.255.119.161": "5.255.96.0/19",
|
|
||||||
"158.158.96.17": "158.158.0.0/16",
|
|
||||||
"2600:3000:1305:4212::81": "2600:3000::/32",
|
|
||||||
"192.133.77.16": "192.133.76.0/23",
|
|
||||||
"172.232.175.132": "172.232.160.0/19",
|
|
||||||
"193.37.33.117": "193.37.33.0/24",
|
|
||||||
"2a04:c300:400::5d": "2a04:c300::/29",
|
|
||||||
"20.198.120.225": "20.192.0.0/10",
|
|
||||||
"192.253.248.7": "192.253.248.0/24",
|
|
||||||
"2400:8901::2000:62ff:fe9a:e1c2": "2400:8901::/32",
|
|
||||||
"20.215.218.234": "20.192.0.0/10",
|
|
||||||
"3.79.41.12": "3.64.0.0/12",
|
|
||||||
"94.154.43.183": "94.154.43.0/24",
|
|
||||||
"43.98.201.177": "43.98.201.0/24",
|
|
||||||
"20.104.50.116": "20.64.0.0/10",
|
|
||||||
"52.165.196.84": "52.160.0.0/11",
|
|
||||||
"185.177.72.53": "185.177.72.0/24",
|
|
||||||
"91.92.47.101": "91.92.47.0/24",
|
|
||||||
"216.73.216.140": "216.73.216.0/22",
|
|
||||||
"35.187.9.30": "35.187.0.0/20",
|
|
||||||
"191.237.250.106": "191.232.0.0/13",
|
|
||||||
"104.28.240.187": "104.28.240.0/24",
|
|
||||||
"66.187.5.228": "66.187.5.0/24",
|
|
||||||
"2a00:6340:2008:ec80:be24:11ff:fe82:2775": "2a00:6340::/32",
|
|
||||||
"113.57.186.101": "113.56.0.0/15",
|
|
||||||
"34.244.177.56": "34.240.0.0/13",
|
|
||||||
"2600:3000:1305:4212::85": "2600:3000::/32",
|
|
||||||
"20.9.15.100": "20.0.0.0/11",
|
|
||||||
"80.91.86.169": "80.91.86.0/24",
|
|
||||||
"20.91.140.156": "20.64.0.0/10",
|
|
||||||
"139.59.231.238": "139.59.224.0/20",
|
|
||||||
"23.102.123.231": "23.102.0.0/16",
|
|
||||||
"20.104.22.47": "20.64.0.0/10",
|
|
||||||
"216.73.216.209": "216.73.216.0/22",
|
|
||||||
"4.223.71.149": "4.208.0.0/12",
|
|
||||||
"192.133.77.18": "192.133.76.0/23",
|
|
||||||
"93.123.109.163": "93.123.109.0/24",
|
|
||||||
"4.225.166.222": "4.224.0.0/12",
|
|
||||||
"193.148.56.148": "193.148.56.0/24",
|
|
||||||
"23.178.112.212": "23.178.112.0/24",
|
|
||||||
"194.5.82.123": "194.5.82.0/24",
|
|
||||||
"34.171.75.208": "34.171.0.0/17",
|
|
||||||
"185.177.72.22": "185.177.72.0/24",
|
|
||||||
"20.197.178.120": "20.192.0.0/10",
|
|
||||||
"158.158.45.224": "158.158.0.0/16",
|
|
||||||
"85.204.70.100": "85.204.70.0/24",
|
|
||||||
"2600:3000:2710:200::82": "2600:3000::/32",
|
|
||||||
"34.65.14.246": "34.65.0.0/20",
|
|
||||||
"185.177.72.5": "185.177.72.0/24",
|
|
||||||
"20.195.181.10": "20.192.0.0/10",
|
|
||||||
"110.179.80.213": "110.176.0.0/13",
|
|
||||||
"34.69.107.154": "34.69.96.0/20",
|
|
||||||
"195.178.110.199": "195.178.110.0/24",
|
|
||||||
"85.204.70.118": "85.204.70.0/24",
|
|
||||||
"20.226.43.166": "20.192.0.0/10",
|
|
||||||
"130.12.182.254": "130.12.182.0/24",
|
|
||||||
"20.226.5.174": "20.192.0.0/10",
|
|
||||||
"20.91.208.34": "20.64.0.0/10",
|
|
||||||
"68.221.130.241": "68.220.0.0/15",
|
|
||||||
"85.204.70.112": "85.204.70.0/24",
|
|
||||||
"165.245.190.205": "165.245.176.0/20",
|
|
||||||
"51.120.79.193": "51.120.0.0/16",
|
|
||||||
"216.73.216.41": "216.73.216.0/22",
|
|
||||||
"216.73.216.66": "216.73.216.0/22",
|
|
||||||
"34.105.30.251": "34.105.16.0/20",
|
|
||||||
"45.148.10.244": "45.148.10.0/24",
|
|
||||||
"2600:3000:2710:200::85": "2600:3000::/32",
|
|
||||||
"20.215.216.94": "20.192.0.0/10",
|
|
||||||
"18.142.90.109": "18.142.0.0/15",
|
|
||||||
"34.242.226.103": "34.240.0.0/13",
|
|
||||||
"158.158.41.79": "158.158.0.0/16",
|
|
||||||
"195.178.110.31": "195.178.110.0/24",
|
|
||||||
"20.203.209.227": "20.192.0.0/10",
|
|
||||||
"209.99.189.190": "209.99.184.0/21",
|
|
||||||
"148.251.182.79": "148.251.0.0/16",
|
|
||||||
"16.146.99.254": "16.146.0.0/17",
|
|
||||||
"167.99.210.137": "167.99.208.0/20",
|
|
||||||
"45.134.20.128": "45.134.20.0/24",
|
|
||||||
"69.164.220.232": "69.164.208.0/20",
|
|
||||||
"34.11.108.163": "34.11.0.0/17",
|
|
||||||
"104.248.151.168": "104.248.144.0/20",
|
|
||||||
"128.199.182.55": "128.199.128.0/18",
|
|
||||||
"23.178.112.103": "23.178.112.0/24",
|
|
||||||
"91.92.41.115": "91.92.41.0/24",
|
|
||||||
"20.151.225.23": "20.150.0.0/15",
|
|
||||||
"91.92.241.196": "91.92.241.0/24",
|
|
||||||
"185.177.72.81": "185.177.72.0/24",
|
|
||||||
"20.100.176.141": "20.64.0.0/10",
|
|
||||||
"129.212.237.196": "129.212.224.0/20",
|
|
||||||
"2a09:bac5:9443:3af::5e:4d": "2a09:bac5::/32",
|
|
||||||
"20.100.169.152": "20.64.0.0/10",
|
|
||||||
"20.151.10.161": "20.150.0.0/15",
|
|
||||||
"34.73.128.17": "34.73.128.0/20",
|
|
||||||
"143.244.57.84": "143.244.56.0/23",
|
|
||||||
"20.198.114.210": "20.192.0.0/10",
|
|
||||||
"45.88.138.44": "45.88.138.0/24",
|
|
||||||
"167.99.182.39": "167.99.176.0/20",
|
|
||||||
"185.177.72.58": "185.177.72.0/24",
|
|
||||||
"54.82.69.43": "54.82.0.0/17",
|
|
||||||
"172.202.29.124": "172.200.0.0/13",
|
|
||||||
"64.89.161.82": "64.89.161.0/24",
|
|
||||||
"20.197.195.24": "20.192.0.0/10",
|
|
||||||
"20.251.48.208": "20.192.0.0/10",
|
|
||||||
"150.255.94.250": "150.255.0.0/16",
|
|
||||||
"82.165.82.154": "82.165.0.0/16",
|
|
||||||
"5.34.213.142": "5.34.208.0/21",
|
|
||||||
"45.154.138.181": "45.154.138.0/24",
|
|
||||||
"200.162.152.170": "200.162.144.0/20",
|
|
||||||
"4.193.178.28": "4.192.0.0/12",
|
|
||||||
"2a01:2a8:a0dd:1::1012": "2a01:2a8::/29",
|
|
||||||
"216.73.216.98": "216.73.216.0/22",
|
|
||||||
"20.48.234.177": "20.48.0.0/12",
|
|
||||||
"134.122.94.138": "134.122.80.0/20",
|
|
||||||
"35.231.41.225": "35.231.0.0/17",
|
|
||||||
"2.58.56.101": "2.58.56.0/24",
|
|
||||||
"146.190.63.248": "146.190.32.0/19",
|
|
||||||
"34.31.88.250": "34.31.0.0/16",
|
|
||||||
"20.220.210.52": "20.192.0.0/10",
|
|
||||||
"52.139.36.144": "52.136.0.0/13",
|
|
||||||
"194.5.82.87": "194.5.82.0/24",
|
|
||||||
"35.199.111.12": "35.199.96.0/20",
|
|
||||||
"85.204.70.106": "85.204.70.0/24",
|
|
||||||
"20.203.133.142": "20.192.0.0/10",
|
|
||||||
"91.124.88.29": "91.124.88.0/24",
|
|
||||||
"216.73.216.10": "216.73.216.0/22",
|
|
||||||
"198.98.52.229": "198.98.48.0/20",
|
|
||||||
"93.123.109.101": "93.123.109.0/24",
|
|
||||||
"34.139.141.56": "34.139.128.0/20",
|
|
||||||
"143.244.168.161": "143.244.160.0/20",
|
|
||||||
"216.73.216.230": "216.73.216.0/22",
|
|
||||||
"194.5.82.89": "194.5.82.0/24",
|
|
||||||
"195.178.110.104": "195.178.110.0/24",
|
|
||||||
"34.86.217.208": "34.86.208.0/20",
|
|
||||||
"20.214.106.237": "20.192.0.0/10",
|
|
||||||
"54.206.147.115": "54.206.128.0/18",
|
|
||||||
"91.224.92.190": "91.224.92.0/24",
|
|
||||||
"20.48.236.161": "20.48.0.0/12",
|
|
||||||
"34.181.134.189": "34.181.128.0/17",
|
|
||||||
"157.245.197.36": "157.245.192.0/20",
|
|
||||||
"45.61.150.67": "45.61.148.0/22",
|
|
||||||
"143.244.57.92": "143.244.56.0/23",
|
|
||||||
"94.154.43.184": "94.154.43.0/24",
|
|
||||||
"20.203.148.31": "20.192.0.0/10",
|
|
||||||
"34.65.18.71": "34.65.16.0/20",
|
|
||||||
"192.133.77.17": "192.133.76.0/23",
|
|
||||||
"84.17.60.251": "84.17.60.0/24",
|
|
||||||
"138.197.104.211": "138.197.96.0/20",
|
|
||||||
"206.81.24.227": "206.81.16.0/20",
|
|
||||||
"20.46.121.148": "20.40.0.0/13",
|
|
||||||
"36.106.166.92": "36.106.166.0/23",
|
|
||||||
"20.63.63.128": "20.48.0.0/12",
|
|
||||||
"20.151.200.100": "20.150.0.0/15",
|
|
||||||
"216.73.217.11": "216.73.216.0/22",
|
|
||||||
"130.12.180.39": "130.12.180.0/24",
|
|
||||||
"146.70.194.254": "146.70.194.0/24",
|
|
||||||
"91.148.244.131": "91.148.244.0/22",
|
|
||||||
"194.5.82.63": "194.5.82.0/24",
|
|
||||||
"20.205.229.184": "20.192.0.0/10",
|
|
||||||
"2600:3000:1305:4212::83": "2600:3000::/32",
|
|
||||||
"206.189.95.232": "206.189.80.0/20",
|
|
||||||
"130.131.231.171": "130.131.0.0/16",
|
|
||||||
"185.177.72.24": "185.177.72.0/24",
|
|
||||||
"20.220.212.5": "20.192.0.0/10",
|
|
||||||
"146.70.194.228": "146.70.194.0/24",
|
|
||||||
"20.203.135.57": "20.192.0.0/10",
|
|
||||||
"20.206.105.145": "20.192.0.0/10",
|
|
||||||
"2a04:c300:400::58": "2a04:c300::/29",
|
|
||||||
"20.100.203.84": "20.64.0.0/10",
|
|
||||||
"91.224.92.159": "91.224.92.0/24",
|
|
||||||
"104.196.62.35": "104.196.62.35/32",
|
|
||||||
"153.75.89.249": "153.75.89.0/24",
|
|
||||||
"104.168.93.75": "104.168.0.0/17",
|
|
||||||
"185.177.72.13": "185.177.72.0/24",
|
|
||||||
"91.92.42.86": "91.92.42.0/24",
|
|
||||||
"45.153.34.182": "45.153.34.182/32",
|
|
||||||
"185.177.72.100": "185.177.72.0/24",
|
|
||||||
"178.16.54.137": "178.16.54.0/24",
|
|
||||||
"103.215.75.66": "103.215.74.0/23",
|
|
||||||
"81.171.72.135": "81.171.72.135/32",
|
|
||||||
"20.251.58.190": "20.192.0.0/10",
|
|
||||||
"195.178.110.159": "195.178.110.0/24",
|
|
||||||
"23.178.112.211": "23.178.112.0/24",
|
|
||||||
"185.177.72.56": "185.177.72.0/24",
|
|
||||||
"143.110.178.165": "143.110.178.165/32",
|
|
||||||
"20.127.18.0": "20.64.0.0/10",
|
|
||||||
"185.177.72.69": "185.177.72.0/24",
|
|
||||||
"185.177.72.30": "185.177.72.0/24",
|
|
||||||
"147.182.149.75": "147.182.149.75/32",
|
|
||||||
"94.154.43.179": "94.154.43.0/24",
|
|
||||||
"194.5.82.95": "194.5.82.0/24",
|
|
||||||
"172.213.232.128": "172.208.0.0/13",
|
|
||||||
"118.81.196.142": "118.80.0.0/15",
|
|
||||||
"93.123.109.10": "93.123.109.0/24",
|
|
||||||
"66.228.38.221": "66.228.32.0/20",
|
|
||||||
"91.148.245.81": "91.148.244.0/22",
|
|
||||||
"74.248.35.25": "74.248.0.0/15",
|
|
||||||
"45.45.237.69": "45.45.237.0/24",
|
|
||||||
"34.17.159.73": "34.17.0.0/16",
|
|
||||||
"45.148.10.194": "45.148.10.0/24",
|
|
||||||
"8.229.1.130": "8.229.0.0/16",
|
|
||||||
"104.199.182.104": "104.199.176.0/20",
|
|
||||||
"2001:4ca0:108:42::24": "2001:4ca0::/32",
|
|
||||||
"111.113.89.107": "111.112.0.0/15",
|
|
||||||
"4.184.185.91": "4.176.0.0/12",
|
|
||||||
"34.146.12.12": "34.146.0.0/20",
|
|
||||||
"34.131.251.163": "34.131.240.0/20",
|
|
||||||
"45.138.16.114": "45.138.16.0/24",
|
|
||||||
"182.119.230.145": "182.112.0.0/12",
|
|
||||||
"51.120.76.82": "51.120.0.0/16",
|
|
||||||
"34.40.240.124": "34.40.128.0/17",
|
|
||||||
"111.113.88.210": "111.112.0.0/15",
|
|
||||||
"34.42.48.100": "34.42.0.0/16",
|
|
||||||
"20.220.203.117": "20.192.0.0/10",
|
|
||||||
"45.148.10.60": "45.148.10.0/24",
|
|
||||||
"8.234.157.73": "8.234.128.0/17",
|
|
||||||
"74.208.40.151": "74.208.0.0/16",
|
|
||||||
"165.22.235.3": "165.22.224.0/20",
|
|
||||||
"20.240.137.17": "20.192.0.0/10",
|
|
||||||
"144.172.97.164": "144.172.96.0/23",
|
|
||||||
"142.111.152.16": "142.111.152.0/24",
|
|
||||||
"216.73.163.148": "216.73.163.0/24",
|
|
||||||
"4.223.226.192": "4.208.0.0/12",
|
|
||||||
"93.152.221.13": "93.152.221.0/24",
|
|
||||||
"20.63.10.215": "20.48.0.0/12",
|
|
||||||
"136.116.215.248": "136.116.0.0/16",
|
|
||||||
"103.76.88.36": "103.76.88.0/24",
|
|
||||||
"213.35.113.47": "213.35.96.0/19",
|
|
||||||
"160.179.166.222": "160.176.0.0/14",
|
|
||||||
"216.81.248.20": "216.81.248.0/22",
|
|
||||||
"104.28.254.131": "104.28.254.0/24",
|
|
||||||
"3.27.206.50": "3.24.0.0/14",
|
|
||||||
"91.193.232.61": "91.193.232.0/24",
|
|
||||||
"136.66.128.85": "136.66.0.0/15",
|
|
||||||
"5.161.225.116": "5.161.0.0/16",
|
|
||||||
"34.187.20.70": "34.187.0.0/17",
|
|
||||||
"185.223.152.40": "185.223.152.0/24",
|
|
||||||
"20.172.218.20": "20.160.0.0/12",
|
|
||||||
"31.56.58.124": "31.56.58.0/24",
|
|
||||||
"173.239.240.222": "173.239.240.0/24",
|
|
||||||
"2001:ba0:24c:a500::1": "2001:ba0::/32",
|
|
||||||
"185.223.152.126": "185.223.152.0/24",
|
|
||||||
"62.93.179.146": "62.93.179.0/24",
|
|
||||||
"104.248.5.228": "104.248.0.0/20",
|
|
||||||
"35.182.17.8": "35.182.0.0/15",
|
|
||||||
"20.46.252.52": "20.40.0.0/13",
|
|
||||||
"34.116.255.65": "34.116.240.0/20",
|
|
||||||
"172.213.208.20": "172.208.0.0/13",
|
|
||||||
"163.7.13.205": "163.7.13.0/24",
|
|
||||||
"46.232.235.6": "46.232.235.0/24",
|
|
||||||
"4.231.36.50": "4.224.0.0/12",
|
|
||||||
"45.130.203.154": "45.130.202.0/23",
|
|
||||||
"20.100.173.28": "20.64.0.0/10",
|
|
||||||
"176.227.240.61": "176.227.240.0/24",
|
|
||||||
"2a04:c300::3ce": "2a04:c300::/29",
|
|
||||||
"185.251.19.30": "185.251.19.0/24",
|
|
||||||
"92.118.39.29": "92.118.39.0/24",
|
|
||||||
"185.192.71.16": "185.192.71.0/24",
|
|
||||||
"138.197.34.12": "138.197.32.0/20",
|
|
||||||
"34.90.88.37": "34.90.80.0/20",
|
|
||||||
"165.22.24.78": "165.22.16.0/20",
|
|
||||||
"144.172.101.168": "144.172.101.0/24",
|
|
||||||
"216.24.212.243": "216.24.212.0/24",
|
|
||||||
"4.236.158.50": "4.224.0.0/12",
|
|
||||||
"168.144.174.42": "168.144.160.0/20",
|
|
||||||
"2406:da18:525:0:a23e:e8f6:3ea1:4c2c": "2406:da18:400::/38",
|
|
||||||
"52.159.244.176": "52.152.0.0/13",
|
|
||||||
"34.24.155.203": "34.24.0.0/16",
|
|
||||||
"37.140.223.58": "37.140.223.0/24",
|
|
||||||
"194.156.79.68": "194.156.79.0/24",
|
|
||||||
"46.23.108.234": "46.23.108.0/24",
|
|
||||||
"74.248.96.101": "74.248.0.0/15",
|
|
||||||
"31.58.51.130": "31.58.51.0/24",
|
|
||||||
"34.94.172.72": "34.94.160.0/20",
|
|
||||||
"200.162.145.220": "200.162.144.0/20",
|
|
||||||
"13.126.86.18": "13.126.0.0/15",
|
|
||||||
"143.198.25.99": "143.198.16.0/20",
|
|
||||||
"104.28.254.134": "104.28.254.0/24",
|
|
||||||
"167.86.82.167": "167.86.82.0/23",
|
|
||||||
"91.92.243.245": "91.92.243.0/24",
|
|
||||||
"106.63.26.8": "106.63.0.0/17",
|
|
||||||
"2a09:bac1:76c0:460::5e:6a": "2a09:bac1::/32",
|
|
||||||
"136.66.153.34": "136.66.0.0/15",
|
|
||||||
"20.52.125.110": "20.48.0.0/12",
|
|
||||||
"15.206.146.240": "15.206.0.0/15",
|
|
||||||
"194.32.120.151": "194.32.120.0/24",
|
|
||||||
"135.232.177.160": "135.232.0.0/14",
|
|
||||||
"20.171.55.167": "20.160.0.0/12",
|
|
||||||
"64.225.63.129": "64.225.48.0/20",
|
|
||||||
"172.202.95.21": "172.200.0.0/13",
|
|
||||||
"136.144.43.127": "136.144.43.0/24",
|
|
||||||
"24.144.85.55": "24.144.80.0/20",
|
|
||||||
"128.199.182.152": "128.199.128.0/18",
|
|
||||||
"152.42.137.175": "152.42.128.0/20",
|
|
||||||
"87.121.79.42": "87.121.79.0/24",
|
|
||||||
"82.39.206.154": "82.39.206.0/24",
|
|
||||||
"167.71.39.61": "167.71.32.0/20",
|
|
||||||
"193.19.109.68": "193.19.109.0/24",
|
|
||||||
"195.178.110.18": "195.178.110.0/24",
|
|
||||||
"193.36.225.180": "193.36.225.0/24",
|
|
||||||
"136.144.33.102": "136.144.33.0/24",
|
|
||||||
"167.71.0.47": "167.71.0.0/20",
|
|
||||||
"104.28.240.188": "104.28.240.0/24",
|
|
||||||
"45.146.54.80": "45.146.54.0/24",
|
|
||||||
"193.36.225.133": "193.36.225.0/24",
|
|
||||||
"103.177.150.66": "103.177.150.0/24",
|
|
||||||
"172.86.122.106": "172.86.120.0/22",
|
|
||||||
"37.140.223.6": "37.140.223.0/24",
|
|
||||||
"216.73.163.119": "216.73.163.0/24",
|
|
||||||
"179.43.134.114": "179.43.134.0/24",
|
|
||||||
"130.12.182.12": "130.12.182.0/24",
|
|
||||||
"159.89.46.165": "159.89.32.0/20",
|
|
||||||
"62.60.130.227": "62.60.130.0/24",
|
|
||||||
"207.241.173.229": "207.241.172.0/23",
|
|
||||||
"142.93.62.168": "142.93.48.0/20",
|
|
||||||
"91.230.225.192": "91.230.225.0/24",
|
|
||||||
"54.210.219.163": "54.210.0.0/16",
|
|
||||||
"45.131.195.127": "45.131.195.0/24",
|
|
||||||
"20.199.181.219": "20.192.0.0/10",
|
|
||||||
"193.37.33.55": "193.37.33.0/24",
|
|
||||||
"216.73.163.138": "216.73.163.0/24",
|
|
||||||
"188.213.202.116": "188.213.202.0/24",
|
|
||||||
"85.204.70.98": "85.204.70.0/24",
|
|
||||||
"193.36.225.117": "193.36.225.0/24",
|
|
||||||
"188.213.202.121": "188.213.202.0/24",
|
|
||||||
"45.131.195.175": "45.131.195.0/24",
|
|
||||||
"34.75.4.161": "34.75.0.0/20",
|
|
||||||
"20.125.176.178": "20.64.0.0/10",
|
|
||||||
"34.158.83.192": "34.158.64.0/18",
|
|
||||||
"45.130.203.177": "45.130.202.0/23",
|
|
||||||
"216.24.212.233": "216.24.212.0/24",
|
|
||||||
"168.144.167.238": "168.144.160.0/20",
|
|
||||||
"216.73.163.132": "216.73.163.0/24",
|
|
||||||
"191.232.199.39": "191.232.0.0/13",
|
|
||||||
"20.203.200.218": "20.192.0.0/10",
|
|
||||||
"20.12.214.177": "20.0.0.0/11",
|
|
||||||
"98.159.37.147": "98.159.37.0/24",
|
|
||||||
"34.21.144.169": "34.21.128.0/17",
|
|
||||||
"45.146.54.137": "45.146.54.0/24",
|
|
||||||
"193.37.32.159": "193.37.32.0/24",
|
|
||||||
"172.98.33.251": "172.98.33.0/24",
|
|
||||||
"207.154.197.113": "207.154.192.0/20",
|
|
||||||
"104.251.181.106": "104.251.180.0/22",
|
|
||||||
"34.26.191.118": "34.26.0.0/16",
|
|
||||||
"193.37.33.251": "193.37.33.0/24",
|
|
||||||
"135.119.74.228": "135.119.0.0/16",
|
|
||||||
"20.226.83.28": "20.192.0.0/10",
|
|
||||||
"2a04:4e40:e000:0:566:f8d7:adbe:f536": "2a04:4e40:e000::/48",
|
|
||||||
"168.144.83.150": "168.144.80.0/20",
|
|
||||||
"68.221.137.211": "68.220.0.0/15",
|
|
||||||
"167.172.192.26": "167.172.192.0/20",
|
|
||||||
"143.110.217.244": "143.110.208.0/20",
|
|
||||||
"88.184.210.4": "88.184.192.0/19",
|
|
||||||
"167.172.53.28": "167.172.48.0/20",
|
|
||||||
"20.55.86.49": "20.48.0.0/12",
|
|
||||||
"35.199.115.51": "35.199.112.0/20",
|
|
||||||
"34.154.122.183": "34.154.0.0/17",
|
|
||||||
"85.204.70.92": "85.204.70.0/24",
|
|
||||||
"2a13:adc0::2ed:89ff:fe58:19c5": "2a13:adc0::/48",
|
|
||||||
"157.245.113.227": "157.245.112.0/20",
|
|
||||||
"45.131.195.153": "45.131.195.0/24",
|
|
||||||
"34.82.10.34": "34.82.0.0/20",
|
|
||||||
"47.85.160.47": "47.85.160.0/24",
|
|
||||||
"85.11.167.225": "85.11.167.0/24",
|
|
||||||
"4.246.70.112": "4.240.0.0/12",
|
|
||||||
"143.20.64.236": "143.20.64.0/24",
|
|
||||||
"185.192.71.76": "185.192.71.0/24",
|
|
||||||
"134.209.185.72": "134.209.176.0/20",
|
|
||||||
"144.126.207.57": "144.126.192.0/20",
|
|
||||||
"172.202.37.160": "172.200.0.0/13",
|
|
||||||
"118.194.253.208": "118.194.253.0/24",
|
|
||||||
"144.172.93.117": "144.172.92.0/22",
|
|
||||||
"4.225.203.146": "4.224.0.0/12",
|
|
||||||
"209.126.2.173": "209.126.0.0/20",
|
|
||||||
"35.204.88.252": "35.204.80.0/20",
|
|
||||||
"2600:3c15::2000:9cff:fef0:8ad6": "2600:3c15::/32",
|
|
||||||
"2a06:a880:5:7fb3::1": "2a06:a880:5::/48",
|
|
||||||
"94.154.43.177": "94.154.43.0/24",
|
|
||||||
"176.227.240.17": "176.227.240.0/24",
|
|
||||||
"91.230.225.186": "91.230.225.0/24",
|
|
||||||
"8.235.114.162": "8.235.0.0/17",
|
|
||||||
"159.223.132.86": "159.223.128.0/20",
|
|
||||||
"91.92.47.153": "91.92.47.0/24",
|
|
||||||
"2602:fa59:10:e70::1": "2602:fa59::/40",
|
|
||||||
"188.213.202.84": "188.213.202.0/24",
|
|
||||||
"45.146.55.48": "45.146.55.0/24",
|
|
||||||
"45.131.193.139": "45.131.193.0/24",
|
|
||||||
"34.55.200.45": "34.55.0.0/16",
|
|
||||||
"20.215.191.139": "20.192.0.0/10",
|
|
||||||
"8.234.207.247": "8.234.128.0/17",
|
|
||||||
"9.205.16.190": "9.205.0.0/16",
|
|
||||||
"35.204.141.252": "35.204.128.0/20",
|
|
||||||
"136.144.35.201": "136.144.35.0/24",
|
|
||||||
"2a04:4e40:e000:0:bfc:4b0:174b:d52d": "2a04:4e40:e000::/48",
|
|
||||||
"208.84.102.195": "208.84.102.0/24",
|
|
||||||
"160.179.166.124": "160.176.0.0/14",
|
|
||||||
"74.248.20.32": "74.248.0.0/15",
|
|
||||||
"93.123.109.167": "93.123.109.0/24",
|
|
||||||
"52.165.250.117": "52.160.0.0/11",
|
|
||||||
"5.161.62.209": "5.161.0.0/18",
|
|
||||||
"2602:fa59:10:4c9::1": "2602:fa59::/40",
|
|
||||||
"45.154.98.134": "45.154.98.0/24",
|
|
||||||
"172.245.102.64": "172.245.102.0/24",
|
|
||||||
"2a04:4e40:4400:0:537:cdd2:7250:95db": "2a04:4e40:4400::/48",
|
|
||||||
"216.24.210.46": "216.24.210.0/24",
|
|
||||||
"143.198.6.225": "143.198.0.0/20",
|
|
||||||
"91.230.225.2": "91.230.225.0/24",
|
|
||||||
"142.93.155.36": "142.93.144.0/20",
|
|
||||||
"20.220.225.223": "20.192.0.0/10",
|
|
||||||
"68.221.186.136": "68.220.0.0/15",
|
|
||||||
"172.170.254.20": "172.160.0.0/11",
|
|
||||||
"13.83.161.80": "13.64.0.0/11",
|
|
||||||
"103.76.88.37": "103.76.88.0/24",
|
|
||||||
"20.100.186.69": "20.64.0.0/10",
|
|
||||||
"185.192.71.247": "185.192.71.0/24",
|
|
||||||
"52.242.193.144": "52.224.0.0/11",
|
|
||||||
"92.119.121.196": "92.119.120.0/22",
|
|
||||||
"185.223.152.60": "185.223.152.0/24",
|
|
||||||
"20.184.150.240": "20.184.0.0/13",
|
|
||||||
"52.225.25.48": "52.224.0.0/11",
|
|
||||||
"94.154.43.178": "94.154.43.0/24",
|
|
||||||
"216.24.212.14": "216.24.212.0/24",
|
|
||||||
"2a04:4e40:e000:0:33c:a689:be95:5a4": "2a04:4e40:e000::/48",
|
|
||||||
"35.184.157.121": "35.184.144.0/20",
|
|
||||||
"185.192.71.5": "185.192.71.0/24",
|
|
||||||
"104.28.240.189": "104.28.240.0/24",
|
|
||||||
"147.93.169.103": "147.93.168.0/22",
|
|
||||||
"164.90.169.163": "164.90.160.0/20",
|
|
||||||
"46.232.235.5": "46.232.235.0/24",
|
|
||||||
"155.2.215.98": "155.2.215.0/24",
|
|
||||||
"134.209.25.199": "134.209.16.0/20",
|
|
||||||
"185.238.231.167": "185.238.231.0/24",
|
|
||||||
"20.199.136.34": "20.192.0.0/10",
|
|
||||||
"216.24.212.45": "216.24.212.0/24",
|
|
||||||
"147.93.168.91": "147.93.168.0/22",
|
|
||||||
"185.223.152.103": "185.223.152.0/24",
|
|
||||||
"159.89.117.109": "159.89.112.0/20",
|
|
||||||
"140.248.75.106": "140.248.75.0/24",
|
|
||||||
"193.37.32.157": "193.37.32.0/24",
|
|
||||||
"128.90.128.13": "128.90.128.0/24",
|
|
||||||
"185.192.71.3": "185.192.71.0/24",
|
|
||||||
"106.63.26.7": "106.63.0.0/17",
|
|
||||||
"158.158.41.78": "158.158.0.0/16",
|
|
||||||
"185.192.71.13": "185.192.71.0/24",
|
|
||||||
"85.204.70.102": "85.204.70.0/24",
|
|
||||||
"152.53.242.93": "152.53.240.0/22",
|
|
||||||
"144.172.100.174": "144.172.100.0/24",
|
|
||||||
"178.62.196.238": "178.62.192.0/18",
|
|
||||||
"159.203.13.62": "159.203.0.0/20",
|
|
||||||
"31.184.236.8": "31.184.236.0/24",
|
|
||||||
"128.90.141.10": "128.90.141.0/24",
|
|
||||||
"45.146.55.11": "45.146.55.0/24",
|
|
||||||
"4.232.188.49": "4.224.0.0/12",
|
|
||||||
"45.148.10.119": "45.148.10.0/24",
|
|
||||||
"172.204.17.66": "172.200.0.0/13",
|
|
||||||
"34.7.160.235": "34.7.128.0/17",
|
|
||||||
"35.201.159.247": "35.201.144.0/20",
|
|
||||||
"34.148.8.63": "34.148.0.0/20",
|
|
||||||
"4.223.140.16": "4.208.0.0/12",
|
|
||||||
"165.22.147.152": "165.22.144.0/20",
|
|
||||||
"34.90.87.32": "34.90.80.0/20",
|
|
||||||
"106.63.26.22": "106.63.0.0/17",
|
|
||||||
"138.124.103.234": "138.124.103.0/24",
|
|
||||||
"34.64.173.205": "34.64.160.0/20",
|
|
||||||
"93.123.109.178": "93.123.109.0/24",
|
|
||||||
"91.193.232.155": "91.193.232.0/24",
|
|
||||||
"209.87.169.142": "209.87.169.0/24",
|
|
||||||
"2a04:4e40:e000:0:954:9789:20b1:d5fc": "2a04:4e40:e000::/48",
|
|
||||||
"34.187.82.183": "34.187.0.0/17",
|
|
||||||
"193.37.32.149": "193.37.32.0/24",
|
|
||||||
"20.52.54.143": "20.48.0.0/12",
|
|
||||||
"106.63.26.21": "106.63.0.0/17",
|
|
||||||
"2a05:4140:3::37cd": "2a05:4140:3::/48",
|
|
||||||
"193.36.224.57": "193.36.224.0/24",
|
|
||||||
"2a09:bac5:55fb:18c8::278:a7": "2a09:bac5::/32",
|
|
||||||
"195.178.110.72": "195.178.110.0/24",
|
|
||||||
"200.162.145.50": "200.162.144.0/20",
|
|
||||||
"31.56.58.122": "31.56.58.0/24",
|
|
||||||
"2a01:4f8:c014:7f9e::1": "2a01:4f8::/32",
|
|
||||||
"216.24.212.43": "216.24.212.0/24",
|
|
||||||
"195.178.110.100": "195.178.110.0/24",
|
|
||||||
"102.37.110.74": "102.37.0.0/17",
|
|
||||||
"20.106.182.98": "20.64.0.0/10",
|
|
||||||
"172.202.44.182": "172.200.0.0/13",
|
|
||||||
"216.26.233.39": "216.26.233.0/24",
|
|
||||||
"52.242.217.80": "52.224.0.0/11",
|
|
||||||
"167.172.31.131": "167.172.16.0/20",
|
|
||||||
"45.8.196.200": "45.8.196.0/24",
|
|
||||||
"195.178.110.241": "195.178.110.0/24",
|
|
||||||
"41.143.154.1": "41.140.0.0/14",
|
|
||||||
"23.94.155.57": "23.94.155.0/24",
|
|
||||||
"159.89.123.252": "159.89.112.0/20",
|
|
||||||
"104.238.222.26": "104.238.222.0/24",
|
|
||||||
"23.95.226.104": "23.94.0.0/15",
|
|
||||||
"159.89.97.19": "159.89.96.0/20",
|
|
||||||
"35.255.40.65": "35.255.0.0/16",
|
|
||||||
"34.145.62.28": "34.145.48.0/20",
|
|
||||||
"185.192.71.2": "185.192.71.0/24",
|
|
||||||
"193.149.176.55": "193.149.176.0/24",
|
|
||||||
"34.7.193.250": "34.7.128.0/17",
|
|
||||||
"152.39.198.115": "152.39.128.0/17",
|
|
||||||
"18.236.136.230": "18.236.0.0/15",
|
|
||||||
"188.213.202.113": "188.213.202.0/24",
|
|
||||||
"136.144.19.51": "136.144.19.0/24",
|
|
||||||
"68.183.9.16": "68.183.0.0/20",
|
|
||||||
"62.60.130.128": "62.60.130.0/24",
|
|
||||||
"185.192.71.6": "185.192.71.0/24",
|
|
||||||
"18.191.56.164": "18.191.0.0/16",
|
|
||||||
"104.234.19.67": "104.234.19.0/24",
|
|
||||||
"172.196.32.64": "172.192.0.0/13",
|
|
||||||
"185.213.175.168": "185.213.175.0/24",
|
|
||||||
"46.232.235.3": "46.232.235.0/24",
|
|
||||||
"94.154.43.229": "94.154.43.0/24",
|
|
||||||
"34.18.38.137": "34.18.0.0/16",
|
|
||||||
"132.196.83.33": "132.196.0.0/16",
|
|
||||||
"91.193.232.168": "91.193.232.0/24",
|
|
||||||
"31.56.58.129": "31.56.58.0/24",
|
|
||||||
"141.98.11.171": "141.98.11.0/24",
|
|
||||||
"216.73.163.122": "216.73.163.0/24",
|
|
||||||
"142.111.152.57": "142.111.152.0/24",
|
|
||||||
"98.159.36.147": "98.159.36.0/24",
|
|
||||||
"2a00:6340:2008:ec80:64cc:9116:beb2:1931": "2a00:6340::/32",
|
|
||||||
"172.212.171.144": "172.208.0.0/13",
|
|
||||||
"46.232.235.4": "46.232.235.0/24",
|
|
||||||
"34.65.138.195": "34.65.128.0/20",
|
|
||||||
"159.89.12.166": "159.89.0.0/20",
|
|
||||||
"193.143.1.112": "193.143.1.0/24",
|
|
||||||
"145.132.103.19": "145.132.0.0/15",
|
|
||||||
"209.99.190.99": "209.99.184.0/21",
|
|
||||||
"20.104.18.253": "20.64.0.0/10",
|
|
||||||
"178.128.176.132": "178.128.176.0/20",
|
|
||||||
"81.171.74.60": "81.171.74.0/24",
|
|
||||||
"72.146.33.199": "72.144.0.0/14",
|
|
||||||
"147.93.169.104": "147.93.168.0/22",
|
|
||||||
"34.39.210.94": "34.39.128.0/17",
|
|
||||||
"20.215.185.25": "20.192.0.0/10",
|
|
||||||
"23.180.120.146": "23.180.120.0/24",
|
|
||||||
"167.99.204.6": "167.99.192.0/20",
|
|
||||||
"172.213.153.79": "172.208.0.0/13",
|
|
||||||
"20.203.243.122": "20.192.0.0/10",
|
|
||||||
"194.26.192.156": "194.26.192.0/24",
|
|
||||||
"74.248.16.78": "74.248.0.0/15",
|
|
||||||
"34.141.160.140": "34.141.160.0/20",
|
|
||||||
"34.83.69.198": "34.83.64.0/20",
|
|
||||||
"2600:3000:1305:4212::82": "2600:3000::/32",
|
|
||||||
"64.226.123.37": "64.226.112.0/20",
|
|
||||||
"172.213.218.74": "172.208.0.0/13",
|
|
||||||
"159.65.10.116": "159.65.0.0/20",
|
|
||||||
"162.141.167.71": "162.141.167.0/24",
|
|
||||||
"20.91.203.45": "20.64.0.0/10",
|
|
||||||
"80.94.92.167": "80.94.92.0/24",
|
|
||||||
"194.102.104.29": "194.102.104.0/24",
|
|
||||||
"143.244.162.58": "143.244.160.0/20",
|
|
||||||
"4.223.73.90": "4.208.0.0/12",
|
|
||||||
"136.107.217.8": "136.107.128.0/17",
|
|
||||||
"20.203.179.12": "20.192.0.0/10",
|
|
||||||
"4.184.238.149": "4.176.0.0/12",
|
|
||||||
"20.52.217.208": "20.48.0.0/12",
|
|
||||||
"20.218.243.163": "20.192.0.0/10",
|
|
||||||
"34.125.219.56": "34.125.208.0/20",
|
|
||||||
"163.7.13.115": "163.7.13.0/24",
|
|
||||||
"34.26.219.248": "34.26.0.0/16",
|
|
||||||
"2.57.122.13": "2.57.122.0/24",
|
|
||||||
"34.95.238.121": "34.95.224.0/20",
|
|
||||||
"20.208.41.112": "20.192.0.0/10",
|
|
||||||
"182.88.226.154": "182.88.0.0/14",
|
|
||||||
"2602:fa59:10:712::1": "2602:fa59::/40",
|
|
||||||
"34.84.59.119": "34.84.48.0/20",
|
|
||||||
"2a04:c300:400::6f": "2a04:c300::/29",
|
|
||||||
"45.146.55.165": "45.146.55.0/24",
|
|
||||||
"85.204.70.94": "85.204.70.0/24",
|
|
||||||
"157.245.63.62": "157.245.48.0/20",
|
|
||||||
"23.178.112.210": "23.178.112.0/24",
|
|
||||||
"221.13.93.71": "221.13.64.0/19",
|
|
||||||
"163.7.13.111": "163.7.13.0/24",
|
|
||||||
"222.176.200.251": "222.176.0.0/13",
|
|
||||||
"123.163.114.150": "123.160.0.0/14",
|
|
||||||
"136.64.132.60": "136.64.0.0/15",
|
|
||||||
"20.215.218.105": "20.192.0.0/10",
|
|
||||||
"20.203.208.191": "20.192.0.0/10",
|
|
||||||
"2400:8901::2000:beff:fea3:5a44": "2400:8901::/32",
|
|
||||||
"163.7.12.95": "163.7.12.0/24",
|
|
||||||
"209.87.169.76": "209.87.169.0/24",
|
|
||||||
"54.251.19.144": "54.251.16.0/21",
|
|
||||||
"34.125.42.15": "34.125.32.0/20",
|
|
||||||
"92.165.170.72": "92.165.128.0/18",
|
|
||||||
"35.187.221.219": "35.187.208.0/20",
|
|
||||||
"151.240.45.69": "151.240.45.0/24",
|
|
||||||
"163.7.12.6": "163.7.12.0/24",
|
|
||||||
"158.51.126.144": "158.51.126.0/24",
|
|
||||||
"163.7.12.80": "163.7.12.0/24",
|
|
||||||
"20.218.73.95": "20.192.0.0/10",
|
|
||||||
"159.223.79.25": "159.223.64.0/20",
|
|
||||||
"172.86.119.249": "172.86.118.0/23",
|
|
||||||
"35.231.118.233": "35.231.0.0/17",
|
|
||||||
"35.233.191.179": "35.233.128.0/17",
|
|
||||||
"34.74.203.130": "34.74.192.0/20",
|
|
||||||
"170.64.156.114": "170.64.128.0/18",
|
|
||||||
"172.213.2.180": "172.208.0.0/13",
|
|
||||||
"143.244.186.167": "143.244.176.0/20",
|
|
||||||
"35.185.21.88": "35.185.16.0/20",
|
|
||||||
"20.91.239.158": "20.64.0.0/10",
|
|
||||||
"52.64.227.143": "52.64.128.0/17",
|
|
||||||
"34.57.157.110": "34.57.0.0/16",
|
|
||||||
"20.170.17.213": "20.160.0.0/12",
|
|
||||||
"209.97.166.251": "209.97.160.0/20",
|
|
||||||
"173.239.196.33": "173.239.196.0/24",
|
|
||||||
"173.239.196.35": "173.239.196.0/24",
|
|
||||||
"91.92.47.81": "91.92.47.0/24",
|
|
||||||
"34.148.98.62": "34.148.96.0/20",
|
|
||||||
"20.215.186.32": "20.192.0.0/10",
|
|
||||||
"136.108.15.119": "136.108.0.0/16",
|
|
||||||
"34.62.38.199": "34.62.0.0/17",
|
|
||||||
"74.248.33.65": "74.248.0.0/15",
|
|
||||||
"209.87.169.125": "209.87.169.0/24",
|
|
||||||
"34.23.104.90": "34.23.0.0/16",
|
|
||||||
"172.213.208.13": "172.208.0.0/13",
|
|
||||||
"91.192.10.243": "91.192.10.0/24",
|
|
||||||
"91.193.232.249": "91.193.232.0/24",
|
|
||||||
"195.178.110.242": "195.178.110.0/24",
|
|
||||||
"20.251.48.84": "20.192.0.0/10",
|
|
||||||
"23.178.112.108": "23.178.112.0/24",
|
|
||||||
"23.178.112.105": "23.178.112.0/24",
|
|
||||||
"66.249.79.68": "66.249.64.0/20",
|
|
||||||
"66.249.74.164": "66.249.64.0/20",
|
|
||||||
"141.148.153.213": "141.148.128.0/18",
|
|
||||||
"216.73.217.87": "216.73.216.0/22",
|
|
||||||
"185.177.72.70": "185.177.72.0/24",
|
|
||||||
"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",
|
|
||||||
"222.176.200.21": "222.176.0.0/13",
|
|
||||||
"106.117.109.102": "106.117.0.0/16",
|
|
||||||
"121.29.51.109": "121.28.0.0/15",
|
|
||||||
"74.248.97.169": "74.248.0.0/15",
|
|
||||||
"34.176.154.83": "34.176.144.0/20",
|
|
||||||
"35.196.132.85": "35.196.128.0/20"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Deployment support for the intrusion-detection IDS.
|
||||||
|
#
|
||||||
|
# This directory is intentionally small and uses no external framework:
|
||||||
|
# deploy.sh conflict-free `git pull` update wrapper (D004)
|
||||||
|
# ids.service systemd unit template (D005)
|
||||||
|
# restart-service.sh supervisor-agnostic restart shim (D005)
|
||||||
|
# check_runtime_git.sh proves a scan never dirties tracked runtime files (D001)
|
||||||
|
#
|
||||||
|
# See .github/instructions/DEPLOYMENT.md for the full plan.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# D001 — runtime write-path guard.
|
||||||
|
#
|
||||||
|
# Proves the deployment invariant: after running a scan, `git status` shows
|
||||||
|
# NO tracked file modified by the scan — every runtime write lands in a
|
||||||
|
# gitignored location (data/, reports/, *.db). This is what makes `git pull`
|
||||||
|
# conflict-free on a server where scans keep running.
|
||||||
|
#
|
||||||
|
# If this fails, a scan wrote into a git-tracked path and `git pull` on the
|
||||||
|
# server can conflict. Investigate the printed files before deploying.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# deploy/check_runtime_git.sh [--live]
|
||||||
|
# --live additionally run a real scan first (requires log sources) and
|
||||||
|
# then assert the tree is still clean. Without --live it only
|
||||||
|
# verifies that whatever runtime files already exist are untracked.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--live" ]]; then
|
||||||
|
echo "== Running a real scan to exercise every write path =="
|
||||||
|
python app.py scan --quiet --dry-run || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Checking that runtime data is gitignored / untracked =="
|
||||||
|
# Paths a scan writes to. Any of these being tracked OR showing as (non-ignored)
|
||||||
|
# untracked means the invariant is broken and a git pull could conflict.
|
||||||
|
RUNTIME_PATHS=(
|
||||||
|
"app/data"
|
||||||
|
"app/reports"
|
||||||
|
"app/data/ids.db"
|
||||||
|
"app/data/ids.db-wal"
|
||||||
|
"app/data/ids.db-shm"
|
||||||
|
)
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
# 1) No runtime path may be TRACKED, except the single allowed read-only
|
||||||
|
# PaloAlto seed. `git ls-files` reflects the index (including staged
|
||||||
|
# changes), so unborn commits don't cause false failures.
|
||||||
|
allowed="app/data/paloalto/scanner-ips.txt"
|
||||||
|
tracked=$(git ls-files app/data app/reports)
|
||||||
|
problem=$(printf '%s\n' "$tracked" | grep -xvF "$allowed" || true)
|
||||||
|
if [[ -n "$problem" ]]; then
|
||||||
|
echo "FAIL: unexpected tracked files under runtime dirs:" >&2
|
||||||
|
echo "$problem" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Nothing may show as untracked-but-not-ignored under the runtime dirs, i.e.
|
||||||
|
# a scan wrote a file git would happily add (and later conflict on).
|
||||||
|
for p in "${RUNTIME_PATHS[@]}"; do
|
||||||
|
dirty=$(git status --porcelain --untracked-files=all -- "$p" 2>/dev/null || true)
|
||||||
|
if [[ -n "$dirty" ]]; then
|
||||||
|
echo "FAIL: runtime path has changes git cares about:" >&2
|
||||||
|
echo "$dirty" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "OK: no tracked runtime data; git pull will not conflict with scan writes."
|
||||||
|
else
|
||||||
|
echo "BROKEN: a scan wrote into git-tracked paths — fix before deploying." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# D004 — conflict-free deployment update wrapper.
|
||||||
|
#
|
||||||
|
# Updates a deployed instance with `git pull` WITHOUT ever hitting a merge
|
||||||
|
# conflict from files a scan wrote since the last pull. Guarantees:
|
||||||
|
#
|
||||||
|
# 1. Refuses to update over uncommitted tracked changes (clean-tree guard).
|
||||||
|
# 2. Fast-forwards only (--ff-only): never creates merge commits and never
|
||||||
|
# silently conflicts.
|
||||||
|
# 3. Runtime data (data/, reports/, *.db) is gitignored, so a running scan
|
||||||
|
# leaves no dirty tracked paths and never blocks the pull.
|
||||||
|
# 4. Reinstalls dependencies only if requirements.txt changed.
|
||||||
|
# 5. Applies DB migrations idempotently.
|
||||||
|
# 6. Restarts the app under its supervisor.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo deploy/deploy.sh # update + restart (default env: production)
|
||||||
|
# ENV=production deploy/deploy.sh # use deploy/ids@production.service
|
||||||
|
# deploy/deploy.sh --no-restart # update deps+migrate but don't restart
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ENV="${ENV:-production}"
|
||||||
|
RESTART="${1:-restart}"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
PY="${PY:-python}"
|
||||||
|
|
||||||
|
echo "== Intrusion Detection deploy (env=$ENV) =="
|
||||||
|
|
||||||
|
# 1) Clean-tree guard. Runtime data is gitignored, so this only fails if a
|
||||||
|
# human edited tracked source without committing.
|
||||||
|
if ! git diff --quiet HEAD || ! git diff --cached --quiet; then
|
||||||
|
echo "REFUSING: working tree is not clean." >&2
|
||||||
|
echo "Commit or stash tracked changes, then re-run." >&2
|
||||||
|
echo " git status --short" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) Pre-flight: prove runtime isolation holds (fail fast before touching repo).
|
||||||
|
if "$ROOT/deploy/check_runtime_git.sh"; then
|
||||||
|
echo "== runtime isolation ok =="
|
||||||
|
else
|
||||||
|
echo "ABORT: runtime isolation check failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) Fast-forward only.
|
||||||
|
echo "== fetching upstream =="
|
||||||
|
git fetch origin
|
||||||
|
echo "== fast-forward pull =="
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
# 4) Dependencies (only if the requirement set changed since the last pull).
|
||||||
|
if git diff --quiet HEAD@{1} HEAD -- requirements.txt; then
|
||||||
|
echo "== requirements.txt unchanged; skipping pip =="
|
||||||
|
else
|
||||||
|
echo "== requirements.txt changed; installing deps =="
|
||||||
|
command -v "$PY" >/dev/null && "$PY" -m pip install -r requirements.txt \
|
||||||
|
|| pip install -r requirements.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5) Apply DB migrations (idempotent).
|
||||||
|
echo "== applying DB migrations =="
|
||||||
|
"$PY" -m app.db --migrate
|
||||||
|
|
||||||
|
# 6) Restart under the supervisor.
|
||||||
|
if [[ "$RESTART" == "--no-restart" ]]; then
|
||||||
|
echo "== --no-restart: update complete; restart manually =="
|
||||||
|
else
|
||||||
|
echo "== restarting service =="
|
||||||
|
"$ROOT/deploy/restart-service.sh" "$ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== deploy complete =="
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# systemd unit template (D005) — intrusion-detection web UI.
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# - Replace /path/to/intrusion-detection and the User/Group.
|
||||||
|
# - Copy to /etc/systemd/system/ids.service
|
||||||
|
# - sudo systemctl daemon-reload && sudo systemctl enable --now ids
|
||||||
|
#
|
||||||
|
# The app binds to 127.0.0.1:<WEB_PORT>; it must NOT be exposed to the LAN.
|
||||||
|
#
|
||||||
|
# Restart policy: automatically restart on crash. `deploy/deploy.sh` restarts
|
||||||
|
# this unit after a successful `git pull`. Scans run in a background thread, so
|
||||||
|
# one (serialized) scan at a time; deploy waits for the worker to be idle
|
||||||
|
# (see restart-service.sh) before restarting.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Intrusion Detection web UI + IDS scanner
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Run as an unprivileged user; the app only needs read access to the log dirs
|
||||||
|
# it consumes (COPYPARTY_LOG_PATH / NPM_LOG_PATH) and write access to its own
|
||||||
|
# data/ + reports/ + ids.db.
|
||||||
|
User=ids
|
||||||
|
Group=ids
|
||||||
|
WorkingDirectory=/path/to/intrusion-detection
|
||||||
|
EnvironmentFile=-/etc/ids.env
|
||||||
|
ExecStart=/path/to/intrusion-detection/.venv/bin/python /path/to/intrusion-detection/app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=no
|
||||||
|
ReadWritePaths=/path/to/intrusion-detection/data /path/to/intrusion-detection/reports /var/lib/ids
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# D005 — supervisor-agnostic restart shim.
|
||||||
|
#
|
||||||
|
# Restarts the intrusion-detection service after a deploy, preferring systemd
|
||||||
|
# (Linux), with a fallback message for other environments (Windows dev).
|
||||||
|
#
|
||||||
|
# It first waits for any in-flight background scan to finish so a checkout
|
||||||
|
# change never interleaves with a live scan (belt-and-suspenders; the runtime
|
||||||
|
# isolation guarantee already means scans only write gitignored files).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# deploy/restart-service.sh [production]
|
||||||
|
# ENV=production deploy/restart-service.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ENV="${1:-${ENV:-production}}"
|
||||||
|
UNIT="ids@${ENV}.service"
|
||||||
|
|
||||||
|
# Wait for an in-flight scan to finish (~0..120s). We probe the web UI status
|
||||||
|
# endpoint if it is reachable; a failure to reach it just means no web UI is up.
|
||||||
|
web_host="${WEB_HOST:-127.0.0.1}"
|
||||||
|
web_port="${WEB_PORT:-54666}"
|
||||||
|
status_url="http://${web_host}:${web_port}/api/scan/status"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
running=$(curl -fsS --max-time 2 "$status_url" 2>/dev/null \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("running",False))' 2>/dev/null \
|
||||||
|
|| echo false)
|
||||||
|
[[ "$running" == "false" ]] && break
|
||||||
|
echo " waiting for running scan to finish..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
# Installed unit is <unit>.service.template; the enabled name includes env.
|
||||||
|
if systemctl list-unit-files "${UNIT}" >/dev/null 2>&1; then
|
||||||
|
echo "== systemctl restart ${UNIT} =="
|
||||||
|
systemctl restart "${UNIT}"
|
||||||
|
systemctl --no-pager status "${UNIT}" --lines=0 || true
|
||||||
|
elif systemctl list-unit-files "ids.service" >/dev/null 2>&1; then
|
||||||
|
echo "== systemctl restart ids.service =="
|
||||||
|
systemctl restart ids.service
|
||||||
|
systemctl --no-pager status ids.service --lines=0 || true
|
||||||
|
else
|
||||||
|
echo "No ids systemd unit found; restart manually." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No systemd available (Windows dev?). Restart the app manually." >&2
|
||||||
|
echo " python app.py # in a terminal, or via your dev runner" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
102.37.110.74/32
|
|
||||||
103.153.183.126/32
|
|
||||||
103.168.66.237/32
|
|
||||||
103.177.150.66/32
|
|
||||||
103.215.75.19/32
|
|
||||||
103.215.75.66/32
|
|
||||||
103.221.220.62/32
|
|
||||||
103.76.88.36/32
|
|
||||||
103.76.88.37/32
|
|
||||||
104.168.93.75/32
|
|
||||||
104.196.62.35/32
|
|
||||||
104.199.182.104/32
|
|
||||||
104.208.94.19/32
|
|
||||||
104.234.19.67/32
|
|
||||||
104.238.222.26/32
|
|
||||||
104.248.151.168/32
|
|
||||||
104.248.5.228/32
|
|
||||||
104.251.181.106/32
|
|
||||||
104.28.215.219/32
|
|
||||||
104.28.215.220/32
|
|
||||||
104.28.222.131/32
|
|
||||||
104.28.240.0/24
|
|
||||||
104.28.247.219/32
|
|
||||||
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
|
|
||||||
110.176.0.0/13
|
|
||||||
111.113.88.210/32
|
|
||||||
111.113.89.107/32
|
|
||||||
112.94.252.15/32
|
|
||||||
113.57.186.101/32
|
|
||||||
117.14.153.168/32
|
|
||||||
118.194.253.208/32
|
|
||||||
118.212.120.78/32
|
|
||||||
118.81.196.142/32
|
|
||||||
118.81.207.202/32
|
|
||||||
121.29.51.109/32
|
|
||||||
123.158.61.179/32
|
|
||||||
123.160.175.254/32
|
|
||||||
123.163.114.150/32
|
|
||||||
123.178.210.228/32
|
|
||||||
124.117.194.69/32
|
|
||||||
124.88.113.10/32
|
|
||||||
128.199.182.152/32
|
|
||||||
128.199.182.55/32
|
|
||||||
128.90.128.13/32
|
|
||||||
128.90.141.10/32
|
|
||||||
129.212.237.196/32
|
|
||||||
13.126.86.18/32
|
|
||||||
13.221.106.198/32
|
|
||||||
13.57.15.41/32
|
|
||||||
13.83.161.80/32
|
|
||||||
130.12.180.126/32
|
|
||||||
130.12.180.39/32
|
|
||||||
130.12.182.12/32
|
|
||||||
130.12.182.254/32
|
|
||||||
130.131.231.171/32
|
|
||||||
132.196.11.13/32
|
|
||||||
132.196.83.33/32
|
|
||||||
134.122.94.138/32
|
|
||||||
134.199.148.147/32
|
|
||||||
134.209.162.128/32
|
|
||||||
134.209.185.72/32
|
|
||||||
134.209.25.199/32
|
|
||||||
135.119.63.61/32
|
|
||||||
135.119.74.228/32
|
|
||||||
135.225.93.215/32
|
|
||||||
135.232.177.160/32
|
|
||||||
136.107.208.33/32
|
|
||||||
136.107.217.8/32
|
|
||||||
136.108.11.195/32
|
|
||||||
136.108.15.119/32
|
|
||||||
136.109.87.37/32
|
|
||||||
136.110.80.8/32
|
|
||||||
136.115.17.125/32
|
|
||||||
136.116.215.248/32
|
|
||||||
136.144.19.51/32
|
|
||||||
136.144.33.102/32
|
|
||||||
136.144.35.201/32
|
|
||||||
136.144.43.127/32
|
|
||||||
136.64.132.60/32
|
|
||||||
136.64.51.224/32
|
|
||||||
136.66.128.85/32
|
|
||||||
136.66.153.34/32
|
|
||||||
136.70.149.147/32
|
|
||||||
138.124.103.234/32
|
|
||||||
138.197.104.211/32
|
|
||||||
138.197.191.87/32
|
|
||||||
138.197.34.12/32
|
|
||||||
139.28.219.70/32
|
|
||||||
139.59.118.17/32
|
|
||||||
139.59.231.238/32
|
|
||||||
140.248.75.106/32
|
|
||||||
141.148.153.213/32
|
|
||||||
141.98.11.171/32
|
|
||||||
142.111.152.16/32
|
|
||||||
142.111.152.57/32
|
|
||||||
142.93.155.36/32
|
|
||||||
142.93.62.168/32
|
|
||||||
143.110.178.165/32
|
|
||||||
143.110.217.244/32
|
|
||||||
143.198.25.99/32
|
|
||||||
143.198.6.225/32
|
|
||||||
143.20.64.236/32
|
|
||||||
143.244.162.58/32
|
|
||||||
143.244.168.161/32
|
|
||||||
143.244.186.167/32
|
|
||||||
143.244.56.0/23
|
|
||||||
143.244.57.123/32
|
|
||||||
144.126.207.57/32
|
|
||||||
144.172.100.174/32
|
|
||||||
144.172.101.168/32
|
|
||||||
144.172.93.117/32
|
|
||||||
144.172.97.164/32
|
|
||||||
144.76.19.27/32
|
|
||||||
145.132.103.19/32
|
|
||||||
146.190.63.248/32
|
|
||||||
146.190.63.48/32
|
|
||||||
146.190.83.166/32
|
|
||||||
146.70.194.0/24
|
|
||||||
146.70.40.68/32
|
|
||||||
147.182.149.75/32
|
|
||||||
147.93.168.0/22
|
|
||||||
147.93.171.188/32
|
|
||||||
148.251.182.79/32
|
|
||||||
149.56.150.98/32
|
|
||||||
15.206.146.240/32
|
|
||||||
150.255.94.250/32
|
|
||||||
151.240.45.69/32
|
|
||||||
152.39.198.115/32
|
|
||||||
152.42.137.175/32
|
|
||||||
152.53.242.93/32
|
|
||||||
153.75.89.249/32
|
|
||||||
155.2.215.98/32
|
|
||||||
157.143.3.35/32
|
|
||||||
157.245.105.107/32
|
|
||||||
157.245.113.227/32
|
|
||||||
157.245.197.36/32
|
|
||||||
157.245.204.205/32
|
|
||||||
157.245.63.62/32
|
|
||||||
158.101.144.198/32
|
|
||||||
158.158.0.0/16
|
|
||||||
158.51.126.144/32
|
|
||||||
159.203.13.62/32
|
|
||||||
159.223.132.86/32
|
|
||||||
159.223.79.25/32
|
|
||||||
159.65.10.116/32
|
|
||||||
159.65.144.72/32
|
|
||||||
159.89.117.109/32
|
|
||||||
159.89.12.166/32
|
|
||||||
159.89.123.252/32
|
|
||||||
159.89.174.87/32
|
|
||||||
159.89.46.165/32
|
|
||||||
159.89.97.19/32
|
|
||||||
16.146.99.254/32
|
|
||||||
160.179.166.124/32
|
|
||||||
160.179.166.222/32
|
|
||||||
161.118.251.181/32
|
|
||||||
162.141.167.14/32
|
|
||||||
162.141.167.71/32
|
|
||||||
162.243.79.174/32
|
|
||||||
163.7.11.71/32
|
|
||||||
163.7.12.0/24
|
|
||||||
163.7.13.0/24
|
|
||||||
164.90.169.163/32
|
|
||||||
165.22.147.152/32
|
|
||||||
165.22.235.3/32
|
|
||||||
165.22.24.78/32
|
|
||||||
165.245.185.158/32
|
|
||||||
165.245.190.205/32
|
|
||||||
167.172.192.26/32
|
|
||||||
167.172.31.131/32
|
|
||||||
167.172.53.28/32
|
|
||||||
167.71.0.47/32
|
|
||||||
167.71.39.61/32
|
|
||||||
167.86.82.167/32
|
|
||||||
167.99.181.249/32
|
|
||||||
167.99.182.39/32
|
|
||||||
167.99.204.6/32
|
|
||||||
167.99.210.137/32
|
|
||||||
168.144.100.227/32
|
|
||||||
168.144.106.212/32
|
|
||||||
168.144.167.238/32
|
|
||||||
168.144.174.42/32
|
|
||||||
168.144.83.150/32
|
|
||||||
168.62.180.116/32
|
|
||||||
170.64.144.116/32
|
|
||||||
170.64.156.114/32
|
|
||||||
171.104.81.195/32
|
|
||||||
172.170.254.20/32
|
|
||||||
172.171.198.214/32
|
|
||||||
172.196.32.64/32
|
|
||||||
172.200.0.0/13
|
|
||||||
172.208.0.0/13
|
|
||||||
172.213.216.126/32
|
|
||||||
172.232.175.132/32
|
|
||||||
172.245.102.64/32
|
|
||||||
172.86.119.249/32
|
|
||||||
172.86.122.106/32
|
|
||||||
172.98.33.251/32
|
|
||||||
173.239.196.33/32
|
|
||||||
173.239.196.35/32
|
|
||||||
173.239.240.222/32
|
|
||||||
173.249.34.197/32
|
|
||||||
176.227.240.17/32
|
|
||||||
176.227.240.61/32
|
|
||||||
178.128.176.132/32
|
|
||||||
178.16.54.137/32
|
|
||||||
178.62.196.238/32
|
|
||||||
179.43.134.114/32
|
|
||||||
18.142.90.109/32
|
|
||||||
18.191.56.164/32
|
|
||||||
18.227.73.7/32
|
|
||||||
18.236.136.230/32
|
|
||||||
180.95.231.78/32
|
|
||||||
182.119.230.145/32
|
|
||||||
182.88.226.154/32
|
|
||||||
185.151.21.124/32
|
|
||||||
185.177.72.0/24
|
|
||||||
185.177.72.29/32
|
|
||||||
185.192.71.0/24
|
|
||||||
185.198.240.238/32
|
|
||||||
185.213.175.168/32
|
|
||||||
185.223.152.0/24
|
|
||||||
185.238.231.167/32
|
|
||||||
185.251.19.30/32
|
|
||||||
185.93.89.147/32
|
|
||||||
185.93.89.167/32
|
|
||||||
188.166.245.173/32
|
|
||||||
188.213.202.0/24
|
|
||||||
191.232.0.0/13
|
|
||||||
192.133.76.0/23
|
|
||||||
192.133.77.15/32
|
|
||||||
192.253.248.142/32
|
|
||||||
192.253.248.7/32
|
|
||||||
192.34.61.236/32
|
|
||||||
193.143.1.112/32
|
|
||||||
193.148.56.148/32
|
|
||||||
193.148.57.33/32
|
|
||||||
193.149.176.55/32
|
|
||||||
193.19.109.0/24
|
|
||||||
193.36.224.57/32
|
|
||||||
193.36.225.0/24
|
|
||||||
193.37.32.0/24
|
|
||||||
193.37.33.0/24
|
|
||||||
193.47.62.168/32
|
|
||||||
194.102.104.29/32
|
|
||||||
194.156.79.68/32
|
|
||||||
194.233.91.21/32
|
|
||||||
194.26.192.156/32
|
|
||||||
194.32.120.151/32
|
|
||||||
194.5.82.0/24
|
|
||||||
195.178.110.0/24
|
|
||||||
195.178.110.211/32
|
|
||||||
195.178.110.223/32
|
|
||||||
198.98.52.229/32
|
|
||||||
199.16.157.0/24
|
|
||||||
2.57.122.13/32
|
|
||||||
2.57.122.173/32
|
|
||||||
2.58.56.101/32
|
|
||||||
20.0.0.0/11
|
|
||||||
20.150.0.0/15
|
|
||||||
20.160.0.0/12
|
|
||||||
20.184.150.240/32
|
|
||||||
20.192.0.0/10
|
|
||||||
20.215.189.213/32
|
|
||||||
20.46.121.148/32
|
|
||||||
20.46.252.52/32
|
|
||||||
20.48.0.0/12
|
|
||||||
20.64.0.0/10
|
|
||||||
200.162.144.0/20
|
|
||||||
201.78.128.89/32
|
|
||||||
203.159.90.217/32
|
|
||||||
206.189.19.19/32
|
|
||||||
206.189.233.36/32
|
|
||||||
206.189.95.232/32
|
|
||||||
206.81.12.187/32
|
|
||||||
206.81.24.227/32
|
|
||||||
207.154.197.113/32
|
|
||||||
207.241.173.229/32
|
|
||||||
208.84.102.195/32
|
|
||||||
209.126.2.173/32
|
|
||||||
209.87.169.0/24
|
|
||||||
209.97.166.251/32
|
|
||||||
209.99.184.0/21
|
|
||||||
211.175.22.56/32
|
|
||||||
213.111.158.220/32
|
|
||||||
213.35.113.47/32
|
|
||||||
216.24.210.46/32
|
|
||||||
216.24.212.0/24
|
|
||||||
216.26.233.39/32
|
|
||||||
216.73.163.0/24
|
|
||||||
216.73.216.0/22
|
|
||||||
216.73.217.176/32
|
|
||||||
216.81.248.20/32
|
|
||||||
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
|
|
||||||
23.161.169.127/32
|
|
||||||
23.161.169.62/32
|
|
||||||
23.178.112.0/24
|
|
||||||
23.180.120.0/24
|
|
||||||
23.234.98.171/32
|
|
||||||
23.94.155.57/32
|
|
||||||
23.95.226.104/32
|
|
||||||
24.144.85.55/32
|
|
||||||
3.101.54.4/32
|
|
||||||
3.107.107.18/32
|
|
||||||
3.237.185.144/32
|
|
||||||
3.250.52.216/32
|
|
||||||
3.27.206.50/32
|
|
||||||
3.77.92.222/32
|
|
||||||
3.79.41.12/32
|
|
||||||
31.184.236.8/32
|
|
||||||
31.56.58.0/24
|
|
||||||
31.58.51.130/32
|
|
||||||
31.58.51.184/32
|
|
||||||
31.59.129.193/32
|
|
||||||
34.105.30.251/32
|
|
||||||
34.11.108.163/32
|
|
||||||
34.11.127.22/32
|
|
||||||
34.116.255.65/32
|
|
||||||
34.118.184.2/32
|
|
||||||
34.125.213.146/32
|
|
||||||
34.125.219.56/32
|
|
||||||
34.125.42.15/32
|
|
||||||
34.131.251.163/32
|
|
||||||
34.139.141.56/32
|
|
||||||
34.141.160.140/32
|
|
||||||
34.145.62.28/32
|
|
||||||
34.146.12.12/32
|
|
||||||
34.148.8.63/32
|
|
||||||
34.148.98.62/32
|
|
||||||
34.154.122.183/32
|
|
||||||
34.158.83.192/32
|
|
||||||
34.17.159.73/32
|
|
||||||
34.171.75.208/32
|
|
||||||
34.175.82.8/32
|
|
||||||
34.176.154.83/32
|
|
||||||
34.18.38.137/32
|
|
||||||
34.181.134.189/32
|
|
||||||
34.186.83.134/32
|
|
||||||
34.187.20.70/32
|
|
||||||
34.187.200.119/32
|
|
||||||
34.187.82.183/32
|
|
||||||
34.21.144.169/32
|
|
||||||
34.23.104.90/32
|
|
||||||
34.24.155.203/32
|
|
||||||
34.242.226.103/32
|
|
||||||
34.244.177.56/32
|
|
||||||
34.26.191.118/32
|
|
||||||
34.26.219.248/32
|
|
||||||
34.31.88.250/32
|
|
||||||
34.39.210.94/32
|
|
||||||
34.40.240.124/32
|
|
||||||
34.42.48.100/32
|
|
||||||
34.47.183.174/32
|
|
||||||
34.47.194.183/32
|
|
||||||
34.55.200.45/32
|
|
||||||
34.57.157.110/32
|
|
||||||
34.59.187.134/32
|
|
||||||
34.62.38.199/32
|
|
||||||
34.64.173.205/32
|
|
||||||
34.65.138.195/32
|
|
||||||
34.65.14.246/32
|
|
||||||
34.65.18.71/32
|
|
||||||
34.69.107.154/32
|
|
||||||
34.7.160.235/32
|
|
||||||
34.7.193.250/32
|
|
||||||
34.73.128.17/32
|
|
||||||
34.74.203.130/32
|
|
||||||
34.75.4.161/32
|
|
||||||
34.82.10.34/32
|
|
||||||
34.83.69.198/32
|
|
||||||
34.84.59.119/32
|
|
||||||
34.86.126.198/32
|
|
||||||
34.86.217.208/32
|
|
||||||
34.90.87.32/32
|
|
||||||
34.90.88.37/32
|
|
||||||
34.91.243.226/32
|
|
||||||
34.94.172.72/32
|
|
||||||
34.95.238.121/32
|
|
||||||
34.95.239.96/32
|
|
||||||
35.157.251.190/32
|
|
||||||
35.182.17.8/32
|
|
||||||
35.184.157.121/32
|
|
||||||
35.185.21.88/32
|
|
||||||
35.187.221.219/32
|
|
||||||
35.187.9.30/32
|
|
||||||
35.192.232.168/32
|
|
||||||
35.196.22.146/32
|
|
||||||
35.198.180.224/32
|
|
||||||
35.199.111.12/32
|
|
||||||
35.199.115.51/32
|
|
||||||
35.199.183.190/32
|
|
||||||
35.201.159.247/32
|
|
||||||
35.204.141.252/32
|
|
||||||
35.204.88.252/32
|
|
||||||
35.230.52.27/32
|
|
||||||
35.231.0.0/17
|
|
||||||
35.233.191.179/32
|
|
||||||
35.237.138.104/32
|
|
||||||
35.237.178.50/32
|
|
||||||
35.243.173.211/32
|
|
||||||
35.243.223.147/32
|
|
||||||
35.255.40.65/32
|
|
||||||
35.95.56.230/32
|
|
||||||
36.106.166.223/32
|
|
||||||
36.106.166.92/32
|
|
||||||
37.140.223.58/32
|
|
||||||
37.140.223.6/32
|
|
||||||
38.248.95.248/32
|
|
||||||
4.176.0.0/12
|
|
||||||
4.192.0.0/12
|
|
||||||
4.208.0.0/12
|
|
||||||
4.224.0.0/12
|
|
||||||
4.246.70.112/32
|
|
||||||
41.143.154.1/32
|
|
||||||
43.228.157.102/32
|
|
||||||
43.228.157.191/32
|
|
||||||
43.98.201.177/32
|
|
||||||
44.224.0.0/11
|
|
||||||
45.130.203.154/32
|
|
||||||
45.130.203.177/32
|
|
||||||
45.131.193.139/32
|
|
||||||
45.131.195.0/24
|
|
||||||
45.134.20.116/32
|
|
||||||
45.134.20.128/32
|
|
||||||
45.138.16.114/32
|
|
||||||
45.139.104.172/32
|
|
||||||
45.146.54.137/32
|
|
||||||
45.146.54.80/32
|
|
||||||
45.146.55.0/24
|
|
||||||
45.148.10.0/24
|
|
||||||
45.153.34.16/32
|
|
||||||
45.153.34.182/32
|
|
||||||
45.153.34.43/32
|
|
||||||
45.154.138.181/32
|
|
||||||
45.154.138.232/32
|
|
||||||
45.154.98.0/24
|
|
||||||
45.45.237.214/32
|
|
||||||
45.45.237.69/32
|
|
||||||
45.61.150.67/32
|
|
||||||
45.61.187.30/32
|
|
||||||
45.67.85.190/32
|
|
||||||
45.8.196.200/32
|
|
||||||
45.88.138.44/32
|
|
||||||
46.23.108.234/32
|
|
||||||
46.232.235.0/24
|
|
||||||
47.85.160.47/32
|
|
||||||
5.161.225.116/32
|
|
||||||
5.161.62.209/32
|
|
||||||
5.253.247.16/32
|
|
||||||
5.255.119.161/32
|
|
||||||
5.34.213.142/32
|
|
||||||
51.103.133.211/32
|
|
||||||
51.103.152.96/32
|
|
||||||
51.116.233.22/32
|
|
||||||
51.120.0.0/16
|
|
||||||
51.79.208.246/32
|
|
||||||
52.139.36.144/32
|
|
||||||
52.159.244.176/32
|
|
||||||
52.160.0.0/11
|
|
||||||
52.224.0.0/11
|
|
||||||
52.64.227.143/32
|
|
||||||
54.163.186.39/32
|
|
||||||
54.169.103.174/32
|
|
||||||
54.179.129.22/32
|
|
||||||
54.206.147.115/32
|
|
||||||
54.210.219.163/32
|
|
||||||
54.216.227.171/32
|
|
||||||
54.251.19.144/32
|
|
||||||
54.82.69.43/32
|
|
||||||
58.212.237.231/32
|
|
||||||
62.60.130.128/32
|
|
||||||
62.60.130.227/32
|
|
||||||
62.93.179.146/32
|
|
||||||
64.225.63.129/32
|
|
||||||
64.225.75.246/32
|
|
||||||
64.226.123.37/32
|
|
||||||
64.227.32.66/32
|
|
||||||
64.23.218.208/32
|
|
||||||
64.89.161.82/32
|
|
||||||
64.89.161.85/32
|
|
||||||
65.0.122.218/32
|
|
||||||
66.187.5.228/32
|
|
||||||
66.187.6.102/32
|
|
||||||
66.228.38.221/32
|
|
||||||
66.249.64.0/20
|
|
||||||
68.183.180.73/32
|
|
||||||
68.183.9.16/32
|
|
||||||
68.220.0.0/15
|
|
||||||
69.164.220.232/32
|
|
||||||
69.164.220.85/32
|
|
||||||
69.30.85.194/32
|
|
||||||
72.146.20.230/32
|
|
||||||
72.146.33.199/32
|
|
||||||
73.116.194.16/32
|
|
||||||
74.208.40.151/32
|
|
||||||
74.248.0.0/15
|
|
||||||
74.248.33.172/32
|
|
||||||
74.7.0.0/16
|
|
||||||
77.90.185.230/32
|
|
||||||
8.229.1.130/32
|
|
||||||
8.229.130.50/32
|
|
||||||
8.231.78.19/32
|
|
||||||
8.234.157.73/32
|
|
||||||
8.234.207.247/32
|
|
||||||
8.235.114.162/32
|
|
||||||
80.91.86.169/32
|
|
||||||
80.94.92.167/32
|
|
||||||
81.171.72.135/32
|
|
||||||
81.171.72.93/32
|
|
||||||
81.171.74.60/32
|
|
||||||
82.102.18.0/24
|
|
||||||
82.102.18.190/32
|
|
||||||
82.165.82.154/32
|
|
||||||
82.39.206.154/32
|
|
||||||
84.17.60.251/32
|
|
||||||
85.11.167.225/32
|
|
||||||
85.204.70.0/24
|
|
||||||
87.121.79.42/32
|
|
||||||
88.184.210.4/32
|
|
||||||
9.205.16.190/32
|
|
||||||
91.124.88.29/32
|
|
||||||
91.124.88.36/32
|
|
||||||
91.134.35.95/32
|
|
||||||
91.148.244.131/32
|
|
||||||
91.148.245.81/32
|
|
||||||
91.192.10.243/32
|
|
||||||
91.192.10.86/32
|
|
||||||
91.193.232.0/24
|
|
||||||
91.224.92.159/32
|
|
||||||
91.224.92.190/32
|
|
||||||
91.230.225.0/24
|
|
||||||
91.239.79.146/32
|
|
||||||
91.92.241.196/32
|
|
||||||
91.92.243.245/32
|
|
||||||
91.92.41.115/32
|
|
||||||
91.92.42.86/32
|
|
||||||
91.92.47.0/24
|
|
||||||
92.118.39.126/32
|
|
||||||
92.118.39.29/32
|
|
||||||
92.119.121.196/32
|
|
||||||
92.165.170.72/32
|
|
||||||
93.123.109.0/24
|
|
||||||
93.152.221.13/32
|
|
||||||
94.154.43.0/24
|
|
||||||
95.85.238.18/32
|
|
||||||
98.159.36.147/32
|
|
||||||
98.159.37.147/32
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
2001:4ca0:108:42::24/128
|
|
||||||
2001:ba0:24c:a500::1/128
|
|
||||||
2400:8901::2000:62ff:fe9a:e1c2/128
|
|
||||||
2400:8901::2000:beff:fea3:5a44/128
|
|
||||||
2406:da18:525:0:a23e:e8f6:3ea1:4c2c/128
|
|
||||||
2600:3000::/32
|
|
||||||
2600:3c09::2000:5cff:fe99:c802/128
|
|
||||||
2600:3c15::2000:53ff:fe31:8c0b/128
|
|
||||||
2600:3c15::2000:9cff:fef0:8ad6/128
|
|
||||||
2602:fa59::/40
|
|
||||||
2a00:6340:2008:ec80:64cc:9116:beb2:1931/128
|
|
||||||
2a00:6340:2008:ec80:be24:11ff:fe3b:4058/128
|
|
||||||
2a00:6340:2008:ec80:be24:11ff:fe82:2775/128
|
|
||||||
2a01:2a8:a0dd:1::1012/128
|
|
||||||
2a01:4f8:c014:7f9e::1/128
|
|
||||||
2a04:4e40:4400:0:537:cdd2:7250:95db/128
|
|
||||||
2a04:4e40:e000::/48
|
|
||||||
2a04:c300::/29
|
|
||||||
2a05:4140:3::37cd/128
|
|
||||||
2a06:a880:5:7fb3::1/128
|
|
||||||
2a09:bac1:76c0:460::5e:6a/128
|
|
||||||
2a09:bac5::/32
|
|
||||||
2a0a:4cc0:2000:8528:c4e8:3ff:febb:3ee9/128
|
|
||||||
2a13:adc0::2ed:89ff:fe58:19c5/128
|
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
|
flask
|
||||||
paramiko
|
paramiko
|
||||||
python-dotenv
|
python-dotenv
|
||||||
flask
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Starts the flask application in a virtual environment.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script activates the virtual environment and starts the flask application.
|
||||||
|
It only runs a single threaded instance of the application, so it is not suitable for production use.
|
||||||
|
#>
|
||||||
|
|
||||||
|
## Check if the virtual environment exists
|
||||||
|
if (-Not (Test-Path -Path ".\.venv\Scripts\Activate.ps1")) {
|
||||||
|
Write-Host "Virtual environment not found. Please create it first."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
## Activate the virtual environment
|
||||||
|
& .\.venv\Scripts\Activate.ps1
|
||||||
|
|
||||||
|
## Start the Flask application
|
||||||
|
Write-Host "Starting the Flask application..."
|
||||||
|
& .\.venv\Scripts\python.exe .\app.py
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deployment invariant tests (D001).
|
||||||
|
|
||||||
|
Proves that running a scan never creates or modifies git-tracked runtime data,
|
||||||
|
which is what makes ``git pull`` conflict-free on a deployed server.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python -m unittest discover -s tests -p 'test_*.py'
|
||||||
|
or (if pytest is installed):
|
||||||
|
python -m pytest tests/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
# The paloalto scanner-ips.txt seed is deliberately tracked (read-only); every
|
||||||
|
# other file under the runtime dirs must remain untracked.
|
||||||
|
ALLOWED_TRACKED_RUNTIME = {os.path.join("app", "data", "paloalto",
|
||||||
|
"scanner-ips.txt")}
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args: str) -> str:
|
||||||
|
return subprocess.run(["git", *args], cwd=ROOT, check=True,
|
||||||
|
capture_output=True, text=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeGitTest(unittest.TestCase):
|
||||||
|
def test_no_runtime_data_tracked(self):
|
||||||
|
"""No runtime file (other than the read-only paloalto seed) is tracked."""
|
||||||
|
tracked = _git("ls-files", "app/data", "app/reports").splitlines()
|
||||||
|
unexpected = [f for f in tracked
|
||||||
|
if f not in ALLOWED_TRACKED_RUNTIME]
|
||||||
|
self.assertEqual(
|
||||||
|
unexpected, [],
|
||||||
|
"git-tracked runtime files would make `git pull` conflict; "
|
||||||
|
"add them to .gitignore: %s" % unexpected)
|
||||||
|
|
||||||
|
def test_runtime_write_paths_ignored(self):
|
||||||
|
"""Every runtime write path is ignored by git (won't dirty the tree)."""
|
||||||
|
stats = subprocess.run(
|
||||||
|
["git", "check-ignore", "-v",
|
||||||
|
"app/data/scan_state.json", "app/data/scan_jobs.json",
|
||||||
|
"app/data/whois_cache.json", "app/data/ids.db",
|
||||||
|
"app/data/ids.db-wal", "app/reports/app/data/blocklist_ipv4.txt",
|
||||||
|
], cwd=ROOT, capture_output=True, text=True).stdout
|
||||||
|
self.assertIn("scan_state.json", stats)
|
||||||
|
self.assertIn("ids.db", stats)
|
||||||
|
self.assertIn("blocklist_ipv4.txt", stats)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for evidence-first firewall sync behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.firewall import MikrotikFirewall
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFirewall(MikrotikFirewall):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(router_ip="0.0.0.0", username="x", password="x")
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def get_address_list_ipv4(self, name: str) -> set[str]:
|
||||||
|
self.calls.append(f"v4:{name}")
|
||||||
|
if name == "Blocked":
|
||||||
|
return {"9.9.9.9/32"}
|
||||||
|
if name == "Init7":
|
||||||
|
return {"77.74.80.0/21"}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def get_address_list_ipv6(self, name: str) -> set[str]:
|
||||||
|
self.calls.append(f"v6:{name}")
|
||||||
|
if name == "Blocked":
|
||||||
|
return set()
|
||||||
|
if name == "Init7":
|
||||||
|
return {"2001:1620::/30"}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallSyncEvidenceTest(unittest.TestCase):
|
||||||
|
def test_sync_loads_router_status_before_computing_changes(self):
|
||||||
|
fw = _FakeFirewall()
|
||||||
|
# type: ignore[assignment]
|
||||||
|
fw.save_state_report = lambda *args, **kwargs: "noop"
|
||||||
|
|
||||||
|
with patch("app.firewall.cumulative_blocklist",
|
||||||
|
return_value=({"8.8.8.8/32"}, set())):
|
||||||
|
changes = fw.sync_blocklist(["8.8.8.8/32"], [], dry_run=True)
|
||||||
|
|
||||||
|
self.assertIn("v4:Blocked", fw.calls)
|
||||||
|
self.assertIn("v6:Blocked", fw.calls)
|
||||||
|
self.assertIn("v4:Init7", fw.calls)
|
||||||
|
self.assertIn("v6:Init7", fw.calls)
|
||||||
|
self.assertEqual(changes["added_ipv4"], ["8.8.8.8/32"])
|
||||||
|
self.assertEqual(changes["removed_ipv4"], ["9.9.9.9/32"])
|
||||||
|
|
||||||
|
def test_sync_evaluates_against_loaded_current_status(self):
|
||||||
|
fw = _FakeFirewall()
|
||||||
|
# type: ignore[assignment]
|
||||||
|
fw.save_state_report = lambda *args, **kwargs: "noop"
|
||||||
|
|
||||||
|
with patch("app.firewall.cumulative_blocklist",
|
||||||
|
return_value=({"9.9.9.9/32"}, set())):
|
||||||
|
changes = fw.sync_blocklist(["9.9.9.9/32"], [], dry_run=True)
|
||||||
|
|
||||||
|
self.assertEqual(changes["added_ipv4"], [])
|
||||||
|
self.assertEqual(changes["removed_ipv4"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for legacy report artifact backfill."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.db import connect
|
||||||
|
from app.db.backfill import backfill_report_artifacts
|
||||||
|
from app.db import repositories as repo
|
||||||
|
|
||||||
|
|
||||||
|
class ReportBackfillTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.conn = connect(str(self.root / "test.db"))
|
||||||
|
self.roots = {
|
||||||
|
"copyparty": str(self.root / "copyparty"),
|
||||||
|
"npm": str(self.root / "npm"),
|
||||||
|
"app": str(self.root / "app"),
|
||||||
|
"mikrotik": str(self.root / "mikrotik"),
|
||||||
|
}
|
||||||
|
for path in self.roots.values():
|
||||||
|
Path(path).mkdir(parents=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_dry_run_and_import_are_idempotent(self):
|
||||||
|
Path(self.roots["copyparty"], "2026-11-06-11-30-10.txt").write_text(
|
||||||
|
"copyparty summary\n", encoding="utf-8")
|
||||||
|
Path(self.roots["npm"], "2026-11-06.csv").write_text(
|
||||||
|
"ip,count\n192.0.2.1,3\n", encoding="utf-8")
|
||||||
|
Path(self.roots["app"], "2026-11-06.json").write_text(
|
||||||
|
'{"summary":{"blocked":1}}', encoding="utf-8")
|
||||||
|
Path(self.roots["app"], "data").mkdir()
|
||||||
|
Path(self.roots["app"], "data", "requests.csv").write_text(
|
||||||
|
"ip\n192.0.2.2\n", encoding="utf-8")
|
||||||
|
|
||||||
|
preview = backfill_report_artifacts(
|
||||||
|
self.conn, dry_run=True, roots=self.roots)["reports"]
|
||||||
|
self.assertEqual(preview["would_import"], 3)
|
||||||
|
self.assertEqual(preview["skipped_canonical"], [
|
||||||
|
{"source": "app", "path": "data/requests.csv"}])
|
||||||
|
self.assertEqual(preview["by_source"]["copyparty"]["would_import"], 1)
|
||||||
|
self.assertEqual(preview["by_format"]["csv"]["would_import"], 1)
|
||||||
|
self.assertEqual(repo.list_report_artifacts(self.conn)["items"], [])
|
||||||
|
|
||||||
|
imported = backfill_report_artifacts(
|
||||||
|
self.conn, roots=self.roots)["reports"]
|
||||||
|
self.assertEqual(imported["imported"], 3)
|
||||||
|
self.assertEqual(imported["unmapped"], [])
|
||||||
|
|
||||||
|
repeated = backfill_report_artifacts(
|
||||||
|
self.conn, roots=self.roots)["reports"]
|
||||||
|
self.assertEqual(repeated["imported"], 0)
|
||||||
|
self.assertEqual(repeated["already_imported"], 3)
|
||||||
|
|
||||||
|
items = repo.list_report_artifacts(
|
||||||
|
self.conn, source="copyparty")["items"]
|
||||||
|
self.assertEqual(items[0]["original_path"],
|
||||||
|
"copyparty/2026-11-06-11-30-10.txt")
|
||||||
|
self.assertEqual(items[0]["generated_at"],
|
||||||
|
"2026-11-06T11:30:10+00:00")
|
||||||
|
|
||||||
|
def test_malformed_json_is_preserved_with_parse_error(self):
|
||||||
|
Path(self.roots["app"], "2026-11-06.json").write_text(
|
||||||
|
'{"incomplete":', encoding="utf-8")
|
||||||
|
|
||||||
|
result = backfill_report_artifacts(
|
||||||
|
self.conn, roots=self.roots)["reports"]
|
||||||
|
self.assertEqual(result["imported"], 1)
|
||||||
|
artifact = repo.list_report_artifacts(
|
||||||
|
self.conn, source="app")["items"][0]
|
||||||
|
detail = repo.get_report_artifact(self.conn, artifact["id"])
|
||||||
|
self.assertEqual(detail["payload"]["content"], '{"incomplete":')
|
||||||
|
self.assertIn("parse_error", detail["payload"])
|
||||||
|
|
||||||
|
def test_artifacts_survive_sqlite_backup_and_restore(self):
|
||||||
|
artifact_id = repo.insert_report_artifact(
|
||||||
|
self.conn, source="app", report_type="summary",
|
||||||
|
generated_at="2026-11-06T00:00:00+00:00",
|
||||||
|
payload={"summary": {"count": 4}},
|
||||||
|
original_path="app/2026-11-06.json", content_sha256="c" * 64,
|
||||||
|
)
|
||||||
|
restored = connect(str(self.root / "restored.db"))
|
||||||
|
try:
|
||||||
|
self.conn.backup(restored)
|
||||||
|
artifact = repo.get_report_artifact(restored, artifact_id)
|
||||||
|
self.assertEqual(artifact["payload"], {"summary": {"count": 4}})
|
||||||
|
finally:
|
||||||
|
restored.close()
|
||||||
|
|
||||||
|
def test_oversized_file_is_reported_without_deleting_source(self):
|
||||||
|
source = Path(self.roots["npm"], "large.txt")
|
||||||
|
source.write_text("keep", encoding="utf-8")
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("app.db.backfill.MAX_REPORT_ARTIFACT_BYTES", 1):
|
||||||
|
result = backfill_report_artifacts(
|
||||||
|
self.conn, roots=self.roots)["reports"]
|
||||||
|
|
||||||
|
self.assertEqual(len(result["unmapped"]), 1)
|
||||||
|
self.assertTrue(source.exists())
|
||||||
|
self.assertEqual(result["unmapped"][0]["path"], "large.txt")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for dual-writing generated reports to SQLite."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import app.scan
|
||||||
|
from app.analyzer import Analyzer
|
||||||
|
from app.db import connect
|
||||||
|
from app.db import repositories as repo
|
||||||
|
from app.utils.blocklist_history import write_blocklist_report
|
||||||
|
|
||||||
|
|
||||||
|
class ReportPersistenceTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.conn = connect(str(self.root / "test.db"))
|
||||||
|
self.connection_patch = patch("app.db.get_connection",
|
||||||
|
return_value=self.conn)
|
||||||
|
self.connection_patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.connection_patch.stop()
|
||||||
|
self.conn.close()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_analyzer_summary_is_stored_with_file_provenance(self):
|
||||||
|
report_dir = self.root / "copyparty"
|
||||||
|
report_dir.mkdir()
|
||||||
|
path = report_dir / "2026-11-06-11-30-10.txt"
|
||||||
|
path.write_text("summary body", encoding="utf-8")
|
||||||
|
analyzer = Analyzer("logs", str(report_dir), quiet=True)
|
||||||
|
|
||||||
|
analyzer.persist_report_artifact(str(path), scan_id=14)
|
||||||
|
|
||||||
|
item = repo.list_report_artifacts(
|
||||||
|
self.conn, source="ids")["items"][0]
|
||||||
|
detail = repo.get_report_artifact(self.conn, item["id"])
|
||||||
|
self.assertEqual(item["original_path"], "ids/2026-11-06-11-30-10.txt")
|
||||||
|
self.assertEqual(detail["scan_id"], 14)
|
||||||
|
self.assertEqual(detail["payload"]["content"], "summary body")
|
||||||
|
|
||||||
|
def test_blocklist_report_is_stored_as_structured_artifact(self):
|
||||||
|
path = write_blocklist_report(
|
||||||
|
str(self.root / "reports"), history_v4=set(), history_v6=set(),
|
||||||
|
prev_v4=set(), prev_v6=set(),
|
||||||
|
complete_v4={"192.0.2.1/32"}, complete_v6=set(),
|
||||||
|
firewall_changes={"added_ipv4": ["192.0.2.1/32"]},
|
||||||
|
dry_run=True, scan_id=22,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = repo.list_report_artifacts(
|
||||||
|
self.conn, source="app", report_type="blocklist_change")["items"]
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
detail = repo.get_report_artifact(self.conn, items[0]["id"])
|
||||||
|
self.assertEqual(detail["scan_id"], 22)
|
||||||
|
self.assertEqual(detail["payload"]["blocked_ipv4"], ["192.0.2.1/32"])
|
||||||
|
self.assertTrue(Path(path).exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for database-backed report API routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.db import connect
|
||||||
|
from app.db import repositories as repo
|
||||||
|
from app.web import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRoutesTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.conn = connect(str(Path(self.temp_dir.name) / "test.db"))
|
||||||
|
self.app = create_app()
|
||||||
|
self.app.config["TESTING"] = True
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.connection_patch = patch("app.db.get_connection",
|
||||||
|
return_value=self.conn)
|
||||||
|
self.connection_patch.start()
|
||||||
|
self.artifact_id = repo.insert_report_artifact(
|
||||||
|
self.conn, source="copyparty", report_type="text_report",
|
||||||
|
generated_at="2026-11-06T10:00:00+00:00",
|
||||||
|
payload={"format": "text", "content": "summary"},
|
||||||
|
original_path="copyparty/2026-11-06.txt",
|
||||||
|
content_sha256="b" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.connection_patch.stop()
|
||||||
|
self.conn.close()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_list_detail_and_text_export(self):
|
||||||
|
listed = self.client.get("/api/reports?source=copyparty&limit=1")
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
self.assertEqual(listed.json["items"][0]["id"], self.artifact_id)
|
||||||
|
|
||||||
|
detail = self.client.get(
|
||||||
|
f"/api/report-artifacts/{self.artifact_id}")
|
||||||
|
self.assertEqual(detail.status_code, 200)
|
||||||
|
self.assertEqual(detail.json["payload"]["content"], "summary")
|
||||||
|
|
||||||
|
exported = self.client.get(
|
||||||
|
f"/api/report-artifacts/{self.artifact_id}/export?format=txt")
|
||||||
|
self.assertEqual(exported.status_code, 200)
|
||||||
|
self.assertEqual(exported.data.decode(), "summary")
|
||||||
|
self.assertIn("attachment", exported.headers["Content-Disposition"])
|
||||||
|
self.assertNotIn(".txt.txt", exported.headers["Content-Disposition"])
|
||||||
|
|
||||||
|
def test_invalid_source_cursor_and_missing_id(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get("/api/reports?source=unknown").status_code, 400)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get("/api/reports?cursor=invalid").status_code, 400)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get("/api/report-artifacts/9999").status_code, 404)
|
||||||
|
|
||||||
|
def test_legacy_file_routes_remain_registered(self):
|
||||||
|
response = self.client.get("/api/reports/copyparty")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("files", response.json)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the SQLite repositories + schema migrations (T001).
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python -m unittest discover -s tests -p 'test_*.py'
|
||||||
|
or (if pytest is installed):
|
||||||
|
python -m pytest tests/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.db import connect
|
||||||
|
from app.db import repositories as repo
|
||||||
|
from app.db.schema import SCHEMA_VERSION, apply_migrations
|
||||||
|
|
||||||
|
|
||||||
|
def _new_conn() -> sqlite3.Connection:
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
os.unlink(path)
|
||||||
|
return connect(path)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaTest(unittest.TestCase):
|
||||||
|
def test_migrations_apply_and_are_idempotent(self):
|
||||||
|
conn = _new_conn()
|
||||||
|
self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0],
|
||||||
|
SCHEMA_VERSION)
|
||||||
|
# Re-applying should be a no-op (no exception, version unchanged).
|
||||||
|
apply_migrations(conn)
|
||||||
|
self.assertEqual(conn.execute("PRAGMA user_version").fetchone()[0],
|
||||||
|
SCHEMA_VERSION)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_core_tables_exist(self):
|
||||||
|
conn = _new_conn()
|
||||||
|
tables = {r["name"] for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
for t in ("requests", "ip_stats", "scans", "scans_sources",
|
||||||
|
"blocklist_entries", "blocklist_events",
|
||||||
|
"blocklist_reasons", "whitelist_entries",
|
||||||
|
"whitelist_sources", "firewall_snapshots",
|
||||||
|
"firewall_address_list_entries", "firewall_rule_entries",
|
||||||
|
"whois_info", "log_sources", "report_artifacts"):
|
||||||
|
self.assertIn(t, tables, f"missing table {t}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ScanAndRequestTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = _new_conn()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_scan_lifecycle(self):
|
||||||
|
sid = repo.start_scan(self.conn, "ids", params={"full": True})
|
||||||
|
self.assertEqual(repo.get_scan(self.conn, sid)["status"], "running")
|
||||||
|
repo.finish_scan(self.conn, sid, status="done",
|
||||||
|
result={"ok": True}, request_count=3)
|
||||||
|
s = repo.get_scan(self.conn, sid)
|
||||||
|
self.assertEqual(s["status"], "done")
|
||||||
|
self.assertEqual(s["request_count"], 3)
|
||||||
|
self.assertEqual([x["id"] for x in repo.latest_scans(self.conn)],
|
||||||
|
[sid])
|
||||||
|
|
||||||
|
def test_insert_and_query_requests(self):
|
||||||
|
sid = repo.start_scan(self.conn, "ids")
|
||||||
|
rows = [
|
||||||
|
{"source": "npm", "scan_id": sid, "log_file": "a.log",
|
||||||
|
"ts": "2026-09-01 10:00:00", "ip": "1.2.3.4", "version": 4,
|
||||||
|
"method": "GET", "path": "/.env", "status": 404,
|
||||||
|
"host": "h", "user": None, "ua": "curl", "referer": None,
|
||||||
|
"denied": False, "banned": False, "is_error": True, "extra": None},
|
||||||
|
{"source": "copyparty", "scan_id": sid, "log_file": "f.txt",
|
||||||
|
"ts": "2026-09-01 10:00:01", "ip": "1.2.3.4", "version": 4,
|
||||||
|
"method": "GET", "path": "/admin", "status": 403,
|
||||||
|
"host": None, "user": None, "ua": None, "referer": None,
|
||||||
|
"denied": True, "banned": False, "is_error": True, "extra": None},
|
||||||
|
]
|
||||||
|
self.assertEqual(repo.insert_requests(self.conn, rows), 2)
|
||||||
|
self.assertEqual(repo.count_requests(self.conn), 2)
|
||||||
|
# cross-source
|
||||||
|
cs = repo.correlate_across_sources(self.conn)
|
||||||
|
self.assertEqual(cs[0]["ip"], "1.2.3.4")
|
||||||
|
self.assertEqual(cs[0]["sources"], 2)
|
||||||
|
# timeline
|
||||||
|
tl = repo.ip_timeline(self.conn, "1.2.3.4")
|
||||||
|
self.assertEqual(len(tl), 2)
|
||||||
|
# top ips
|
||||||
|
top = repo.top_ips(self.conn)
|
||||||
|
self.assertEqual(top[0]["total"], 2)
|
||||||
|
repo.finish_scan(self.conn, sid, status="done")
|
||||||
|
|
||||||
|
|
||||||
|
class BlocklistTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = _new_conn()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_grow_only_record(self):
|
||||||
|
r1 = repo.record_blocklist(self.conn, ["5.5.5.5/32", "6.6.6.6/32"],
|
||||||
|
added_by="scan:1")
|
||||||
|
self.assertEqual(r1["active"], 2)
|
||||||
|
# a later scan only sees 5.5.5.5; 6.6.6.6 stays (cumulative).
|
||||||
|
r2 = repo.record_blocklist(
|
||||||
|
self.conn, ["5.5.5.5/32"], added_by="scan:2")
|
||||||
|
active = [e["cidr"] for e in repo.active_blocklist(self.conn)]
|
||||||
|
self.assertIn("6.6.6.6/32", active)
|
||||||
|
self.assertEqual(r2["active"], 2)
|
||||||
|
|
||||||
|
def test_unblock(self):
|
||||||
|
repo.record_blocklist(self.conn, ["7.7.7.7/32"])
|
||||||
|
self.assertTrue(repo.unblock_entry(self.conn, "7.7.7.7/32"))
|
||||||
|
self.assertNotIn("7.7.7.7/32",
|
||||||
|
[e["cidr"] for e in repo.active_blocklist(self.conn)])
|
||||||
|
self.assertFalse(repo.unblock_entry(self.conn, "7.7.7.7/32"))
|
||||||
|
|
||||||
|
def test_prune_old_single_ip_blocks_keeps_ranges(self):
|
||||||
|
old = (datetime.now(timezone.utc) - timedelta(days=31)).isoformat(
|
||||||
|
timespec="seconds")
|
||||||
|
with repo.transaction(self.conn):
|
||||||
|
repo._block_entry_inner(self.conn, "1.2.3.4/32", last_seen=old)
|
||||||
|
repo._block_entry_inner(
|
||||||
|
self.conn, "2001:db8::1/128", last_seen=old)
|
||||||
|
repo._block_entry_inner(self.conn, "1.2.3.0/24", last_seen=old)
|
||||||
|
repo._block_entry_inner(self.conn, "2001:db8::/64", last_seen=old)
|
||||||
|
|
||||||
|
removed = repo.prune_old_single_ip_blocks(
|
||||||
|
self.conn, older_than_days=30)
|
||||||
|
self.assertCountEqual(removed, ["1.2.3.4/32", "2001:db8::1/128"])
|
||||||
|
|
||||||
|
active = {e["cidr"] for e in repo.active_blocklist(self.conn)}
|
||||||
|
self.assertIn("1.2.3.0/24", active)
|
||||||
|
self.assertIn("2001:db8::/64", active)
|
||||||
|
self.assertNotIn("1.2.3.4/32", active)
|
||||||
|
self.assertNotIn("2001:db8::1/128", active)
|
||||||
|
|
||||||
|
def test_prune_old_single_ip_blocks_respects_recency(self):
|
||||||
|
fresh = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat(
|
||||||
|
timespec="seconds")
|
||||||
|
with repo.transaction(self.conn):
|
||||||
|
repo._block_entry_inner(self.conn, "9.9.9.9/32", last_seen=fresh)
|
||||||
|
|
||||||
|
removed = repo.prune_old_single_ip_blocks(
|
||||||
|
self.conn, older_than_days=30)
|
||||||
|
self.assertEqual(removed, [])
|
||||||
|
self.assertIn("9.9.9.9/32",
|
||||||
|
{e["cidr"] for e in repo.active_blocklist(self.conn)})
|
||||||
|
|
||||||
|
|
||||||
|
class WhitelistTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = _new_conn()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_whitelist_lookup(self):
|
||||||
|
repo.set_whitelist(self.conn, "Cloudflare", ["173.245.48.0/20"],
|
||||||
|
source="test")
|
||||||
|
self.assertTrue(repo.is_whitelisted_db(self.conn, "173.245.48.5"))
|
||||||
|
self.assertFalse(repo.is_whitelisted_db(self.conn, "8.8.8.8"))
|
||||||
|
# provider replace deactivates old ranges
|
||||||
|
repo.set_whitelist(self.conn, "Cloudflare", ["104.16.0.0/13"])
|
||||||
|
self.assertFalse(repo.is_whitelisted_db(self.conn, "173.245.48.5"))
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = _new_conn()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_firewall_snapshot(self):
|
||||||
|
sid = repo.save_firewall_snapshot(
|
||||||
|
self.conn,
|
||||||
|
address_lists_v4=[{"list": "Blocked", "address": "5.5.5.5/32"}],
|
||||||
|
address_lists_v6=[{"list": "Init7", "address": "2001:1620::/30"}],
|
||||||
|
rules_v4=[{"chain": "input", "action": "drop", "comment": "x"}],
|
||||||
|
rules_v6=[])
|
||||||
|
self.assertEqual(sid, 1)
|
||||||
|
self.assertEqual(self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM firewall_address_list_entries").fetchone()[0], 2)
|
||||||
|
self.assertEqual(self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM firewall_rule_entries").fetchone()[0], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportArtifactTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = _new_conn()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_artifact_insert_is_idempotent_and_detail_decodes_payload(self):
|
||||||
|
kwargs = {
|
||||||
|
"source": "copyparty", "report_type": "analyzer_summary",
|
||||||
|
"generated_at": "2026-11-01T10:00:00+00:00",
|
||||||
|
"payload": {"summary": {"requests": 12}},
|
||||||
|
"original_path": "reports/copyparty/2026-11-01.txt",
|
||||||
|
"content_sha256": "a" * 64,
|
||||||
|
}
|
||||||
|
first_id = repo.insert_report_artifact(self.conn, **kwargs)
|
||||||
|
duplicate_id = repo.insert_report_artifact(self.conn, **kwargs)
|
||||||
|
|
||||||
|
self.assertEqual(first_id, duplicate_id)
|
||||||
|
self.assertEqual(self.conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM report_artifacts").fetchone()[0], 1)
|
||||||
|
self.assertEqual(repo.get_report_artifact(self.conn, first_id)["payload"],
|
||||||
|
kwargs["payload"])
|
||||||
|
|
||||||
|
def test_artifact_cursor_orders_stably_and_filters_source(self):
|
||||||
|
for day, source in (("01", "npm"), ("02", "copyparty"),
|
||||||
|
("03", "npm")):
|
||||||
|
repo.insert_report_artifact(
|
||||||
|
self.conn, source=source, report_type="summary",
|
||||||
|
generated_at=f"2026-11-{day}T00:00:00+00:00",
|
||||||
|
payload={"day": day}, original_path=f"reports/{day}.txt",
|
||||||
|
content_sha256=day * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = repo.list_report_artifacts(self.conn, source="npm", limit=1)
|
||||||
|
self.assertEqual(len(first["items"]), 1)
|
||||||
|
self.assertIsNotNone(first["next_cursor"])
|
||||||
|
second = repo.list_report_artifacts(
|
||||||
|
self.conn, source="npm", limit=1, cursor=first["next_cursor"])
|
||||||
|
self.assertEqual(len(second["items"]), 1)
|
||||||
|
self.assertIsNone(second["next_cursor"])
|
||||||
|
self.assertGreater(first["items"][0]["generated_at"],
|
||||||
|
second["items"][0]["generated_at"])
|
||||||
|
|
||||||
|
def test_artifact_cursor_rejects_invalid_token(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
repo.list_report_artifacts(self.conn, cursor="invalid")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user