Compare commits

..
15 Commits
Author SHA1 Message Date
zwitschi 6f2e479958 feat(reports): store and browse report artifacts 2026-11-06 12:27:00 +01:00
zwitschi 5967a43eb5 test(firewall): add tests for evidence-first firewall sync behavior 2026-11-06 12:09:26 +01:00
zwitschi a9a03c1347 docs: define report storage and API design 2026-11-06 12:09:09 +01:00
zwitschi 03f3c65a58 docs: inventory report artifacts and consumers 2026-11-06 12:08:05 +01:00
zwitschi 5ece4cd113 feat(prune): add functionality to remove old single-host IP blocks while keeping CIDR ranges intact 2026-11-06 11:33:12 +01:00
zwitschi 714aaf901e fix(scan): _db_complete_blocklist must return sets, not lists
B001 change caused 'unsupported operand type(s) for -: list and set' during a
full consolidated scan: _db_complete_blocklist returned split_cidr_versions()
output, which is tuple[list, list], but write_blocklist_report()/blocklist_delta()
expect set operands. Return set(v4), set(v6) to match cumulative_blocklist's
contract. Reproduced the exact report path with a populated ledger and
verified it no longer crashes.
2026-10-24 20:00:59 +02:00
zwitschi 6051d2b548 test(db): repository + migration tests (T001); document analyze in README
tests/test_repositories.py covers schema migrations (idempotent), scan
lifecycle, request insert/query/cross-source, grow-only blocklist ledger,
unblock, whitelist lookup/replace, and firewall snapshots. 10 tests total.
README usage documents the 'python app.py analyze' command.
2026-10-24 19:56:22 +02:00
zwitschi 2fb4032766 feat(db): backfill, DB-backed web scans, firewall snapshot view (R001/S001/F001)
- R001: app/db/backfill.py imports git-history blocklist -> ledger, firewall
  snapshots, and per-source requests.csv (idempotent; ran live migrating 705
  historical blocklist entries).
- S001: web_scan.latest_scans() now merges SQLite 'scans' records so the
  dashboard surfaces CLI/restart scans (_db_scans).
- F001: /api/firewall/snapshots + 'Snapshot history' panel; added
  recent_firewall_snapshots() query with entry counts.
2026-10-24 19:56:20 +02:00
zwitschi ba48054293 feat(blocklist): make SQLite ledger authoritative for the cumulative set (B001)
- Add repo.record_blocklist(): grow-only, never auto-removes (mirrors router
  cumulative model; preserves entries that leave the NPM log window).
- scan.py uses it on the scan path and derives the report's complete set from
  the ledger (_db_complete_blocklist), falling back to git history only if the
  DB is unavailable.
2026-10-24 19:56:17 +02:00
zwitschi 4a30f6b582 feat: add 'python app.py analyze' CLI report (L002)
Reads the persisted SQLite store to print a detailed analysis: latest scans,
top IPs, cross-source IPs, active blocklist ledger, and a per-IP request
history drill-down (--ip). Supports --since, --top, --json. No scan is run.
2026-10-24 19:56:15 +02:00
zwitschi 1e80be746e docs(readme): source of truth is SQLite; add Deployment & updating section
- Blocklist source of truth is the SQLite ledger, not git history.
- data/mikrotik is a scratch mirror, not a git audit trail.
- Removed the 'commit blocklist files' advice.
- Added 'Deployment & updating (git pull)' section covering deploy.sh,
  runtime isolation, and python -m app.db --migrate (D008).
2026-10-24 19:51:03 +02:00
zwitschi 5d81f617f0 feat(deploy): conflict-free 'git pull' update flow (D001/D004/D005)
- deploy.sh: clean-tree guard + git fetch && git pull --ff-only + optional
  dep reinstall + app.db --migrate + restart.
- restart-service.sh: supervisor-agnostic restart (systemd or fallback), waits
  for an in-flight scan.
