mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
The in-app feed was a second copy of the activity log with a red badge on it. A scan published up to six notifications (started, completed, new subdomains, new services, new vulnerabilities, coverage), every one of them already an activity_logs row and every one of them toasted for five seconds. A nightly twenty-target schedule left forty to a hundred and twenty unread rows. If it does not want a human to do something, it is not a notification. Deleted scan_started, scan_completed and scan_cancelled (both the API-side echo of the user's own Cancel click and the finalize one). whois/ripestat enrichment_complete became enrichment_incomplete and return None unless something actually failed. One digest replaces the five scan templates. scan_digest(...) -> dict | None takes a ScanDeltas and derives its severity, title, type and deep-link tab from the worst thing inside it, and returns None when nothing changed, so a quiet scheduled run is silent. Severity now encodes risk rather than process: scan_failed is warning, not error, because the bell takes its colour from the highest unread severity and one flaky run should not read like a KEV. Toasts are interrupts only: warning and error toast, success and info land silently in the bell. A toast with a url finally gets a View action; it used to require an action_label no template ever set. notifications.project_id scopes the bell to the active project (nullable and indexed, no FK so a transient row can never block a project delete). The list, stats, read-all and clear-all endpoints take ?project_id=, and the store drops SSE events from other projects. app.tasks.notifications.cleanup runs on beat every six hours. expires_at had been decorative since nothing ever called cleanup_expired, and the inbox modal fetched stats.total rows in a single request. Three bugs this uncovered: _count_new_subdomains had no baseline gate while services and vulnerabilities did, so a target's first scan announced its whole inventory as new: the first gov.np run would have reported 24022 new subdomains. Baselines are now per dimension, and a run with none reports the census instead. The delta counts ignored census_only, so a focused rescan both counted as a baseline and had its own rows announced. Every comparison now joins scans bs ... scope = 'full' AND bs.started_at < :started. Measuring the deltas took 16.8s on a 13,528-host scan: the scan-level baseline was a correlated EXISTS re-evaluated once per row and could not use ix_subdomains_target_name_discovered, having no name predicate. Hoisting it to one scan-level check and rewriting the per-row test as a hash anti-join over a WITH seen AS (SELECT DISTINCT ...) CTE gives the identical answer in 52ms, verified against a plain EXCEPT query. Not done, deliberately: is_read is still one global flag, so on a shared instance one analyst's Mark all as read still applies to everyone; that needs a notification_reads table. And notifier.wants() still governs external channels only, the bell having no preference layer, because volume reduction was the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCPTXCr5qPESCt3LxR3329
248 lines
7.9 KiB
Python
248 lines
7.9 KiB
Python
"""Report generation runs here so the api never blocks on a render."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
from celery import shared_task
|
|
|
|
from app.config import settings
|
|
from app.database import get_sync_session
|
|
from reports import theme_store
|
|
from reports.pipeline import generate
|
|
from shared.definitions.reports import (
|
|
FORMAT_EXTENSIONS,
|
|
REPORT_ROOT,
|
|
RETENTION_DAYS,
|
|
ReportSpec,
|
|
ReportStatus,
|
|
)
|
|
from shared.enums.notification import NotificationSeverity, NotificationType
|
|
from shared.enums.sse import SSEChannel, SSEEventType
|
|
from shared.logging import get_logger
|
|
from shared.models.report import Report
|
|
from shared.models.scan import Scan
|
|
from shared.models.target import Target
|
|
from shared.services.event_publisher import SyncEventPublisher
|
|
from shared.services.notification_sync import SyncNotificationPublisher
|
|
from shared.utils.datetime import utc_now
|
|
from shared.utils.slug import generate_slug
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _root(report_id: UUID) -> Path:
|
|
path = Path(REPORT_ROOT) / str(report_id)
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _publisher() -> SyncEventPublisher | None:
|
|
try:
|
|
return SyncEventPublisher(settings.celery_broker_url)
|
|
except Exception:
|
|
logger.warning("report events unavailable")
|
|
return None
|
|
|
|
|
|
def _announce(publisher, report: Report) -> None:
|
|
if publisher is None:
|
|
return
|
|
payload = {
|
|
"id": str(report.id),
|
|
"status": report.status,
|
|
"progress": report.progress,
|
|
"step": report.step,
|
|
"error": report.error,
|
|
"page_count": report.page_count,
|
|
}
|
|
publisher.publish(
|
|
SSEChannel.report(str(report.id)), SSEEventType.REPORT.value, payload
|
|
)
|
|
publisher.publish(
|
|
SSEChannel.project(str(report.project_id)), SSEEventType.REPORT.value, payload
|
|
)
|
|
|
|
|
|
@shared_task(bind=True, name="app.tasks.reports.generate", max_retries=0)
|
|
def generate_report(self, report_id: str) -> dict:
|
|
started = time.monotonic()
|
|
publisher = _publisher()
|
|
|
|
with get_sync_session() as session:
|
|
report = session.get(Report, UUID(report_id))
|
|
if report is None:
|
|
logger.warning("report missing", report_id=report_id)
|
|
return {"status": "missing"}
|
|
|
|
report.status = ReportStatus.RUNNING.value
|
|
report.started_at = utc_now()
|
|
report.task_id = self.request.id
|
|
report.progress = 5
|
|
report.step = "Starting"
|
|
report.error = None
|
|
session.add(report)
|
|
session.commit()
|
|
_announce(publisher, report)
|
|
|
|
def progress(percent: int, label: str) -> None:
|
|
report.progress = percent
|
|
report.step = label
|
|
session.add(report)
|
|
session.commit()
|
|
_announce(publisher, report)
|
|
|
|
try:
|
|
spec = ReportSpec.model_validate(report.spec or {})
|
|
scan, target = _subject(session, report)
|
|
theme_store.sync_builtin(session)
|
|
output = generate(
|
|
session,
|
|
spec,
|
|
scan=scan,
|
|
target=target,
|
|
progress=progress,
|
|
)
|
|
|
|
stem = (
|
|
generate_slug(f"{spec.title or 'report'}-{target.target_value}")[:80]
|
|
or "report"
|
|
)
|
|
files = _write_files(report, output, stem)
|
|
|
|
_finish(report, output, files, round(time.monotonic() - started, 2))
|
|
session.add(report)
|
|
session.commit()
|
|
_announce(publisher, report)
|
|
_notify(session, report, ok=True)
|
|
logger.info(
|
|
"report ready",
|
|
report_id=report_id,
|
|
pages=output.pages,
|
|
seconds=report.duration_seconds,
|
|
)
|
|
return {"status": report.status, "pages": output.pages, "files": len(files)}
|
|
|
|
except Exception as exc:
|
|
_fail(
|
|
session, publisher, report_id, exc, round(time.monotonic() - started, 2)
|
|
)
|
|
logger.exception("report failed", report_id=report_id)
|
|
raise
|
|
|
|
|
|
def _fail(session, publisher, report_id: str, exc: Exception, seconds: float) -> None:
|
|
session.rollback()
|
|
report = session.get(Report, UUID(report_id))
|
|
if report is None:
|
|
return
|
|
report.status = ReportStatus.FAILED.value
|
|
report.error = str(exc)[:2000]
|
|
report.step = "Failed"
|
|
report.completed_at = utc_now()
|
|
report.duration_seconds = seconds
|
|
session.add(report)
|
|
session.commit()
|
|
_announce(publisher, report)
|
|
_notify(session, report, ok=False)
|
|
|
|
|
|
def _subject(session, report: Report) -> tuple[Scan | None, Target]:
|
|
target = session.get(Target, report.target_id) if report.target_id else None
|
|
scan = session.get(Scan, report.scan_id) if report.scan_id else None
|
|
if target is None and scan is not None:
|
|
target = session.get(Target, scan.target_id)
|
|
if target is None:
|
|
msg = "The report has no target to describe."
|
|
raise ValueError(msg)
|
|
return scan, target
|
|
|
|
|
|
def _write_files(report: Report, output, stem: str) -> list[dict]:
|
|
root = _root(report.id)
|
|
files: list[dict] = []
|
|
for fmt, data in output.files.items():
|
|
name = f"{stem}.{FORMAT_EXTENSIONS.get(fmt, fmt)}"
|
|
(root / name).write_bytes(data)
|
|
files.append(
|
|
{
|
|
"format": fmt,
|
|
"filename": name,
|
|
"bytes": len(data),
|
|
"pages": output.pages if fmt == "pdf" else None,
|
|
}
|
|
)
|
|
return files
|
|
|
|
|
|
def _finish(report: Report, output, files: list[dict], seconds: float) -> None:
|
|
report.files = files
|
|
report.page_count = output.pages
|
|
report.stats = output.stats
|
|
report.ai_used = output.ai_used
|
|
report.ai_provider = output.ai_provider or None
|
|
report.ai_model = output.ai_model or None
|
|
report.ai_calls = output.usage.calls
|
|
report.ai_cached_calls = output.usage.cached
|
|
report.ai_input_tokens = output.usage.input_tokens
|
|
report.ai_output_tokens = output.usage.output_tokens
|
|
report.status = ReportStatus.COMPLETED.value
|
|
report.progress = 100
|
|
report.step = "Ready"
|
|
report.completed_at = utc_now()
|
|
report.duration_seconds = seconds
|
|
report.expires_at = utc_now() + timedelta(days=RETENTION_DAYS)
|
|
|
|
|
|
def _notify(session, report: Report, *, ok: bool) -> None:
|
|
name = report.title or report.template_name or "Report"
|
|
body = (
|
|
f"{name} for {report.subject} is ready to download."
|
|
if ok
|
|
else f"{name} for {report.subject} could not be generated: {report.error}"
|
|
)
|
|
try:
|
|
SyncNotificationPublisher(settings.celery_broker_url).publish(
|
|
session,
|
|
NotificationType.SYSTEM,
|
|
NotificationSeverity.SUCCESS if ok else NotificationSeverity.ERROR,
|
|
"Report ready" if ok else "Report failed",
|
|
body,
|
|
project_id=report.project_id,
|
|
)
|
|
except Exception:
|
|
logger.debug("report notification skipped", exc_info=True)
|
|
|
|
|
|
@shared_task(name="app.tasks.reports.cleanup")
|
|
def cleanup() -> dict:
|
|
"""Drop generated files past their retention date."""
|
|
removed = 0
|
|
with get_sync_session() as session:
|
|
now = utc_now()
|
|
rows = (
|
|
session.query(Report)
|
|
.filter(Report.expires_at.is_not(None), Report.expires_at < now)
|
|
.limit(500)
|
|
.all()
|
|
)
|
|
for report in rows:
|
|
root = Path(REPORT_ROOT) / str(report.id)
|
|
if root.exists():
|
|
for item in root.iterdir():
|
|
item.unlink(missing_ok=True)
|
|
root.rmdir()
|
|
report.files = []
|
|
report.status = ReportStatus.FAILED.value
|
|
report.error = (
|
|
"The generated files passed their retention date and were removed."
|
|
)
|
|
session.add(report)
|
|
removed += 1
|
|
if removed:
|
|
session.commit()
|
|
return {"removed": removed}
|