mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 15:21:30 +02:00
A target can follow the check library: once a day, when the library gains checks, one focused run tests every web asset the target's completed scans found, with those checks only. Per-target watermark, one sweep lock, a `daily.run` chain that registers future once-a-day jobs, two notifications, a What's new group, a "New" badge on the library, a monitored-target signal and a "New checks runs" scan filter. The pipeline review behind it, in three passes: Planner and scanner. The deep sweep is its own tier, outside ROOT_TIERS and after replay, so a cut costs the tail and cluster members are still confirmed; the one-request tier runs first, sized on its clustered cost; blind core ranks CISA then VulnCheck then severity then cost; the planned tiers come from the selection; skipped hosts are marked alone; library gaps are one row per run; the sweep cap is 8 requests. Output. Payload-free fingerprints for fuzzing, out-of-band and dalfox findings; suppression follows the replay edge; the digest baseline is every earlier scan; host and port read through split_host_port; interaction obeys store_evidence and redaction; the rollup excludes replays. Tools. nuclei's exit code, abort and budget are read; a tool arg cannot restate a stage flag under its short spelling; one refused row costs one row; a headless block decides the protocol. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B58eEM68a5qDFTQXPE5aoW
651 lines
21 KiB
Python
651 lines
21 KiB
Python
"""Notification templates: one interrupt per event."""
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
from shared.definitions.bounty_programs import BountyEvent, event_spec
|
|
from shared.definitions.interest import InterestBand, kind_label
|
|
from shared.definitions.surface import SURFACE_NOUN, SurfaceDimension
|
|
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}"
|
|
|
|
|
|
MAX_NAMED_TARGETS = 3
|
|
ENRICHMENT_FAILED = "The lookup did not complete. Check the worker log."
|
|
|
|
|
|
def _subject(names: Sequence[str], failed: int, total: int) -> str:
|
|
"""Name the targets when there are few enough to read, else count them."""
|
|
listed = [n for n in names if n][:MAX_NAMED_TARGETS]
|
|
if listed and failed <= MAX_NAMED_TARGETS:
|
|
return ", ".join(listed)
|
|
return f"{failed} of {total} {'target' if total == 1 else 'targets'}"
|
|
|
|
|
|
def whois_enrichment_incomplete(
|
|
success: int, failed: int, total: int, names: Sequence[str] = ()
|
|
) -> dict | None:
|
|
if not failed:
|
|
return None
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.WARNING,
|
|
"title": "WHOIS lookup failed",
|
|
"message": (
|
|
f"WHOIS lookup failed for {_subject(names, failed, total)}."
|
|
f"{f' {success} succeeded.' if success else ''}"
|
|
),
|
|
}
|
|
|
|
|
|
def whois_enrichment_failed() -> dict:
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.ERROR,
|
|
"title": "WHOIS enrichment failed",
|
|
"message": "No target was enriched. Check the worker log.",
|
|
}
|
|
|
|
|
|
def ripestat_enrichment_incomplete(
|
|
success: int, failed: int, skipped: int, total: int, names: Sequence[str] = ()
|
|
) -> dict | None:
|
|
if not failed:
|
|
return None
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.WARNING,
|
|
"title": "BGP enrichment failed",
|
|
"message": (
|
|
f"BGP lookup failed for {_subject(names, failed, total)}."
|
|
f"{f' {success} succeeded.' if success else ''}"
|
|
f"{f' {skipped} had nothing to look up.' if skipped else ''}"
|
|
),
|
|
}
|
|
|
|
|
|
def ripestat_enrichment_failed() -> dict:
|
|
return {
|
|
"type": NotificationType.TARGET,
|
|
"severity": NotificationSeverity.ERROR,
|
|
"title": "BGP enrichment failed",
|
|
"message": "No target was enriched. Check the worker log.",
|
|
}
|
|
|
|
|
|
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}
|
|
|
|
|
|
def _noun(
|
|
dimension: SurfaceDimension, *, before: str = "", after: str = ""
|
|
) -> tuple[str, str]:
|
|
singular, plural = SURFACE_NOUN[dimension.value]
|
|
return (
|
|
" ".join(w for w in (before, singular, after) if w),
|
|
" ".join(w for w in (before, plural, after) if w),
|
|
)
|
|
|
|
|
|
HTTP_SERVICE_NOUN = ("HTTP service", "HTTP services")
|
|
|
|
|
|
_SCAN_COUNT_LABELS: dict[str, tuple[str, str]] = {
|
|
"subdomains_found": _noun(SurfaceDimension.WEB_ASSETS),
|
|
"ips_found": _noun(SurfaceDimension.IPS),
|
|
"open_ports_found": _noun(SurfaceDimension.SERVICES),
|
|
"http_assets_found": HTTP_SERVICE_NOUN,
|
|
"vulnerabilities_found": _noun(SurfaceDimension.VULNERABILITIES),
|
|
"endpoints_found": _noun(SurfaceDimension.ENDPOINTS),
|
|
"secrets_found": _noun(SurfaceDimension.SECRETS),
|
|
}
|
|
|
|
|
|
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": _noun(SurfaceDimension.WEB_ASSETS, before="resolving"),
|
|
"addresses": _noun(SurfaceDimension.IPS),
|
|
"alive": _noun(SurfaceDimension.WEB_ASSETS, before="responsive"),
|
|
"answered": ("answered", "answered"),
|
|
"bgp": ("BGP record", "BGP records"),
|
|
"cdn": ("CDN-fronted address", "CDN-fronted addresses"),
|
|
"checked": _noun(SurfaceDimension.SERVICES, after="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": _noun(SurfaceDimension.ENDPOINTS),
|
|
"endpoints_new": _noun(SurfaceDimension.ENDPOINTS, before="new"),
|
|
"endpoints_probed": _noun(SurfaceDimension.ENDPOINTS, after="requested"),
|
|
"enriched": _noun(SurfaceDimension.IPS, after="enriched"),
|
|
"fingerprinted": _noun(SurfaceDimension.SERVICES, after="identified"),
|
|
"http_assets": HTTP_SERVICE_NOUN,
|
|
"ips": _noun(SurfaceDimension.IPS),
|
|
"known_ports": _noun(SurfaceDimension.SERVICES, before="known"),
|
|
"new": ("new", "new"),
|
|
"open_ports": _noun(SurfaceDimension.SERVICES, before="open"),
|
|
"posture_issues": ("posture check failing", "posture checks failing"),
|
|
"probed": _noun(SurfaceDimension.WEB_ASSETS, after="probed"),
|
|
"ptr": ("PTR record", "PTR records"),
|
|
"scanned": _noun(SurfaceDimension.IPS, after="scanned"),
|
|
"screenshots": ("screenshot", "screenshots"),
|
|
"secrets": _noun(SurfaceDimension.SECRETS),
|
|
"documents": ("response read", "responses read"),
|
|
"skipped": ("skipped", "skipped"),
|
|
"subdomains": _noun(SurfaceDimension.WEB_ASSETS),
|
|
"targets": ("target", "targets"),
|
|
"vulnerabilities": _noun(SurfaceDimension.VULNERABILITIES),
|
|
"waf": ("firewall identified", "firewalls identified"),
|
|
"web_services": _noun(SurfaceDimension.SERVICES, before="web"),
|
|
"whois": ("WHOIS record", "WHOIS records"),
|
|
"zones": ("zone checked", "zones checked"),
|
|
}
|
|
|
|
|
|
def stage_count_summary(counts: dict) -> str:
|
|
"""Label every figure a stage reports."""
|
|
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
|
|
posture_regressions: 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
|
|
or self.posture_regressions
|
|
)
|
|
|
|
|
|
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, *_noun(SurfaceDimension.VULNERABILITIES, before="critical")
|
|
)
|
|
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,
|
|
*_noun(SurfaceDimension.VULNERABILITIES, before="new"),
|
|
)
|
|
elif deltas.posture_regressions:
|
|
head = "Sender policy weakened"
|
|
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. This run found {scan_count_summary(counts)}."
|
|
else:
|
|
detail = _severity_phrase(deltas.vulnerability_counts)
|
|
parts = [
|
|
text
|
|
for text, n in (
|
|
(
|
|
_count(
|
|
deltas.new_hosts,
|
|
*_noun(SurfaceDimension.WEB_ASSETS, before="new"),
|
|
),
|
|
deltas.new_hosts,
|
|
),
|
|
(
|
|
_count(
|
|
deltas.new_services,
|
|
*_noun(SurfaceDimension.SERVICES, before="new"),
|
|
),
|
|
deltas.new_services,
|
|
),
|
|
(
|
|
_count(
|
|
deltas.new_vulnerabilities,
|
|
*_noun(SurfaceDimension.VULNERABILITIES, before="new"),
|
|
)
|
|
+ (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.posture_regressions:
|
|
body += (
|
|
f" {_count(deltas.posture_regressions, 'zone', 'zones')} lost SPF or "
|
|
"DMARC protection since the previous run."
|
|
)
|
|
if deltas.dropped_hosts:
|
|
body += (
|
|
" Testing stopped on "
|
|
f"{_count(deltas.dropped_hosts, *_noun(SurfaceDimension.WEB_ASSETS))} "
|
|
f"after repeated errors. Coverage there is partial."
|
|
)
|
|
return body
|
|
|
|
|
|
def scan_digest(
|
|
scan_id: str, target: str, counts: dict, deltas: ScanDeltas
|
|
) -> dict | None:
|
|
"""One row per run."""
|
|
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
|
|
or deltas.posture_regressions
|
|
):
|
|
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),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class InterestLead:
|
|
host: str
|
|
band: str
|
|
score: int
|
|
kinds: tuple[str, ...] = ()
|
|
source: str = ""
|
|
|
|
|
|
def _lead_line(lead: InterestLead) -> str:
|
|
reasons = ", ".join(kind_label(k) for k in lead.kinds[:3])
|
|
return f"• {lead.host}" + (f" · {reasons}" if reasons else "")
|
|
|
|
|
|
def scan_interesting(
|
|
scan_id: str, target: str, leads: list[InterestLead], shown: int = 5
|
|
) -> dict | None:
|
|
"""Hosts not flagged by an earlier scan of this target."""
|
|
if not leads:
|
|
return None
|
|
critical = [x for x in leads if x.band == InterestBand.CRITICAL.value]
|
|
severity = NotificationSeverity.WARNING if critical else NotificationSeverity.INFO
|
|
head = _count(len(leads), "new asset", "new assets")
|
|
title = f"{head} flagged as an exposure on {target}"
|
|
body = "\n".join(_lead_line(lead) for lead in leads[:shown])
|
|
if len(leads) > shown:
|
|
body += f"\n… and {len(leads) - shown} more"
|
|
return {
|
|
"type": NotificationType.SCAN,
|
|
"severity": severity,
|
|
"title": title,
|
|
"message": body,
|
|
"metadata": _scan_meta(scan_id, "interesting"),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class IntelShift:
|
|
"""A finding whose exploitation intelligence moved without a new scan."""
|
|
|
|
cve: str
|
|
target: str
|
|
finding: str
|
|
kind: str
|
|
scan_id: str
|
|
|
|
|
|
def _shift_line(shift: IntelShift) -> str:
|
|
return f"• {shift.cve} · {shift.target} · {shift.finding}"
|
|
|
|
|
|
def intel_changed(shifts: list["IntelShift"], shown: int = 5) -> dict | None:
|
|
"""Delta-only: findings that earned a new exploitation signal since the last refresh."""
|
|
if not shifts:
|
|
return None
|
|
exploited = [s for s in shifts if s.kind in {"kev", "ransom_path", "fresh_exploit"}]
|
|
severity = NotificationSeverity.ERROR if exploited else NotificationSeverity.WARNING
|
|
head = _count(len(shifts), "finding", "findings")
|
|
what = "became known-exploited" if exploited else "gained a public exploit"
|
|
title = f"{head} {what} since the last refresh"
|
|
body = "\n".join(_shift_line(s) for s in shifts[:shown])
|
|
if len(shifts) > shown:
|
|
body += f"\n… and {len(shifts) - shown} more"
|
|
body += "\nThe exploitation feeds changed. No scan ran."
|
|
return {
|
|
"type": NotificationType.SCAN,
|
|
"severity": severity,
|
|
"title": title,
|
|
"message": body,
|
|
"metadata": {"kind": "threat_intel"},
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SoftwareExposure:
|
|
"""A software CVE match the nightly corpus refresh wrote for the first time."""
|
|
|
|
cve: str
|
|
host: str
|
|
name: str
|
|
version: str
|
|
severity: str
|
|
is_kev: bool
|
|
kev_ransomware: bool
|
|
|
|
|
|
def _exposure_line(e: SoftwareExposure) -> str:
|
|
return f"• {e.cve} · {e.host} · {e.name} {e.version}"
|
|
|
|
|
|
def software_exposed(
|
|
exposures: list["SoftwareExposure"], shown: int = 5
|
|
) -> dict | None:
|
|
"""Delta-only: matches absent from the previous corpus. Severe or known exploited."""
|
|
loud: list[SoftwareExposure] = []
|
|
seen: set[tuple[str, str, str, str]] = set()
|
|
for e in exposures:
|
|
key = (e.cve, e.host, e.name, e.version)
|
|
if key in seen or not (e.is_kev or e.severity in ALERT_SEVERITIES):
|
|
continue
|
|
seen.add(key)
|
|
loud.append(e)
|
|
if not loud:
|
|
return None
|
|
exploited = any(e.is_kev for e in loud)
|
|
cves = sorted({e.cve for e in loud})
|
|
assets = _count(len({e.host for e in loud}), "asset", "assets")
|
|
what = "known-exploited" if exploited else "published"
|
|
if len(cves) == 1:
|
|
title = f"{assets} newly match {cves[0]}"
|
|
else:
|
|
title = (
|
|
f"{assets} newly match {_count(len(cves), f'{what} CVE', f'{what} CVEs')}"
|
|
)
|
|
body = "\n".join(_exposure_line(e) for e in loud[:shown])
|
|
if len(loud) > shown:
|
|
body += f"\n… and {len(loud) - shown} more"
|
|
body += "\nThe NVD corpus changed. No scan ran."
|
|
url = (
|
|
f"/surface/cve/{cves[0]}"
|
|
if len(cves) == 1
|
|
else "/surface/software?sw_q=seen%3A%3C24h"
|
|
)
|
|
return {
|
|
"type": NotificationType.VULNERABILITY,
|
|
"severity": NotificationSeverity.ERROR
|
|
if exploited
|
|
else NotificationSeverity.WARNING,
|
|
"title": title,
|
|
"message": body,
|
|
"metadata": {"kind": "software_exposed", "url": url, "cves": cves[:25]},
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class BountyChange:
|
|
kind: str
|
|
program: str
|
|
handle: str
|
|
asset: str | None = None
|
|
|
|
|
|
def _bounty_line(change: "BountyChange") -> str:
|
|
what = f" · {change.asset}" if change.asset else ""
|
|
return f"• {event_spec(change.kind).label} · {change.program}{what}"
|
|
|
|
|
|
def bounty_changes(changes: list["BountyChange"], shown: int = 6) -> dict | None:
|
|
"""Delta-only: what a program changed since the last sync."""
|
|
if not changes:
|
|
return None
|
|
stop = [c for c in changes if c.kind == BountyEvent.WENT_OUT_OF_SCOPE.value]
|
|
fresh = [
|
|
c
|
|
for c in changes
|
|
if c.kind in {BountyEvent.PROGRAM_ADDED.value, BountyEvent.SCOPE_ADDED.value}
|
|
]
|
|
severity = NotificationSeverity.WARNING if stop else NotificationSeverity.INFO
|
|
if stop:
|
|
title = _count(len(stop), "asset", "assets") + " went out of scope"
|
|
elif fresh:
|
|
title = _count(len(fresh), "scope change", "scope changes")
|
|
else:
|
|
title = _count(len(changes), "program change", "program changes")
|
|
body = "\n".join(_bounty_line(c) for c in changes[:shown])
|
|
if len(changes) > shown:
|
|
body += f"\n… and {len(changes) - shown} more"
|
|
return {
|
|
"type": NotificationType.INTEGRATION,
|
|
"severity": severity,
|
|
"title": title,
|
|
"message": body,
|
|
"metadata": {"kind": "bounty_programs"},
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WatchAlert:
|
|
program: str
|
|
host: str
|
|
matched_item: str | None
|
|
status_code: int | None
|
|
title: str | None
|
|
tech: tuple[str, ...]
|
|
ips: tuple[str, ...]
|
|
issuer: str | None
|
|
not_before: datetime | None
|
|
scan_id: str | None
|
|
target_id: str | None = None
|
|
wildcard: bool = False
|
|
repeat: bool = False
|
|
|
|
|
|
@dataclass
|
|
class NewChecksSweep:
|
|
templates: int
|
|
targets: int
|
|
busy: int = 0
|
|
waiting: int = 0
|
|
skipped: int = 0
|
|
|
|
|
|
def new_checks_started(sweep: NewChecksSweep) -> dict | None:
|
|
"""One message per project when the library gained checks and follow-up runs started."""
|
|
if sweep.templates <= 0:
|
|
return None
|
|
noun = "check" if sweep.templates == 1 else "checks"
|
|
lines = [
|
|
f"Follow-up runs started for {sweep.targets} "
|
|
f"{'target' if sweep.targets == 1 else 'targets'}."
|
|
]
|
|
if sweep.busy:
|
|
lines.append(
|
|
f"{sweep.busy} {'target' if sweep.busy == 1 else 'targets'} skipped: "
|
|
"a scan is running."
|
|
)
|
|
if sweep.waiting:
|
|
lines.append(
|
|
f"{sweep.waiting} {'target waits' if sweep.waiting == 1 else 'targets wait'} "
|
|
"for a completed scan."
|
|
)
|
|
if sweep.skipped:
|
|
lines.append(
|
|
f"{sweep.skipped} {'target' if sweep.skipped == 1 else 'targets'} had "
|
|
"nothing to run: no applicable check, no web asset, or passive intensity."
|
|
)
|
|
return {
|
|
"type": NotificationType.NEW_CHECKS,
|
|
"severity": NotificationSeverity.INFO,
|
|
"title": f"{sweep.templates} new {noun} in the library",
|
|
"message": "\n".join(lines),
|
|
"metadata": {"url": "/arsenal?tab=nuclei"},
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class NewChecksResult:
|
|
scan_id: str
|
|
target: str
|
|
checks: int
|
|
findings: int
|
|
by_severity: dict[str, int] = field(default_factory=dict)
|
|
|
|
|
|
def new_checks_result(result: NewChecksResult) -> dict | None:
|
|
"""One message per follow-up run that found something."""
|
|
if result.findings <= 0:
|
|
return None
|
|
counts = [
|
|
f"{n} {sev}" for sev in SEVERITY_ORDER if (n := result.by_severity.get(sev, 0))
|
|
]
|
|
checks = (
|
|
f"{result.checks} new {'check' if result.checks == 1 else 'checks'} tested."
|
|
)
|
|
found = f"{result.findings} {'finding' if result.findings == 1 else 'findings'}"
|
|
if counts:
|
|
found += ": " + ", ".join(counts)
|
|
severity = NotificationSeverity.INFO
|
|
if result.by_severity.get(Severity.CRITICAL.value):
|
|
severity = NotificationSeverity.ERROR
|
|
elif result.by_severity.get(Severity.HIGH.value):
|
|
severity = NotificationSeverity.WARNING
|
|
return {
|
|
"type": NotificationType.NEW_CHECKS,
|
|
"severity": severity,
|
|
"title": f"New checks · {result.target}",
|
|
"message": f"{checks}\n{found}.",
|
|
"metadata": _scan_meta(result.scan_id, "vulnerabilities"),
|
|
}
|
|
|
|
|
|
def watch_alert(alert: WatchAlert) -> dict:
|
|
"""One message per new in-scope host, again only when the host changes."""
|
|
head = "Changed in-scope asset" if alert.repeat else "New in-scope asset"
|
|
lines = [alert.host]
|
|
if alert.status_code is not None:
|
|
answer = str(alert.status_code)
|
|
if alert.title:
|
|
answer += f" · {alert.title}"
|
|
lines.append(answer)
|
|
if alert.tech:
|
|
lines.append(", ".join(alert.tech[:6]))
|
|
if alert.ips:
|
|
addresses = ", ".join(alert.ips[:4])
|
|
lines.append(addresses + (" · wildcard DNS" if alert.wildcard else ""))
|
|
if alert.issuer or alert.not_before:
|
|
when = alert.not_before.strftime("%Y-%m-%d %H:%M") if alert.not_before else ""
|
|
lines.append(" · ".join(p for p in (alert.issuer or "", when) if p))
|
|
if alert.matched_item:
|
|
lines.append(f"Scope {alert.matched_item}")
|
|
return {
|
|
"type": NotificationType.WATCH,
|
|
"severity": NotificationSeverity.INFO,
|
|
"title": f"{head} · {alert.program}",
|
|
"message": "\n".join(lines),
|
|
"metadata": _scan_meta(alert.scan_id)
|
|
if alert.scan_id
|
|
else {"target_id": alert.target_id, "url": f"/targets/{alert.target_id}"}
|
|
if alert.target_id
|
|
else {},
|
|
}
|