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.
146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
#!/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()
|