feat(reports): store and browse report artifacts
This commit is contained in:
1 parent
5967a43eb5
commit
6f2e479958
13 files changed
+875
-93
No files matched your search
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
@@ -336,6 +337,7 @@ class Analyzer:
|
||||
|
||||
self.write_data()
|
||||
report_path = self.write_report()
|
||||
self.persist_report_artifact(report_path, db_scan_id)
|
||||
print(f"data files: {self.data_dir}")
|
||||
print(f"summary report: {report_path}")
|
||||
|
||||
@@ -349,3 +351,28 @@ class Analyzer:
|
||||
result.update(rsc=rsc, blocked=n_blocked,
|
||||
whitelisted=n_whitelisted)
|
||||
return result
|
||||
|
||||
def persist_report_artifact(self, path: str,
|
||||
scan_id: int | None = None) -> None:
|
||||
"""Store the rendered summary in SQLite while keeping its file export."""
|
||||
try:
|
||||
from .db import get_connection
|
||||
from .db import repositories as repo
|
||||
with open(path, "rb") as file:
|
||||
content = file.read()
|
||||
stat = os.stat(path)
|
||||
relative = os.path.relpath(path, self.report_dir).replace(os.sep, "/")
|
||||
repo.insert_report_artifact(
|
||||
get_connection(), source=self.name,
|
||||
report_type="analyzer_summary",
|
||||
generated_at=datetime.fromtimestamp(
|
||||
stat.st_mtime).astimezone().isoformat(timespec="seconds"),
|
||||
payload={"format": "text",
|
||||
"content": content.decode("utf-8", errors="replace")},
|
||||
original_path=f"{self.name}/{relative}",
|
||||
content_sha256=hashlib.sha256(content).hexdigest(),
|
||||
scan_id=scan_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
if not self.quiet:
|
||||
print(f"WARN: report artifact DB write skipped ({exc})")
|
||||
+166
-1
@@ -19,19 +19,30 @@ 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")
|
||||
@@ -92,7 +103,7 @@ def backfill_firewall(conn, dry_run: bool = False) -> dict:
|
||||
"SELECT COUNT(*) FROM firewall_snapshots").fetchone()[0]
|
||||
if n:
|
||||
return {"firewall": {"dry_run": dry_run, "snapshot": "exists",
|
||||
"skipped": True}}
|
||||
"skipped": True}}
|
||||
if dry_run:
|
||||
return {"firewall": {"dry_run": True, "snapshot": None}}
|
||||
sid = repo.save_firewall_snapshot(
|
||||
@@ -161,6 +172,152 @@ def backfill_requests(conn, dry_run: bool = False) -> dict:
|
||||
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, "", "-"):
|
||||
@@ -181,14 +338,22 @@ def main(argv: list[str] | None = None) -> None:
|
||||
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 ==")
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import base64
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import ipaddress
|
||||
@@ -233,6 +234,109 @@ def count_scans(conn: sqlite3.Connection) -> int:
|
||||
return int(conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report artifacts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def insert_report_artifact(conn: sqlite3.Connection, *, source: str,
|
||||
report_type: str, generated_at: str,
|
||||
payload: Any, original_path: str,
|
||||
content_sha256: str, scan_id: int | None = None,
|
||||
schema_version: int = 1) -> int:
|
||||
"""Insert a structured report artifact, idempotent by path and hash."""
|
||||
with transaction(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO report_artifacts "
|
||||
"(source, scan_id, report_type, generated_at, schema_version, "
|
||||
"payload_json, original_path, content_sha256, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(source, scan_id, report_type, generated_at, schema_version,
|
||||
json.dumps(payload, ensure_ascii=False), original_path,
|
||||
content_sha256, utcnow()),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM report_artifacts "
|
||||
"WHERE original_path=? AND content_sha256=?",
|
||||
(original_path, content_sha256),
|
||||
).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
def list_report_artifacts(conn: sqlite3.Connection, *,
|
||||
source: str | None = None,
|
||||
report_type: str | None = None,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
limit: int = 50,
|
||||
cursor: str | None = None) -> dict[str, Any]:
|
||||
"""List report metadata using bounded keyset pagination."""
|
||||
limit = min(max(int(limit), 1), 200)
|
||||
clauses: list[str] = []
|
||||
args: list[Any] = []
|
||||
if source:
|
||||
clauses.append("source=?")
|
||||
args.append(source)
|
||||
if report_type:
|
||||
clauses.append("report_type=?")
|
||||
args.append(report_type)
|
||||
if since:
|
||||
clauses.append("generated_at>=?")
|
||||
args.append(since)
|
||||
if until:
|
||||
clauses.append("generated_at<=?")
|
||||
args.append(until)
|
||||
if cursor:
|
||||
generated_at, artifact_id = _decode_report_cursor(cursor)
|
||||
clauses.append("(generated_at<? OR (generated_at=? AND id<?))")
|
||||
args.extend((generated_at, generated_at, artifact_id))
|
||||
|
||||
sql = (
|
||||
"SELECT id, source, scan_id, report_type, generated_at, "
|
||||
"schema_version, original_path, content_sha256 "
|
||||
"FROM report_artifacts"
|
||||
)
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY generated_at DESC, id DESC LIMIT ?"
|
||||
rows = [dict(row) for row in conn.execute(sql, (*args, limit + 1))]
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
next_cursor = (
|
||||
_encode_report_cursor(rows[-1]["generated_at"], rows[-1]["id"])
|
||||
if has_more and rows else None
|
||||
)
|
||||
return {"items": rows, "next_cursor": next_cursor}
|
||||
|
||||
|
||||
def get_report_artifact(conn: sqlite3.Connection,
|
||||
artifact_id: int) -> dict[str, Any] | None:
|
||||
"""Fetch one artifact and decode its structured payload."""
|
||||
row = conn.execute(
|
||||
"SELECT id, source, scan_id, report_type, generated_at, "
|
||||
"schema_version, payload_json, original_path, content_sha256, created_at "
|
||||
"FROM report_artifacts WHERE id=?", (artifact_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
artifact = dict(row)
|
||||
artifact["payload"] = json.loads(artifact.pop("payload_json"))
|
||||
return artifact
|
||||
|
||||
|
||||
def _encode_report_cursor(generated_at: str, artifact_id: int) -> str:
|
||||
raw = f"{generated_at}\n{artifact_id}".encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_report_cursor(cursor: str) -> tuple[str, int]:
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
generated_at, artifact_id = base64.urlsafe_b64decode(
|
||||
padded.encode("ascii")).decode("utf-8").split("\n", 1)
|
||||
return generated_at, int(artifact_id)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise ValueError("invalid report cursor") from exc
|
||||
|
||||
|
||||
def correlate_across_sources(conn: sqlite3.Connection,
|
||||
limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""IPs seen in more than one log source, with per-source counts."""
|
||||
|
||||
+24
-8
@@ -14,7 +14,7 @@ import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
#: 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
|
||||
#: 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,
|
||||
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)
|
||||
)
|
||||
""",
|
||||
# ------------------------------------------------------------------
|
||||
# requests — one row per parsed request; insert-only
|
||||
# ------------------------------------------------------------------
|
||||
"CREATE INDEX IF NOT EXISTS idx_report_artifacts_source_time "
|
||||
"ON report_artifacts (source, generated_at DESC, id DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_report_artifacts_type_time "
|
||||
"ON report_artifacts (report_type, generated_at DESC, id DESC)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -261,7 +276,8 @@ def apply_migrations(conn: sqlite3.Connection) -> None:
|
||||
for version in range(current + 1, SCHEMA_VERSION + 1):
|
||||
statements = MIGRATIONS.get(version)
|
||||
if statements is None:
|
||||
raise RuntimeError(f"missing migration for schema version {version}")
|
||||
raise RuntimeError(
|
||||
f"missing migration for schema version {version}")
|
||||
with conn:
|
||||
for ddl in statements:
|
||||
conn.execute(ddl)
|
||||
|
||||
@@ -303,7 +303,8 @@ def run_scan(params: ScanParams) -> ScanResult:
|
||||
complete_v4=complete_v4, complete_v6=complete_v6,
|
||||
firewall_changes=firewall_changes,
|
||||
dry_run=params.dry_run,
|
||||
errors=errors)
|
||||
errors=errors,
|
||||
scan_id=db_scan_id)
|
||||
print(f"blocklist report: {blocklist_report_path}")
|
||||
|
||||
# --- Step 8: prune old reports (retention) ------------------------------
|
||||
|
||||
+124
-76
@@ -1,76 +1,124 @@
|
||||
{% extends "base.html" %} {% block title %}Reports — Intrusion Detection{%
|
||||
endblock %} {% block content %}
|
||||
<h1>Reports</h1>
|
||||
<p class="muted">Browse the timestamped reports written by each scanner.</p>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-actions" style="margin-top: 0">
|
||||
<label style="flex-direction: row; align-items: center; gap: 0.5rem"
|
||||
>Source
|
||||
<select id="source">
|
||||
<option value="copyparty">copyparty</option>
|
||||
<option value="npm">npm</option>
|
||||
<option value="app">app</option>
|
||||
<option value="mikrotik">mikrotik</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn" id="refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<div class="list" id="files"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h2 id="content-title">Report content</h2></div>
|
||||
<pre class="content" id="content">
|
||||
Select a report on the left to view it.</pre
|
||||
>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
async function loadFiles() {
|
||||
const source = document.getElementById("source").value;
|
||||
const list = document.getElementById("files");
|
||||
list.innerHTML = '<div class="muted">Loading…</div>';
|
||||
try {
|
||||
const data = await api(`/api/reports/${source}`);
|
||||
list.innerHTML = "";
|
||||
const files = data.files || [];
|
||||
if (!files.length) {
|
||||
list.innerHTML = '<div class="muted">No reports in this source.</div>';
|
||||
return;
|
||||
}
|
||||
for (const name of files) {
|
||||
list.append(
|
||||
el("a", {
|
||||
href: "#",
|
||||
text: name,
|
||||
onclick: (ev) => {
|
||||
ev.preventDefault();
|
||||
openReport(name);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="muted">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openReport(name) {
|
||||
const source = document.getElementById("source").value;
|
||||
document.getElementById("content-title").textContent = name;
|
||||
const pre = document.getElementById("content");
|
||||
pre.textContent = "Loading…";
|
||||
try {
|
||||
const data = await api(`/api/reports/${source}/${name}`);
|
||||
pre.textContent = data.content;
|
||||
} catch (e) {
|
||||
pre.textContent = `Error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", loadFiles);
|
||||
document.getElementById("source").addEventListener("change", loadFiles);
|
||||
loadFiles();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %} {% block title %}Reports — Intrusion Detection{% endblock %} {% block content %}
|
||||
<h1>Reports</h1>
|
||||
<p class="muted">Browse the timestamped reports written by each scanner.</p>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-actions" style="margin-top: 0">
|
||||
<label style="flex-direction: row; align-items: center; gap: 0.5rem"
|
||||
>Source
|
||||
<select id="source">
|
||||
<option value="copyparty">copyparty</option>
|
||||
<option value="npm">npm</option>
|
||||
<option value="app">app</option>
|
||||
<option value="mikrotik">mikrotik</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn" id="refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<div class="list" id="files"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h2 id="content-title">Report content</h2></div>
|
||||
<pre class="content" id="content">Select a report on the left to view it.</pre>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
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 list = document.getElementById("files");
|
||||
if (!append) {
|
||||
nextCursor = null;
|
||||
list.innerHTML = '<div class="muted">Loading…</div>';
|
||||
} else {
|
||||
document.getElementById("load-more")?.remove();
|
||||
}
|
||||
|
||||
let importedPaths = new Set();
|
||||
try {
|
||||
const query = new URLSearchParams({ source, limit: "50" });
|
||||
if (append && nextCursor) query.set("cursor", nextCursor);
|
||||
const data = await api(`/api/reports?${query}`);
|
||||
if (!append) list.innerHTML = "";
|
||||
for (const artifact of data.items || []) {
|
||||
importedPaths.add(artifact.original_path);
|
||||
addReportLink(list, `${artifact.generated_at} | ${artifact.report_type} | ${artifact.original_path}`, () =>
|
||||
openArtifact(artifact.id),
|
||||
);
|
||||
}
|
||||
nextCursor = data.next_cursor;
|
||||
} catch (e) {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openReport(name) {
|
||||
const source = document.getElementById("source").value;
|
||||
document.getElementById("content-title").textContent = name;
|
||||
const pre = document.getElementById("content");
|
||||
pre.textContent = "Loading…";
|
||||
try {
|
||||
const data = await api(`/api/reports/${source}/${name}`);
|
||||
pre.textContent = data.content;
|
||||
} catch (e) {
|
||||
pre.textContent = `Error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", () => loadFiles());
|
||||
document.getElementById("source").addEventListener("change", () => loadFiles());
|
||||
loadFiles();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,7 @@ import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable
|
||||
import hashlib
|
||||
|
||||
from .files import write_json
|
||||
from .reporting import timestamp_name
|
||||
@@ -151,6 +152,7 @@ def write_blocklist_report(report_dir: str,
|
||||
firewall_changes: dict | None = None,
|
||||
dry_run: bool = False,
|
||||
errors: Iterable[str] = (),
|
||||
scan_id: int | None = None,
|
||||
) -> str:
|
||||
"""Write the timestamped blocklist report to ``<report_dir>/app/``.
|
||||
|
||||
@@ -183,4 +185,18 @@ def write_blocklist_report(report_dir: str,
|
||||
os.makedirs(app_dir, exist_ok=True)
|
||||
path = os.path.join(app_dir, f"{timestamp_name()}.json")
|
||||
write_json(path, payload)
|
||||
try:
|
||||
from ..db import get_connection
|
||||
from ..db import repositories as repo
|
||||
with open(path, "rb") as file:
|
||||
content = file.read()
|
||||
repo.insert_report_artifact(
|
||||
get_connection(), source="app", report_type="blocklist_change",
|
||||
generated_at=payload["generated_at"], payload=payload,
|
||||
original_path=f"app/{os.path.basename(path)}",
|
||||
content_sha256=hashlib.sha256(content).hexdigest(),
|
||||
scan_id=scan_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"WARN: blocklist report DB write skipped ({exc})")
|
||||
return path
|
||||
+83
-1
@@ -3,9 +3,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
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 (
|
||||
APP_REPORT_DATA_DIR,
|
||||
@@ -154,6 +158,84 @@ def register_routes(app: Flask,
|
||||
|
||||
# --- 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>")
|
||||
def list_reports(source):
|
||||
base = report_sources.get(source)
|
||||
|
||||
Reference in new issue
Block a user