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
365 lines
11 KiB
Python
365 lines
11 KiB
Python
"""Aggregate stage outcomes into the final scan status + terminal notification/event."""
|
|
|
|
from sqlalchemy import Integer, String, bindparam, select, text
|
|
from sqlalchemy.dialects.postgresql import ARRAY
|
|
from sqlalchemy.orm import Session
|
|
|
|
from shared.definitions.notifications import (
|
|
ScanDeltas,
|
|
scan_count_summary,
|
|
scan_digest,
|
|
scan_failed,
|
|
)
|
|
from shared.definitions.ports import SENSITIVE_PORTS
|
|
from shared.definitions.vulnerabilities import SUPPRESSED_STATES, CoverageStatus
|
|
from shared.enums.activity import ActivityEvent, ActivityLevel
|
|
from shared.enums.scan import SCAN_TERMINAL_STATUSES, ScanScope, ScanStatus
|
|
from shared.logging import get_logger
|
|
from shared.models.scan import Scan
|
|
from shared.models.scan_activity import ScanActivity
|
|
from shared.models.vulnerability import VulnerabilityCoverage
|
|
from shared.services.activity_log import ActivityLogService
|
|
from shared.services.notification_sync import SyncNotificationPublisher
|
|
from shared.services.orchestrator import (
|
|
aggregate_counts,
|
|
aggregate_status,
|
|
derived_counts,
|
|
)
|
|
from shared.services.orchestrator.events import ScanEventPublisher
|
|
from shared.services.recheck import compute_rechecks
|
|
from shared.utils.datetime import utc_now
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def _notify(
|
|
notifier: SyncNotificationPublisher,
|
|
session: Session,
|
|
scan: Scan,
|
|
payload: dict | None,
|
|
) -> None:
|
|
if payload is None:
|
|
return
|
|
try:
|
|
notifier.publish(
|
|
session=session,
|
|
type=payload["type"],
|
|
severity=payload["severity"],
|
|
title=payload["title"],
|
|
message=payload["message"],
|
|
metadata=payload.get("metadata"),
|
|
project_id=scan.project_id,
|
|
)
|
|
except Exception:
|
|
logger.warning("scan notification dispatch failed", exc_info=True)
|
|
|
|
|
|
def _finalize_user_cancelled(
|
|
session: Session, scan: Scan, events: ScanEventPublisher
|
|
) -> None:
|
|
if scan.completed_at is None:
|
|
scan.completed_at = utc_now()
|
|
session.add(scan)
|
|
session.commit()
|
|
_log_cancelled(ActivityLogService(session), scan)
|
|
session.commit()
|
|
events.scan_cancelled(status=scan.status)
|
|
|
|
|
|
def _log_cancelled(activity_log: ActivityLogService, scan: Scan) -> None:
|
|
target_value = (scan.execution_config or {}).get("target_value", "")
|
|
activity_log.log(
|
|
event=ActivityEvent.SCAN_CANCELLED,
|
|
title=f"Scan cancelled · {target_value}",
|
|
description=scan.engine_name,
|
|
level=ActivityLevel.WARNING,
|
|
project_id=scan.project_id,
|
|
target_id=scan.target_id,
|
|
scan_id=scan.id,
|
|
target_value=target_value,
|
|
)
|
|
|
|
|
|
# an earlier census run of this target; a focused rescan is never a baseline
|
|
_HOST_BASELINE_SQL = """
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM subdomains b
|
|
JOIN scans bs ON bs.id = b.scan_id AND bs.scope = 'full'
|
|
AND bs.id <> :sid AND bs.started_at < :started
|
|
WHERE b.target_id = :tid
|
|
)
|
|
"""
|
|
|
|
_SERVICE_BASELINE_SQL = """
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM ports b
|
|
JOIN scans bs ON bs.id = b.scan_id AND bs.scope = 'full'
|
|
AND bs.id <> :sid AND bs.started_at < :started
|
|
WHERE b.target_id = :tid
|
|
)
|
|
"""
|
|
|
|
_VULN_BASELINE_SQL = """
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM vulnerabilities b
|
|
JOIN scans bs ON bs.id = b.scan_id AND bs.scope = 'full'
|
|
AND bs.id <> :sid AND bs.started_at < :started
|
|
WHERE b.target_id = :tid
|
|
)
|
|
"""
|
|
|
|
_NEW_SUBDOMAINS_SQL = """
|
|
WITH seen AS (
|
|
SELECT DISTINCT b.name FROM subdomains b
|
|
JOIN scans bs ON bs.id = b.scan_id AND bs.scope = 'full'
|
|
AND bs.id <> :sid AND bs.started_at < :started
|
|
WHERE b.target_id = :tid
|
|
)
|
|
SELECT count(*) AS total
|
|
FROM subdomains p
|
|
WHERE p.scan_id = :sid
|
|
AND NOT EXISTS (SELECT 1 FROM seen s WHERE s.name = p.name)
|
|
"""
|
|
|
|
_NEW_SERVICES_SQL = """
|
|
WITH seen AS (
|
|
SELECT DISTINCT b.ip, b.number FROM ports b
|
|
JOIN scans bs ON bs.id = b.scan_id AND bs.scope = 'full'
|
|
AND bs.id <> :sid AND bs.started_at < :started
|
|
WHERE b.target_id = :tid
|
|
)
|
|
SELECT count(*) AS total,
|
|
count(*) FILTER (WHERE p.number = ANY(:sensitive_ports)) AS sensitive
|
|
FROM ports p
|
|
WHERE p.scan_id = :sid
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM seen s WHERE s.ip = p.ip AND s.number = p.number
|
|
)
|
|
"""
|
|
|
|
_NEW_VULNS_SQL = """
|
|
WITH seen AS (
|
|
SELECT DISTINCT b.fingerprint FROM vulnerabilities b
|
|
JOIN scans bs ON bs.id = b.scan_id AND bs.scope = 'full'
|
|
AND bs.id <> :sid AND bs.started_at < :started
|
|
WHERE b.target_id = :tid
|
|
)
|
|
SELECT v.severity AS severity,
|
|
count(*) AS total,
|
|
count(*) FILTER (WHERE v.is_kev) AS kev
|
|
FROM vulnerabilities v
|
|
WHERE v.scan_id = :sid
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM vulnerability_triage t
|
|
WHERE t.target_id = v.target_id AND t.fingerprint = v.fingerprint
|
|
AND t.state = ANY(:suppressed)
|
|
)
|
|
AND NOT EXISTS (SELECT 1 FROM seen s WHERE s.fingerprint = v.fingerprint)
|
|
GROUP BY v.severity
|
|
"""
|
|
|
|
|
|
def _has_baseline(session: Session, scan: Scan, sql: str) -> bool:
|
|
return bool(session.execute(text(sql).bindparams(*_scope(scan))).scalar_one())
|
|
|
|
|
|
def _scope(scan: Scan) -> list:
|
|
return [
|
|
bindparam("sid", scan.id),
|
|
bindparam("tid", scan.target_id),
|
|
bindparam("started", scan.started_at or utc_now()),
|
|
]
|
|
|
|
|
|
def _count_new_subdomains(session: Session, scan: Scan) -> int:
|
|
return int(
|
|
session.execute(
|
|
text(_NEW_SUBDOMAINS_SQL).bindparams(*_scope(scan))
|
|
).scalar_one()
|
|
or 0
|
|
)
|
|
|
|
|
|
def _count_new_services(session: Session, scan: Scan) -> tuple[int, int]:
|
|
row = session.execute(
|
|
text(_NEW_SERVICES_SQL).bindparams(
|
|
*_scope(scan),
|
|
bindparam("sensitive_ports", SENSITIVE_PORTS, type_=ARRAY(Integer)),
|
|
)
|
|
).one()
|
|
return int(row.total or 0), int(row.sensitive or 0)
|
|
|
|
|
|
def _count_new_vulnerabilities(session: Session, scan: Scan) -> tuple[dict, int, int]:
|
|
rows = session.execute(
|
|
text(_NEW_VULNS_SQL).bindparams(
|
|
*_scope(scan),
|
|
bindparam("suppressed", list(SUPPRESSED_STATES), type_=ARRAY(String)),
|
|
)
|
|
).all()
|
|
counts = {row.severity: int(row.total) for row in rows}
|
|
kev = sum(int(row.kev or 0) for row in rows)
|
|
return counts, kev, sum(counts.values())
|
|
|
|
|
|
def _dropped_hosts(session: Session, scan: Scan) -> int:
|
|
rows = (
|
|
session.execute(
|
|
select(VulnerabilityCoverage.hosts_dropped).where(
|
|
VulnerabilityCoverage.scan_id == scan.id,
|
|
VulnerabilityCoverage.status == CoverageStatus.PARTIAL.value,
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return sum(len(entry or []) for entry in rows)
|
|
|
|
|
|
def _guard(fn, fallback):
|
|
try:
|
|
return fn()
|
|
except Exception:
|
|
logger.warning("scan delta measurement failed", exc_info=True)
|
|
return fallback
|
|
|
|
|
|
def _measure(session: Session, scan: Scan) -> ScanDeltas:
|
|
"""Each dimension is only compared where an earlier census run covered it."""
|
|
hosts, services, vulns = (
|
|
_guard(lambda sql=sql: _has_baseline(session, scan, sql), False)
|
|
for sql in (_HOST_BASELINE_SQL, _SERVICE_BASELINE_SQL, _VULN_BASELINE_SQL)
|
|
)
|
|
new_services, sensitive = (
|
|
_guard(lambda: _count_new_services(session, scan), (0, 0))
|
|
if services
|
|
else (0, 0)
|
|
)
|
|
vuln_counts, kev, new_vulns = (
|
|
_guard(lambda: _count_new_vulnerabilities(session, scan), ({}, 0, 0))
|
|
if vulns
|
|
else ({}, 0, 0)
|
|
)
|
|
return ScanDeltas(
|
|
baseline=hosts or services or vulns,
|
|
new_hosts=_guard(lambda: _count_new_subdomains(session, scan), 0)
|
|
if hosts
|
|
else 0,
|
|
new_services=new_services,
|
|
sensitive_services=sensitive,
|
|
new_vulnerabilities=new_vulns,
|
|
vulnerability_counts=vuln_counts,
|
|
kev=kev,
|
|
dropped_hosts=_guard(lambda: _dropped_hosts(session, scan), 0),
|
|
)
|
|
|
|
|
|
def finalize_scan_run(session: Session, scan: Scan, *, redis_url: str) -> None:
|
|
events = ScanEventPublisher(
|
|
redis_url, scan_id=str(scan.id), project_id=str(scan.project_id)
|
|
)
|
|
notifier = SyncNotificationPublisher(redis_url)
|
|
target_value = (scan.execution_config or {}).get("target_value", "")
|
|
|
|
if scan.status in (ScanStatus.COMPLETED.value, ScanStatus.FAILED.value):
|
|
return
|
|
|
|
if scan.status == ScanStatus.CANCELLED.value:
|
|
_finalize_user_cancelled(session, scan, events)
|
|
return
|
|
|
|
activities = (
|
|
session.execute(select(ScanActivity).where(ScanActivity.scan_id == scan.id))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
status = aggregate_status(activities)
|
|
if status == ScanStatus.RUNNING.value:
|
|
logger.info("finalize deferred: scan %s still has in-flight stages", scan.id)
|
|
return
|
|
counts = {**aggregate_counts(activities), **derived_counts(session, scan.id)}
|
|
|
|
# Row-lock + re-check so a concurrent user-cancel (or a duplicate finalize) wins.
|
|
locked = session.get(Scan, scan.id, with_for_update=True)
|
|
if locked is None or locked.status in SCAN_TERMINAL_STATUSES:
|
|
session.commit()
|
|
return
|
|
|
|
for column, value in counts.items():
|
|
setattr(locked, column, value)
|
|
locked.status = status
|
|
locked.completed_at = utc_now()
|
|
duration = (
|
|
(locked.completed_at - locked.started_at).total_seconds()
|
|
if locked.started_at
|
|
else None
|
|
)
|
|
|
|
if status == ScanStatus.FAILED.value:
|
|
failed = next((a for a in activities if a.error), None)
|
|
locked.error = (
|
|
failed.error if failed and failed.error else "One or more stages failed"
|
|
)[:2000]
|
|
session.add(locked)
|
|
session.commit()
|
|
scan = locked
|
|
|
|
if scan.scope == ScanScope.FOCUSED.value:
|
|
try:
|
|
compute_rechecks(session, scan)
|
|
except Exception:
|
|
logger.warning("recheck diff failed for scan %s", scan.id, exc_info=True)
|
|
|
|
activity_log = ActivityLogService(session)
|
|
|
|
if status == ScanStatus.CANCELLED.value:
|
|
_log_cancelled(activity_log, scan)
|
|
session.commit()
|
|
events.scan_cancelled(status=status)
|
|
return
|
|
|
|
if status == ScanStatus.COMPLETED.value:
|
|
activity_log.log(
|
|
event=ActivityEvent.SCAN_COMPLETED,
|
|
title=f"Scan completed · {target_value}",
|
|
description=scan_count_summary(counts),
|
|
level=ActivityLevel.SUCCESS,
|
|
project_id=scan.project_id,
|
|
target_id=scan.target_id,
|
|
scan_id=scan.id,
|
|
target_value=target_value,
|
|
)
|
|
session.commit()
|
|
events.scan_completed(status=status, counts=counts, duration_seconds=duration)
|
|
if scan.scope != ScanScope.FOCUSED.value:
|
|
_notify(
|
|
notifier,
|
|
session,
|
|
scan,
|
|
scan_digest(
|
|
str(scan.id), target_value, counts, _measure(session, scan)
|
|
),
|
|
)
|
|
return
|
|
|
|
activity_log.log(
|
|
event=ActivityEvent.SCAN_FAILED,
|
|
title=f"Scan failed · {target_value}",
|
|
description=scan.error or "One or more stages failed",
|
|
level=ActivityLevel.ERROR,
|
|
project_id=scan.project_id,
|
|
target_id=scan.target_id,
|
|
scan_id=scan.id,
|
|
target_value=target_value,
|
|
)
|
|
session.commit()
|
|
events.scan_failed(status=status, error=scan.error)
|
|
_notify(
|
|
notifier,
|
|
session,
|
|
scan,
|
|
scan_failed(
|
|
str(scan.id), target_value, scan.engine_name, scan.error or "unknown error"
|
|
),
|
|
)
|