- check_runtime_git.sh: proves no scan writes git-tracked runtime data.
- ids.service: systemd unit template.
- tests/test_deployment.py: stdlib unittest invariant tests for the deployment
  runtime-isolation guarantee.
2026-10-24 19:50:45 +02:00
zwitschi 4960eef312 feat(firewall): persist firewall snapshots to SQLite (F001/D007)
save_state_report now also writes the address-list + firewall-rule snapshot into
the app.db firewall_snapshots tables, making the audit trail independent of git.
Removes the 'git-visible data/mikrotik' framing.
2026-10-24 19:50:43 +02:00
zwitschi aef132fe94 feat(db): add 'python -m app.db' CLI for migrations
D006: idempotent --migrate, --info (schema version + row counts), and --path.
Used by deploy.sh to bring the DB up to date after a git pull without the web
server running.
2026-10-24 19:50:41 +02:00
zwitschi 7f392ef908 chore(data): stop tracking runtime-refreshed whitelist seeds
The Cloudflare ips-v4/v6.txt and paloalto/.gitkeep were git-tracked. The
Cloudflare files are overwritten at runtime by every whitelist refresh, so on
a deployed server they become locally-modified tracked files that make 'git pull'
conflict. Keep the read-only paloalto/scanner-ips.txt seed tracked; ignore the
rest (they are re-downloaded if absent; built-in ranges are the fallback).
2026-10-24 19:50:39 +02:00
31 changed files with 2293 additions and 100 deletions

No files matched your search

