mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 15:21:30 +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
281 lines
9.3 KiB
Python
281 lines
9.3 KiB
Python
"""Notification templates: one interrupt per event, never a copy of the activity log."""
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
from shared.definitions.vulnerabilities import (
|
|
ALERT_SEVERITIES,
|
|
SEVERITY_LABELS,
|
|
SEVERITY_ORDER,
|
|
Severity,
|
|
)
|
|
from shared.enums.notification import NotificationSeverity, NotificationType
|
|
|
|
|
|
def _count(n: int, singular: str, plural: str) -> str:
|
|
return f"{n:,} {singular if n == 1 else plural}"
|
|
|
|
|
|
def whois_enrichment_incomplete(success: int, failed: int, total: int) -> dict | None:
|
|
if not failed:
|
|
return None
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.WARNING,
|
|
"title": "WHOIS lookup failed",
|
|
"message": (
|
|
f"WHOIS could not be resolved for {failed} of {total} "
|
|
f"{'target' if total == 1 else 'targets'}"
|
|
f"{f'; {success} succeeded' if success else ''}."
|
|
),
|
|
}
|
|
|
|
|
|
def whois_enrichment_failed(error: str) -> dict:
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.ERROR,
|
|
"title": "WHOIS enrichment failed",
|
|
"message": f"WHOIS enrichment failed. {error}",
|
|
}
|
|
|
|
|
|
def ripestat_enrichment_incomplete(
|
|
success: int, failed: int, skipped: int, total: int
|
|
) -> dict | None:
|
|
if not failed:
|
|
return None
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.WARNING,
|
|
"title": "BGP enrichment failed",
|
|
"message": (
|
|
f"BGP data could not be resolved for {failed} of {total} "
|
|
f"{'target' if total == 1 else 'targets'}"
|
|
f"{f'; {success} succeeded' if success else ''}"
|
|
f"{f', {skipped} had nothing to look up' if skipped else ''}."
|
|
),
|
|
}
|
|
|
|
|
|
def ripestat_enrichment_failed(error: str) -> dict:
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.ERROR,
|
|
"title": "BGP enrichment failed",
|
|
"message": f"BGP enrichment failed. {error}",
|
|
}
|
|
|
|
|
|
def _scan_meta(scan_id: str, tab: str | None = None) -> dict:
|
|
url = f"/scans/{scan_id}" + (f"?tab={tab}" if tab else "")
|
|
return {"scan_id": str(scan_id), "url": url}
|
|
|
|
|
|
_SCAN_COUNT_LABELS = {
|
|
"subdomains_found": ("host", "hosts"),
|
|
"ips_found": ("address", "addresses"),
|
|
"open_ports_found": ("service", "services"),
|
|
"http_assets_found": ("HTTP service", "HTTP services"),
|
|
"vulnerabilities_found": ("finding", "findings"),
|
|
"endpoints_found": ("endpoint", "endpoints"),
|
|
}
|
|
|
|
|
|
def scan_count_summary(counts: dict) -> str:
|
|
parts = [
|
|
_count(n, singular, plural)
|
|
for col, (singular, plural) in _SCAN_COUNT_LABELS.items()
|
|
if (n := counts.get(col, 0))
|
|
]
|
|
return ", ".join(parts) if parts else "no results"
|
|
|
|
|
|
_STAGE_COUNT_LABELS: dict[str, tuple[str, str]] = {
|
|
"active": ("resolving host", "resolving hosts"),
|
|
"addresses": ("address", "addresses"),
|
|
"alive": ("responsive host", "responsive hosts"),
|
|
"answered": ("answered", "answered"),
|
|
"bgp": ("BGP record", "BGP records"),
|
|
"cdn": ("CDN-fronted address", "CDN-fronted addresses"),
|
|
"checked": ("service checked", "services checked"),
|
|
"checks": ("check run", "checks run"),
|
|
"cloud": ("cloud-hosted address", "cloud-hosted addresses"),
|
|
"dns_records": ("DNS record", "DNS records"),
|
|
"edge_only": ("CDN edge address", "CDN edge addresses"),
|
|
"endpoints": ("endpoint", "endpoints"),
|
|
"endpoints_new": ("new endpoint", "new endpoints"),
|
|
"endpoints_probed": ("endpoint requested", "endpoints requested"),
|
|
"enriched": ("address enriched", "addresses enriched"),
|
|
"fingerprinted": ("service identified", "services identified"),
|
|
"http_assets": ("web service", "web services"),
|
|
"ips": ("address", "addresses"),
|
|
"known_ports": ("known service", "known services"),
|
|
"new": ("new", "new"),
|
|
"open_ports": ("open service", "open services"),
|
|
"probed": ("host probed", "hosts probed"),
|
|
"ptr": ("PTR record", "PTR records"),
|
|
"scanned": ("address scanned", "addresses scanned"),
|
|
"screenshots": ("screenshot", "screenshots"),
|
|
"skipped": ("skipped", "skipped"),
|
|
"subdomains": ("host", "hosts"),
|
|
"targets": ("target", "targets"),
|
|
"vulnerabilities": ("finding", "findings"),
|
|
"waf": ("firewall identified", "firewalls identified"),
|
|
"web_services": ("web service", "web services"),
|
|
"whois": ("WHOIS record", "WHOIS records"),
|
|
}
|
|
|
|
|
|
def stage_count_summary(counts: dict) -> str:
|
|
"""Label every figure a stage reports. An unlabelled key is omitted, never printed raw."""
|
|
parts = [
|
|
_count(n, *_STAGE_COUNT_LABELS[key])
|
|
for key, n in counts.items()
|
|
if isinstance(n, int) and n and key in _STAGE_COUNT_LABELS
|
|
]
|
|
return ", ".join(parts) if parts else "no results"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScanDeltas:
|
|
baseline: bool = False
|
|
new_hosts: int = 0
|
|
new_services: int = 0
|
|
sensitive_services: int = 0
|
|
new_vulnerabilities: int = 0
|
|
vulnerability_counts: dict[str, int] = field(default_factory=dict)
|
|
kev: int = 0
|
|
dropped_hosts: int = 0
|
|
|
|
@property
|
|
def critical(self) -> int:
|
|
return self.vulnerability_counts.get(Severity.CRITICAL.value, 0)
|
|
|
|
@property
|
|
def severe(self) -> int:
|
|
return sum(self.vulnerability_counts.get(s, 0) for s in ALERT_SEVERITIES)
|
|
|
|
def worth_reporting(self, counts: dict) -> bool:
|
|
if not self.baseline:
|
|
return any(counts.get(col) for col in _SCAN_COUNT_LABELS)
|
|
return bool(
|
|
self.new_hosts
|
|
or self.new_services
|
|
or self.new_vulnerabilities
|
|
or self.dropped_hosts
|
|
)
|
|
|
|
|
|
def _severity_phrase(counts: dict) -> str:
|
|
return ", ".join(
|
|
f"{counts[name]} {SEVERITY_LABELS[name].lower()}"
|
|
for name in SEVERITY_ORDER
|
|
if counts.get(name)
|
|
)
|
|
|
|
|
|
def _digest_title(target: str, deltas: ScanDeltas) -> str:
|
|
if deltas.critical:
|
|
head = _count(deltas.critical, "critical finding", "critical findings")
|
|
elif deltas.kev:
|
|
head = _count(
|
|
deltas.kev, "exploited vulnerability", "exploited vulnerabilities"
|
|
)
|
|
elif deltas.severe or deltas.sensitive_services:
|
|
head = "New exposure"
|
|
elif not deltas.baseline:
|
|
return f"First scan of {target}"
|
|
elif deltas.new_vulnerabilities:
|
|
head = _count(deltas.new_vulnerabilities, "new finding", "new findings")
|
|
elif deltas.new_hosts or deltas.new_services:
|
|
head = "New assets"
|
|
else:
|
|
head = "Partial coverage"
|
|
return f"{head} on {target}"
|
|
|
|
|
|
def _digest_body(counts: dict, deltas: ScanDeltas) -> str:
|
|
if not deltas.baseline:
|
|
body = f"No earlier run to compare against. This run found {scan_count_summary(counts)}."
|
|
else:
|
|
detail = _severity_phrase(deltas.vulnerability_counts)
|
|
parts = [
|
|
text
|
|
for text, n in (
|
|
(_count(deltas.new_hosts, "new host", "new hosts"), deltas.new_hosts),
|
|
(
|
|
_count(deltas.new_services, "new service", "new services"),
|
|
deltas.new_services,
|
|
),
|
|
(
|
|
_count(deltas.new_vulnerabilities, "new finding", "new findings")
|
|
+ (f" ({detail})" if detail else ""),
|
|
deltas.new_vulnerabilities,
|
|
),
|
|
)
|
|
if n
|
|
]
|
|
if parts:
|
|
body = ", ".join(parts) + "."
|
|
else:
|
|
body = "Nothing new since the previous run."
|
|
|
|
if deltas.sensitive_services:
|
|
n = deltas.sensitive_services
|
|
body += (
|
|
f" {n} {'is' if n == 1 else 'are'} on an administrative or datastore port."
|
|
)
|
|
if deltas.kev:
|
|
body += (
|
|
f" {deltas.kev} {'is' if deltas.kev == 1 else 'are'} "
|
|
f"known to be exploited in the wild."
|
|
)
|
|
if deltas.dropped_hosts:
|
|
body += (
|
|
f" Testing stopped on {_count(deltas.dropped_hosts, 'host', 'hosts')} "
|
|
f"after repeated errors, so coverage there is partial."
|
|
)
|
|
return body
|
|
|
|
|
|
def scan_digest(
|
|
scan_id: str, target: str, counts: dict, deltas: ScanDeltas
|
|
) -> dict | None:
|
|
"""One row per run; None when there is nothing to say."""
|
|
if not deltas.worth_reporting(counts):
|
|
return None
|
|
|
|
if deltas.critical or deltas.kev:
|
|
severity = NotificationSeverity.ERROR
|
|
elif deltas.severe or deltas.sensitive_services or deltas.dropped_hosts:
|
|
severity = NotificationSeverity.WARNING
|
|
else:
|
|
severity = NotificationSeverity.SUCCESS
|
|
|
|
if deltas.new_vulnerabilities or deltas.dropped_hosts:
|
|
tab = "vulnerabilities"
|
|
elif deltas.sensitive_services or deltas.new_services:
|
|
tab = "services"
|
|
else:
|
|
tab = None
|
|
|
|
return {
|
|
"type": NotificationType.VULNERABILITY
|
|
if deltas.new_vulnerabilities
|
|
else NotificationType.SCAN,
|
|
"severity": severity,
|
|
"title": _digest_title(target, deltas),
|
|
"message": _digest_body(counts, deltas),
|
|
"metadata": _scan_meta(scan_id, tab),
|
|
}
|
|
|
|
|
|
def scan_failed(scan_id: str, target: str, engine: str, error: str) -> dict:
|
|
return {
|
|
"type": NotificationType.SCAN,
|
|
"severity": NotificationSeverity.WARNING,
|
|
"title": f"Scan failed on {target}",
|
|
"message": f"The {engine} run did not finish: {error[:300]}",
|
|
"metadata": _scan_meta(scan_id),
|
|
}
|