+7 -5
View File
@@ -13,12 +13,14 @@ __pycache__/
# python virtual environment # python virtual environment
.venv/ .venv/
# app runtime data (feeds, caches, scan state - regenerated each run) # app runtime data (feeds, caches, scan state - regenerated each run).
# matched per-entry (not as a directory) so the whitelist seed files # All of this must stay UNTRACKED so ``git pull`` never conflicts with files a
# for Cloudflare and PaloAlto below can be re-included # scan has written since the last pull (see .github/instructions/DEPLOYMENT.md).
# 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/*
!app/data/cloudflare/ !app/data/paloalto/scanner-ips.txt
!app/data/paloalto/
# SQLite database (canonical store) + WAL/SHM sidecars # SQLite database (canonical store) + WAL/SHM sidecars
app/data/*.db app/data/*.db
+55 -12
View File
@@ -172,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
@@ -214,29 +219,36 @@ time and is never trimmed automatically.
it stops appearing in recent logs (for example after it leaves the NPM 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 ~30-day log-retention window, or once WHOIS subnet consolidation groups it
into a larger range). into a larger range).
- The **source of truth** for "all blocked IPs" is the git history of the - The **source of truth** for "all blocked IPs" is the **SQLite blocklist
consolidated blocklist files `reports/app/data/blocklist_ipv4.txt` and ledger** (`app.db` → `blocklist_entries` / `blocklist_events`), not git
`blocklist_ipv6.txt` (these files are git-tracked). After each scan the history. Each scan syncs the desired blocklist into the ledger; the router's
updated blocklist files should be committed so the history keeps growing. 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 - The sync only ever **adds** entries. The only automatic removals are router
entries that were never blocked by the system — i.e. absent from every git entries that were never blocked by the system — i.e. absent from the ledger
version of the blocklist files (manual / rogue entries). (manual / rogue entries).
- To explicitly unblock an IP, remove it manually via the web UI - To explicitly unblock an IP, remove it manually via the web UI
(`/blocklist` → remove) or directly on the router; the automated sync will (`/blocklist` → remove) or directly on the router; the automated sync will
not re-add entries that are not part of a scan. 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 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 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 of entries added / removed (complete-set delta), the full cumulative blocked
list, and the router sync changes. 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 ### Report retention
Generated files under `reports/` are pruned automatically after each Generated files under `reports/` are pruned automatically after each
consolidated scan: every subdirectory keeps at most consolidated scan: every subdirectory keeps at most
`REPORT_RETENTION_MAX_FILES` files (default 10), all newer than `REPORT_RETENTION_MAX_FILES` files (default 10), all newer than
`REPORT_RETENTION_DAYS` days (default 30). The blocklist source of truth `REPORT_RETENTION_DAYS` days (default 30). The blocklist files
(`reports/app/data/blocklist_ipv4.txt` / `blocklist_ipv6.txt`) is always (`reports/app/data/blocklist_ipv4.txt` / `blocklist_ipv6.txt`) are always
protected and never pruned. Tune via `REPORT_RETENTION_DAYS` / protected and never pruned. Tune via `REPORT_RETENTION_DAYS` /
`REPORT_RETENTION_MAX_FILES`; `--dry-run` previews the cleanup without `REPORT_RETENTION_MAX_FILES`; `--dry-run` previews the cleanup without
deleting. deleting.
@@ -268,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
@@ -318,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
View File
@@ -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.
+10
View File
@@ -40,12 +40,22 @@ def _run_scan(argv: list[str]) -> None:
scan_main(argv) scan_main(argv)
def _run_analyze(argv: list[str]) -> None:
from app.analyze import main as analyze_main
analyze_main(argv)
def main() -> None: def main() -> None:
# `python app.py scan ...` runs the consolidated CLI scan. # `python app.py scan ...` runs the consolidated CLI scan.
if len(sys.argv) > 1 and sys.argv[1] == "scan": if len(sys.argv) > 1 and sys.argv[1] == "scan":
_run_scan(sys.argv[2:]) _run_scan(sys.argv[2:])
return return
# `python app.py analyze ...` prints a detailed report from the SQLite store.
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). # Default: start the web UI (parse_known_args tolerates stray flags).
args, _ = _web_parser().parse_known_args() args, _ = _web_parser().parse_known_args()
_run_web(args.web_host, args.web_port) _run_web(args.web_host, args.web_port)
+145
View File
@@ -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()
+27
View File
@@ -7,6 +7,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
import re import re
from collections import Counter from collections import Counter
@@ -336,6 +337,7 @@ class Analyzer:
self.write_data() self.write_data()
report_path = self.write_report() report_path = self.write_report()
self.persist_report_artifact(report_path, db_scan_id)
print(f"data files: {self.data_dir}") print(f"data files: {self.data_dir}")
print(f"summary report: {report_path}") print(f"summary report: {report_path}")
@@ -349,3 +351,28 @@ class Analyzer:
result.update(rsc=rsc, blocked=n_blocked, result.update(rsc=rsc, blocked=n_blocked,
whitelisted=n_whitelisted) whitelisted=n_whitelisted)
return result 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})")
-15
View File
@@ -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
-7
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
$ = paloalto
+74
View File
@@ -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())
+361
View File
@@ -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())
+214 -2
View File
@@ -10,8 +10,10 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
import base64
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
import ipaddress
from typing import Any, Iterator, Sequence from typing import Any, Iterator, Sequence
from ..utils.models import IPStats from ..utils.models import IPStats
@@ -104,7 +106,8 @@ def _parse_ts(ts: str) -> tuple[str, float | None]:
cleaned = ts.strip() cleaned = ts.strip()
# Accept a trailing timezone like +HH:MM or Z; fall back to naive parse. # Accept a trailing timezone like +HH:MM or Z; fall back to naive parse.
try: try:
epoch = datetime.fromisoformat(cleaned.replace("Z", "+00:00")).timestamp() epoch = datetime.fromisoformat(
cleaned.replace("Z", "+00:00")).timestamp()
except ValueError: except ValueError:
try: try:
epoch = datetime.fromisoformat(cleaned).timestamp() epoch = datetime.fromisoformat(cleaned).timestamp()
@@ -231,6 +234,109 @@ def count_scans(conn: sqlite3.Connection) -> int:
return int(conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]) 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, def correlate_across_sources(conn: sqlite3.Connection,
limit: int = 100) -> list[dict[str, Any]]: limit: int = 100) -> list[dict[str, Any]]:
"""IPs seen in more than one log source, with per-source counts.""" """IPs seen in more than one log source, with per-source counts."""
@@ -322,6 +428,78 @@ def unblock_entry(conn: sqlite3.Connection, cidr: str, *,
return removed 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], *, def sync_blocklist(conn: sqlite3.Connection, desired_cidrs: Sequence[str], *,
added_by: str | None = None) -> dict[str, Any]: added_by: str | None = None) -> dict[str, Any]:
"""Reconcile active blocklist to ``desired_cidrs``. """Reconcile active blocklist to ``desired_cidrs``.
@@ -376,6 +554,26 @@ def _cidr_version(cidr: str) -> int:
return 4 if ":" not in str(cidr) else 6 return 4 if ":" not in str(cidr) else 6
def _is_single_host_cidr(cidr: str) -> bool:
try:
net = ipaddress.ip_network(cidr, strict=False)
return net.num_addresses == 1
except ValueError:
return False
def _parse_iso_ts(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Whitelists # Whitelists
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -465,6 +663,20 @@ def save_firewall_snapshot(conn: sqlite3.Connection, *,
return snapshot_id 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 # WHOIS cache
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+24 -8
View File
@@ -14,7 +14,7 @@ import sqlite3
from typing import Iterable from typing import Iterable
#: Latest schema version. Bump by one whenever a new migration is appended. #: Latest schema version. Bump by one whenever a new migration is appended.
SCHEMA_VERSION = 1 SCHEMA_VERSION = 2
#: Ordered migrations: index -> list of DDL statements. A migration N upgrades a #: 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; #: database from user_version N-1 to N. Never edit an older migration in place;
@@ -31,14 +31,29 @@ MIGRATIONS: dict[int, list[str]] = {
log_path TEXT, log_path TEXT,
enabled INTEGER NOT NULL DEFAULT 1 enabled INTEGER NOT NULL DEFAULT 1
) )
""",
""" """
INSERT OR IGNORE INTO log_sources (name, log_path) ],
VALUES ('copyparty', NULL), ('npm', NULL)
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 "
# requests — one row per parsed request; insert-only "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 ( CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -261,7 +276,8 @@ def apply_migrations(conn: sqlite3.Connection) -> None:
for version in range(current + 1, SCHEMA_VERSION + 1): for version in range(current + 1, SCHEMA_VERSION + 1):
statements = MIGRATIONS.get(version) statements = MIGRATIONS.get(version)
if statements is None: if statements is None:
raise RuntimeError(f"missing migration for schema version {version}") raise RuntimeError(
f"missing migration for schema version {version}")
with conn: with conn:
for ddl in statements: for ddl in statements:
conn.execute(ddl) conn.execute(ddl)
+31 -7
View File
@@ -143,9 +143,11 @@ class MikrotikFirewall:
"""Save current address lists and firewall rules for auditing. """Save current address lists and firewall rules for auditing.
Writes structured snapshots to ``<report_dir>/mikrotik/data/`` and to Writes structured snapshots to ``<report_dir>/mikrotik/data/`` and to
the common ``data/mikrotik/`` directory (overwritten every run so git the common ``data/mikrotik/`` directory (scratch mirror for debugging;
can diff changes to the firewall state over time), plus a timestamped **not** a git audit trail), plus a timestamped JSON report under
JSON report under ``<report_dir>/mikrotik/``. ``<report_dir>/mikrotik/``. The authoritative long-term audit trail is
the SQLite ``firewall_snapshots`` tables (see ``app.db``), which are
appended here regardless of the file writes.
""" """
state = { state = {
"router": self.router_ip, "router": self.router_ip,
@@ -174,11 +176,31 @@ class MikrotikFirewall:
os.path.join(data_dir, f"firewall_{version}.json"), os.path.join(data_dir, f"firewall_{version}.json"),
state["firewall_rules"][version]) state["firewall_rules"][version])
# Persist a SQLite snapshot (authoritative audit trail, independent of
# git). Best-effort: never breaks a router sync if the DB is down.
self._persist_snapshot_to_db(state)
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
report_path = os.path.join(report_dir, "mikrotik", f"{timestamp}.json") report_path = os.path.join(report_dir, "mikrotik", f"{timestamp}.json")
self._write_json(report_path, state) self._write_json(report_path, state)
return report_path return report_path
@staticmethod
def _persist_snapshot_to_db(state: dict) -> None:
"""Write the firewall snapshot into the SQLite store (best-effort)."""
try:
from .db import get_connection
from .db import repositories as repo
repo.save_firewall_snapshot(
get_connection(),
address_lists_v4=state["address_lists"]["ipv4"],
address_lists_v6=state["address_lists"]["ipv6"],
rules_v4=state["firewall_rules"]["ipv4"],
rules_v6=state["firewall_rules"]["ipv6"],
)
except Exception as exc:
print(f"WARN: could not persist firewall snapshot to db ({exc})")
def sync_blocklist(self, blocked_ipv4: Iterable[str], def sync_blocklist(self, blocked_ipv4: Iterable[str],
blocked_ipv6: Iterable[str], blocked_ipv6: Iterable[str],
dry_run: bool = False, dry_run: bool = False,
@@ -186,10 +208,12 @@ class MikrotikFirewall:
"""Synchronize the Blocked list cumulatively and audit the result. """Synchronize the Blocked list cumulatively and audit the result.
The blocklist is grow-only: targets are added when present in the The blocklist is grow-only: targets are added when present in the
complete set (all git history of the blocklist files + the current complete cumulative set (git history of the blocklist files + the
working files + this scan's targets) and never removed automatically. current working files + this scan's targets) and never removed
Only router entries absent from the complete set — i.e. never blocked automatically. Only router entries absent from the complete set — i.e.
by the system — are pruned. See ``app.blocklist_history``. never blocked by the system — are pruned. The history now lives in the
SQLite blocklist ledger (``app.db`` ``blocklist_entries``); git history
remains a read-only fallback. See ``app.blocklist_history``.
""" """
desired_ipv4 = set(blocked_ipv4) desired_ipv4 = set(blocked_ipv4)
+47 -9
View File
@@ -41,19 +41,50 @@ from ..utils.whois import consolidate_by_subnet
def _sync_blocklist_to_db(connected: bool, desired_v4: list[str], def _sync_blocklist_to_db(connected: bool, desired_v4: list[str],
desired_v6: list[str], added_by: str | None = None) -> None: desired_v6: list[str],
"""Best-effort sync of the consolidated blocklist targets into SQLite. added_by: str | None = None) -> dict | None:
"""Best-effort grow-only sync of block targets into the SQLite ledger.
Keep the DB ledger in sync regardless of whether a router sync happened so Uses ``record_blocklist`` (add-only) so the ledger is the authoritative
analysis always sees the current policy.""" 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: try:
from ..db import get_connection from ..db import get_connection
from ..db import repositories as repo from ..db import repositories as repo
conn = get_connection() conn = get_connection()
repo.sync_blocklist(conn, list(desired_v4) + list(desired_v6), result = repo.record_blocklist(
added_by=added_by) conn, list(desired_v4) + list(desired_v6), added_by=added_by)
# Retention policy: expire stale single-host entries after 30 days
# while keeping CIDR ranges/subnets intact.
pruned = repo.prune_old_single_ip_blocks(conn, older_than_days=30)
result["pruned_single_ips"] = pruned
return result
except Exception as exc: # never break the scan for a DB issue except Exception as exc: # never break the scan for a DB issue
print(f"WARN: could not sync blocklist to db ({exc})") print(f"WARN: could not sync blocklist to db ({exc})")
return None
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
@@ -256,9 +287,15 @@ def run_scan(params: ScanParams) -> ScanResult:
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. # Timestamped change report — only when the router was reachable.
complete_v4, complete_v6 = cumulative_blocklist( # Source of truth for the cumulative set: the SQLite |ledger| first
blocklist_v4_path, blocklist_v6_path, # (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) 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( blocklist_report_path = write_blocklist_report(
params.report_dir, params.report_dir,
history_v4=history_v4, history_v6=history_v6, history_v4=history_v4, history_v6=history_v6,
@@ -266,7 +303,8 @@ def run_scan(params: ScanParams) -> ScanResult:
complete_v4=complete_v4, complete_v6=complete_v6, complete_v4=complete_v4, complete_v6=complete_v6,
firewall_changes=firewall_changes, firewall_changes=firewall_changes,
dry_run=params.dry_run, dry_run=params.dry_run,
errors=errors) errors=errors,
scan_id=db_scan_id)
print(f"blocklist report: {blocklist_report_path}") print(f"blocklist report: {blocklist_report_path}")
# --- Step 8: prune old reports (retention) ------------------------------ # --- Step 8: prune old reports (retention) ------------------------------
+45
View File
@@ -23,6 +23,11 @@ endblock %} {% block content %}
<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>
<div class="panel">
<div class="panel-head"><h2>Snapshot history (SQLite)</h2></div>
<div id="fw-snapshots"></div>
</div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
async function loadState() { async function loadState() {
@@ -35,6 +40,45 @@ endblock %} {% block content %}
} }
} }
async function loadSnapshots() {
const box = document.getElementById("fw-snapshots");
try {
const d = await api("/api/firewall/snapshots?limit=10");
const snaps = d.snapshots || [];
if (!snaps.length) {
box.innerHTML =
'<p class="muted">No SQLite snapshots yet — they are recorded on each <code>--update-firewall</code> run.</p>';
return;
}
box.innerHTML = "";
const rows = snaps.map((s) =>
el(
"tr",
el("td", { class: "mono", text: `#${s.id}` }),
el("td", { text: fmtTime(s.captured_at) }),
el("td", { text: s.kind }),
el("td", { text: s.trigger }),
el("td", { class: "num", text: s.addr_entries }),
el("td", { class: "num", text: s.rule_entries }),
),
);
box.append(
el(
"table",
{ class: "table" },
el(
"thead",
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("tbody", {}, ...rows),
),
);
} catch (e) {
box.innerHTML=
'<p class="muted">Snapshot history unavailable (SQLite).</p>';
}
}
function renderAddressLists(addr) { function renderAddressLists(addr) {
const box = document.getElementById("address-lists"); const box = document.getElementById("address-lists");
box.innerHTML = ""; box.innerHTML = "";
@@ -172,5 +216,6 @@ endblock %} {% block content %}
.getElementById("apply-btn") .getElementById("apply-btn")
.addEventListener("click", () => sync(false)); .addEventListener("click", () => sync(false));
loadState(); loadState();
loadSnapshots();
</script> </script>
{% endblock %} {% endblock %}
+75 -27
View File
@@ -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 %}
+16
View File
@@ -20,6 +20,7 @@ import re
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from typing import Any, Iterable from typing import Any, Iterable
import hashlib
from .files import write_json from .files import write_json
from .reporting import timestamp_name from .reporting import timestamp_name
@@ -151,6 +152,7 @@ def write_blocklist_report(report_dir: str,
firewall_changes: dict | None = None, firewall_changes: dict | None = None,
dry_run: bool = False, dry_run: bool = False,
errors: Iterable[str] = (), errors: Iterable[str] = (),
scan_id: int | None = None,
) -> str: ) -> str:
"""Write the timestamped blocklist report to ``<report_dir>/app/``. """Write the timestamped blocklist report to ``<report_dir>/app/``.
@@ -183,4 +185,18 @@ def write_blocklist_report(report_dir: str,
os.makedirs(app_dir, exist_ok=True) os.makedirs(app_dir, exist_ok=True)
path = os.path.join(app_dir, f"{timestamp_name()}.json") path = os.path.join(app_dir, f"{timestamp_name()}.json")
write_json(path, payload) 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 return path
+96 -1
View File
@@ -3,9 +3,13 @@
from __future__ import annotations from __future__ import annotations
import csv
import io
import json
import os import os
from flask import Flask, abort, jsonify, render_template, request from flask import Flask, Response, abort, jsonify, render_template, request
from werkzeug.utils import secure_filename
from ..utils.constants import ( from ..utils.constants import (
APP_REPORT_DATA_DIR, APP_REPORT_DATA_DIR,
@@ -154,6 +158,84 @@ def register_routes(app: Flask,
# --- reports ----------------------------------------------------------- # --- reports -----------------------------------------------------------
@app.get("/api/reports")
def list_report_artifacts():
source = (request.args.get("source") or "").strip() or None
if source is not None and source not in report_sources:
return jsonify({"error": "unknown report source"}), 400
limit = request.args.get("limit", default=50, type=int)
try:
from ..db import get_connection
from ..db import repositories as repo
result = repo.list_report_artifacts(
get_connection(), source=source,
report_type=(request.args.get("type") or "").strip() or None,
since=(request.args.get("since") or "").strip() or None,
until=(request.args.get("until") or "").strip() or None,
limit=limit,
cursor=(request.args.get("cursor") or "").strip() or None,
)
return jsonify(result)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except Exception as exc:
return jsonify({"error": f"report database unavailable: {exc}"}), 503
@app.get("/api/report-artifacts/<int:artifact_id>")
def get_report_artifact(artifact_id: int):
try:
from ..db import get_connection
from ..db import repositories as repo
artifact = repo.get_report_artifact(get_connection(), artifact_id)
except Exception as exc:
return jsonify({"error": f"report database unavailable: {exc}"}), 503
if artifact is None:
abort(404, "report artifact not found")
return jsonify(artifact)
@app.get("/api/report-artifacts/<int:artifact_id>/export")
def export_report_artifact(artifact_id: int):
try:
from ..db import get_connection
from ..db import repositories as repo
artifact = repo.get_report_artifact(get_connection(), artifact_id)
except Exception as exc:
return jsonify({"error": f"report database unavailable: {exc}"}), 503
if artifact is None:
abort(404, "report artifact not found")
payload = artifact["payload"]
export_format = (request.args.get("format") or "json").lower()
filename = secure_filename(os.path.basename(
artifact["original_path"])) or f"report-{artifact_id}"
if export_format == "json":
content = json.dumps(payload, ensure_ascii=False, indent=2)
mimetype = "application/json"
extension = ".json"
elif export_format == "txt" and payload.get("format") == "text":
content = payload.get("content", "")
mimetype = "text/plain; charset=utf-8"
extension = ".txt"
elif export_format == "csv" and payload.get("format") == "csv":
output = io.StringIO(newline="")
writer = csv.DictWriter(
output, fieldnames=payload.get("columns", []),
extrasaction="ignore")
writer.writeheader()
writer.writerows(payload.get("rows", []))
content = output.getvalue()
mimetype = "text/csv; charset=utf-8"
extension = ".csv"
else:
return jsonify({"error": "unsupported export format"}), 406
download_name = (filename if filename.lower().endswith(extension)
else f"{filename}{extension}")
response = Response(content, mimetype=mimetype)
response.headers["Content-Disposition"] = (
f'attachment; filename="{download_name}"')
return response
@app.get("/api/reports/<source>") @app.get("/api/reports/<source>")
def list_reports(source): def list_reports(source):
base = report_sources.get(source) base = report_sources.get(source)
@@ -265,6 +347,19 @@ def register_routes(app: Flask,
"snapshot_dir": MIKROTIK_DATA_DIR, "snapshot_dir": MIKROTIK_DATA_DIR,
}) })
@app.get("/api/firewall/snapshots")
def firewall_snapshots():
"""Recent firewall snapshots persisted in the SQLite store (F001)."""
try:
from ..db import get_connection
from ..db import repositories as repo
conn = get_connection()
limit = request.args.get("limit", 10, type=int)
return jsonify({"snapshots": repo.recent_firewall_snapshots(
conn, limit=limit)})
except Exception as exc:
return jsonify({"error": str(exc), "snapshots": []}), 503
@app.post("/api/firewall/sync") @app.post("/api/firewall/sync")
def firewall_sync(): def firewall_sync():
return _sync_firewall(dry_run=True) return _sync_firewall(dry_run=True)
+54 -6
View File
@@ -258,23 +258,71 @@ def latest_file_scan() -> dict[str, Any] | None:
} }
def _db_scans(limit: int = 10) -> list[dict[str, Any]]:
"""Recent scan records from the SQLite ``scans`` table (S001).
Converts DB rows into the web job-dict shape so the dashboard can surface
scans that were persisted by ``run_scan`` / analyzers even if they were
never run through the in-memory job store (e.g. CLI scans or restarts).
Returns [] when the DB is unavailable.
"""
try:
from ..db import get_connection
from ..db import repositories as repo
conn = get_connection()
rows = repo.latest_scans(conn, limit=limit)
except Exception:
return []
out = []
for r in rows:
try:
result_json = r.get("result_json")
result = json.loads(result_json) if result_json else {}
except (TypeError, ValueError):
result = {}
out.append({
"job_id": f"db-scan-{r['id']}",
"kind": r.get("kind") or "consolidated",
"state": r.get("status") or "done",
"started": r.get("started_at"),
"finished": r.get("finished_at"),
"error": r.get("error"),
"busy": False,
"details": {},
"result": result or None,
})
return out
def latest_scans(limit: int = 10) -> list[dict[str, Any]]: def latest_scans(limit: int = 10) -> list[dict[str, Any]]:
"""Return the most recently started jobs, newest first. """Return the most recently started jobs, newest first.
Appends a file-derived "latest scan" entry when the newest report files on Merges three sources, newest-first:
disk reflect a scan newer than any known job (e.g. after a restart or a 1. the in-memory job store (currently running / last-run jobs),
CLI run), so the dashboard always shows the latest completed scan. 2. the SQLite ``scans`` table (persisted by run_scan/analyzers — S001),
3. a file-derived "latest scan" fallback for pre-DB runs.
Any DB-backed scan not already represented in memory is appended so the
dashboard shows scans from CLI runs and across restarts.
""" """
load_persisted() load_persisted()
with _jobs_lock: with _jobs_lock:
jobs = sorted(_jobs.values(), jobs = sorted(_jobs.values(),
key=lambda j: j.started or "", reverse=True) key=lambda j: j.started or "", reverse=True)
entries = [j.to_dict() for j in jobs] entries = [j.to_dict() for j in jobs]
# Add DB scan records that aren't already in the in-memory store. Key on
# (kind, started_at) to avoid duplicating a job that run_scan also logged.
known = {(e.get("kind"), e.get("started")) for e in entries}
for db_row in _db_scans(limit=limit * 2):
if (db_row.get("kind"), db_row.get("started")) not in known:
entries.append(db_row)
file_entry = latest_file_scan() file_entry = latest_file_scan()
if file_entry is not None: if file_entry is not None:
newest = jobs[0] if jobs else None newest = (entries or [None])[0]
newest_ts = _iso_key( newest_key = (
(newest.finished or newest.started) if newest else None) (newest.get("finished") or newest.get("started")) if newest else None)
newest_ts = _iso_key(newest_key)
# Only surface the file-derived entry when it is genuinely newer than # Only surface the file-derived entry when it is genuinely newer than
# every known job (tolerance avoids duplicating the run that produced # every known job (tolerance avoids duplicating the run that produced
# it, where collected_at lands a moment after the job finished). # it, where collected_at lands a moment after the job finished).
+13
View File
@@ -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
+66
View File
@@ -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
+75
View File
@@ -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 =="
+41
View File
@@ -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
+53
View File
@@ -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
+57
View File
@@ -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()
+65
View File
@@ -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()
+114
View File
@@ -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()
+67
View File
@@ -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()
+71
View File
@@ -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()
+248
View File
@@ -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()