Files
vulncheck/app/services/email_service.py
T
vulncheck 56454dff2a feat(notifications): aggregate the digest to one row per CVE
A CVE on 200 hosts was 200 rows and the counters counted each occurrence
(tester saw 'Neue Schwachstellen 11848' with 'Kritisch 467, Hoch 11381' —
per-(CVE,asset), not distinct). Collapse the digest to one row per CVE:

- {{rows}} is now one line per CVE, sorted by CPR desc, columns CVE | Severity |
  CVSS | CPR | #Systems | Package. The CVE links to ?cve_id= (no asset_id), i.e.
  the vulnerability view listing every affected asset; #Systems says how many.
- Severity counts and {{total}} are per distinct CVE, so 'Neue Schwachstellen'
  and the tiles no longer multiply by host count. {{affected_assets_count}}
  stays distinct assets across the digest.
- default digest template + preview sample + settings hint updated (Host column
  dropped — meaningless once aggregated). Notification-history subject/body show
  distinct-CVE count.

Verified on the tester's shape: 47× CVE-2026-50387 + 2× another -> 2 rows,
counters {critical:1, high:1}, #Systems 47.
2026-07-16 09:58:35 +02:00

999 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Email Service für SMTP-Versand und Template-Rendering
"""
import html
import json
import logging
import os
import re
import smtplib
from datetime import datetime
from urllib.parse import quote
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
DEFAULT_SLA_BREACH_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 0; padding: 20px; background: #fafafa; color: #333; }
.container { max-width: 650px; margin: 0 auto; background: #fff; border-radius: 12px; border: 1px solid #e5e7eb; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); overflow: hidden; }
.header { background: #7f1d1d; color: white; padding: 30px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; }
.body { padding: 40px; }
.status-banner { background: #fef2f2; border: 1px solid #fee2e2; border-radius: 8px; padding: 15px; margin-bottom: 30px; text-align: center; }
.status-text { color: #991b1b; font-weight: 700; font-size: 18px; margin: 0; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px; }
.info-card { background: #f9fafb; padding: 15px; border-radius: 8px; border: 1px solid #f3f4f6; }
.info-label { display: block; font-size: 11px; font-weight: 700; color: #6b7280; text-transform: uppercase; margin-bottom: 5px; }
.info-value { font-size: 16px; font-weight: 600; color: #111827; }
.severity-badge { display: inline-block; padding: 4px 12px; rounded: 100px; font-size: 14px; font-weight: 700; border-radius: 20px; }
.severity-critical { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
.severity-high { background: #ffedd5; color: #9a3412; border: 1px solid #fed7aa; }
.severity-medium { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
.severity-low { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
.btn { display: block; text-align: center; background: #111827; color: #ffffff !important; padding: 16px; border-radius: 8px; text-decoration: none; font-weight: 700; margin-top: 30px; font-size: 16px; letter-spacing: 0.5px; }
.footer { padding: 25px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #6b7280; text-align: center; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SLA Violation Warning</h1>
</div>
<div class="body">
<div class="status-banner">
<p class="status-text">CRITICAL: SLA Breach by {{hours_overdue}} Hours</p>
<p style="margin: 5px 0 0 0; color: #b91c1c; font-size: 14px;">Immediate action required for compliance.</p>
</div>
<div class="info-grid">
<div class="info-card">
<span class="info-label">CVE Identifier</span>
<span class="info-value" style="color: #7f1d1d;">{{cve_id}}</span>
</div>
<div class="info-card">
<span class="info-label">SLA Severity</span>
<span class="severity-badge severity-{{severity}}">{{severity_upper}}</span>
</div>
<div class="info-card" style="grid-column: span 2;">
<span class="info-label">Affected Host / System</span>
<span class="info-value">{{asset_hostname}}</span>
</div>
<div class="info-card">
<span class="info-label">CVSS Score</span>
<span class="info-value text-red-600">{{cvss_score}}</span>
</div>
<div class="info-card">
<span class="info-label">Assigned To</span>
<span class="info-value">{{assigned_user}}</span>
</div>
<div class="info-card" style="grid-column: span 2;">
<span class="info-label">Resource / Package</span>
<span class="info-value">{{package_name}}</span>
</div>
</div>
<p style="color: #4b5563; font-size: 14px; line-height: 1.6; background: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; border-radius: 4px;">
<strong>Summary:</strong> {{title}}
</p>
<p style="color: #4b5563; font-size: 14px; line-height: 1.6; margin-top: 20px;">
This vulnerability was detected at {{detected_at}} and has exceeded its mandatory remediation window.
Please remediate this exposure immediately or provide a valid deferral reason in the management console.
</p>
<a href="{{dashboard_url}}" class="btn">REMEDIATE NOW</a>
</div>
<div class="footer">
<strong>TrueVuln Security Operations Center</strong><br>
Automated compliance monitoring system. Do not reply to this email.<br>
<span style="font-size: 10px; opacity: 0.7;">Sent to: {{recipient_name}} ({{recipient_email}})</span>
</div>
</div>
</body>
</html>"""
DEFAULT_SLA_BREACH_SUBJECT = "SLA Breach: {{cve_id}} on {{asset_hostname}} ({{severity_upper}})"
DEFAULT_NEW_VULN_TEMPLATE = """<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; background: #fdfbf7; color: #333; }
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); overflow: hidden; }
.header { background: #d9480f; color: white; padding: 25px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; font-weight: 600; letter-spacing: 0.5px; }
.body { padding: 30px; }
.alert-badge { display: inline-block; background: #fff5f5; color: #c92a2a; border: 1px solid #ffc9c9; padding: 6px 12px; border-radius: 20px; font-weight: bold; font-size: 14px; margin-bottom: 20px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; }
.detail-item { background: #f8f9fa; padding: 15px; border-radius: 6px; }
.detail-label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #868e96; margin-bottom: 5px; }
.detail-value { font-weight: 600; font-size: 15px; color: #212529; }
.description-box { background: #fff; border: 1px solid #e9ecef; padding: 15px; border-radius: 6px; margin-bottom: 20px; line-height: 1.5; font-size: 14px; color: #495057; }
.action-btn { display: block; width: 100%; text-align: center; background: #339af0; color: white; padding: 15px 0; border-radius: 6px; text-decoration: none; font-weight: bold; font-size: 16px; transition: background 0.2s; }
.action-btn:hover { background: #228be6; }
.footer { padding: 20px; background: #f8f9fa; text-align: center; font-size: 12px; color: #adb5bd; border-top: 1px solid #e9ecef; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>New Vulnerability Detected</h1>
</div>
<div class="body">
<div style="text-align: center;">
<span class="alert-badge">Severity: {{severity_upper}} ({{cvss_score}})</span>
</div>
<p style="font-size: 16px; margin-bottom: 25px; text-align: center;">
A new <strong>{{severity_upper}}</strong> vulnerability has been detected on <strong>{{asset_hostname}}</strong>.
</p>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">CVE ID</span>
<span class="detail-value" style="color: #d9480f;">{{cve_id}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Package</span>
<span class="detail-value">{{package_name}}</span>
</div>
<div class="detail-item">
<span class="detail-label">CPR (priority)</span>
<span class="detail-value">{{cpr_score}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Affected systems</span>
<span class="detail-value">{{affected_assets_count}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Detected At</span>
<span class="detail-value">{{detected_at}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Affected Host</span>
<span class="detail-value">{{asset_hostname}}</span>
</div>
</div>
<div class="detail-label">Description</div>
<div class="description-box">
{{description}}
</div>
<a href="{{cve_on_asset_link}}" class="action-btn">View this CVE on {{asset_hostname}}</a>
<a href="{{asset_link}}" class="action-btn" style="background:#495057;margin-top:10px;">View all findings on this asset</a>
</div>
<div class="footer">
Generated by TrueVuln Dashboard • {{detected_at}}
</div>
</div>
</body>
</html>"""
DEFAULT_NEW_VULN_SUBJECT = "ALERT: New {{severity_upper}} Vulnerability ({{cve_id}}) on {{asset_hostname}}"
# ---------------------------------------------------------------
# Digest variant — one mail per recipient summarising N new CVEs
# instead of one mail per CVE. Selected by setting `notification_mode`.
# ---------------------------------------------------------------
DEFAULT_DIGEST_SUBJECT = "[TRUEVULN] {{total}} new vulnerabilities detected"
DEFAULT_DIGEST_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{font-family:Arial,sans-serif;color:#1f2937;max-width:760px;margin:24px auto;padding:0 16px}
.h{background:#1f2937;color:#fff;padding:14px 18px;border-radius:6px 6px 0 0}
.h h1{margin:0;font-size:18px}
.h .sub{font-size:12px;opacity:.85;margin-top:4px}
.box{background:#fff;border:1px solid #e5e7eb;border-top:none;padding:18px;border-radius:0 0 6px 6px}
.cnt{display:flex;gap:12px;flex-wrap:wrap;margin:0 0 14px 0}
.cnt div{flex:1;min-width:90px;text-align:center;padding:8px;border-radius:4px;font-size:12px;font-weight:bold}
.crit{background:#fee2e2;color:#991b1b}.high{background:#ffedd5;color:#9a3412}
.med{background:#dbeafe;color:#1e40af}.low{background:#dcfce7;color:#166534}
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:13px}
th{text-align:left;background:#f3f4f6;padding:8px 6px;border-bottom:1px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280}
td{padding:7px 6px;border-bottom:1px solid #f3f4f6}
.sev{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;text-transform:uppercase}
.sev-critical{background:#fee2e2;color:#991b1b}.sev-high{background:#ffedd5;color:#9a3412}
.sev-medium{background:#dbeafe;color:#1e40af}.sev-low{background:#dcfce7;color:#166534}
.btn{display:inline-block;margin-top:14px;padding:9px 16px;background:#2563eb;color:#fff;text-decoration:none;border-radius:4px;font-size:13px}
.foot{font-size:11px;color:#6b7280;margin-top:14px;padding-top:10px;border-top:1px solid #e5e7eb}
</style></head><body>
<div class="h">
<h1>{{total}} new vulnerabilities detected</h1>
<div class="sub">Sync at {{detected_at}} — recipient: {{recipient_name}}</div>
</div>
<div class="box">
<div class="cnt">
<div class="crit">CRITICAL<br>{{count_critical}}</div>
<div class="high">HIGH<br>{{count_high}}</div>
<div class="med">MEDIUM<br>{{count_medium}}</div>
<div class="low">LOW<br>{{count_low}}</div>
</div>
<table>
<thead><tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>CPR</th><th>Systems</th><th>Package</th></tr></thead>
<tbody>{{rows}}</tbody>
</table>
<a class="btn" href="{{dashboard_url}}">Open in dashboard</a>
<div class="foot">
You receive this because the affected asset or vulnerability is assigned to you or one of your groups.
Manage assignments and suppression in the TrueVuln UI.
</div>
</div></body></html>"""
def aggregate_by_cve(items: list) -> list:
"""Collapse per-(CVE, asset) items to ONE entry per CVE. The representative
is the highest-CPR occurrence; `systems` is how many distinct assets in this
batch carry the CVE. Sorted by CPR descending (unscored last)."""
groups: dict[str, dict] = {}
for it in items:
cve = str(it.get("cve_id") or "")
g = groups.get(cve)
if g is None:
g = groups[cve] = {"rep": it, "assets": set()}
if it.get("asset_id") is not None:
g["assets"].add(it["asset_id"])
if (it.get("cpr_score") or -1) > (g["rep"].get("cpr_score") or -1):
g["rep"] = it
out = []
for cve, g in groups.items():
rep = dict(g["rep"])
rep["systems"] = len(g["assets"]) or 1
out.append(rep)
out.sort(key=lambda it: (it.get("cpr_score") is None, -(it.get("cpr_score") or 0.0)))
return out
def render_digest_rows(items: list, base_url: str = "") -> str:
"""Render the digest table — ONE row per CVE, not per (CVE, asset). A CVE on
200 hosts is one line: the CVE links to the filtered vulnerability view
(?cve_id=…) that lists every affected asset, and #Systems says how many.
Columns: CVE | Sev | CVSS | CPR | #Systems | Package, CPR-descending. All
dynamic values HTML-escaped against compromised scanner data."""
rows = []
for it in aggregate_by_cve(items):
raw_sev = (it.get("severity") or "none").lower()
sev = raw_sev if raw_sev in {"critical", "high", "medium", "low", "none"} else "none"
cvss = it.get("cvss_score")
cvss_str = html.escape(str(cvss)) if cvss is not None else "-"
cpr = it.get("cpr_score")
cpr_str = html.escape(f"{cpr:.1f}") if isinstance(cpr, (int, float)) else "-"
systems = it.get("systems") or 1
cve = str(it.get("cve_id", ""))
cve_esc = html.escape(cve)
if base_url and cve:
link = f"{base_url}?cve_id={quote(cve)}" # no asset_id → shows ALL affected assets
cve_cell = f"<a href='{html.escape(link)}'>{cve_esc}</a>"
else:
cve_cell = f"<strong>{cve_esc}</strong>"
rows.append(
f"<tr>"
f"<td>{cve_cell}</td>"
f"<td><span class='sev sev-{sev}'>{sev.upper()}</span></td>"
f"<td>{cvss_str}</td>"
f"<td>{cpr_str}</td>"
f"<td>{html.escape(str(systems))}</td>"
f"<td>{html.escape(str(it.get('package_name') or '')[:60])}</td>"
f"</tr>"
)
return "".join(rows)
DEFAULT_SLA_DIGEST_SUBJECT = "[TRUEVULN] {{total}} SLA-breached vulnerabilities require action"
DEFAULT_SLA_DIGEST_TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
body{font-family:Arial,sans-serif;color:#1f2937;max-width:780px;margin:24px auto;padding:0 16px}
.h{background:#991b1b;color:#fff;padding:14px 18px;border-radius:6px 6px 0 0}
.h h1{margin:0;font-size:18px}
.h .sub{font-size:12px;opacity:.85;margin-top:4px}
.box{background:#fff;border:1px solid #e5e7eb;border-top:none;padding:18px;border-radius:0 0 6px 6px}
.warn{background:#fef2f2;border-left:4px solid #dc2626;color:#7f1d1d;padding:10px 12px;margin-bottom:12px;font-size:13px;border-radius:4px}
.cnt{display:flex;gap:12px;flex-wrap:wrap;margin:0 0 14px 0}
.cnt div{flex:1;min-width:90px;text-align:center;padding:8px;border-radius:4px;font-size:12px;font-weight:bold}
.crit{background:#fee2e2;color:#991b1b}.high{background:#ffedd5;color:#9a3412}
.med{background:#dbeafe;color:#1e40af}.low{background:#dcfce7;color:#166534}
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:13px}
th{text-align:left;background:#f3f4f6;padding:8px 6px;border-bottom:1px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280}
td{padding:7px 6px;border-bottom:1px solid #f3f4f6;vertical-align:top}
.sev{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;text-transform:uppercase}
.sev-critical{background:#fee2e2;color:#991b1b}.sev-high{background:#ffedd5;color:#9a3412}
.sev-medium{background:#dbeafe;color:#1e40af}.sev-low{background:#dcfce7;color:#166534}
.ovd{color:#991b1b;font-weight:bold}
.btn{display:inline-block;margin-top:14px;padding:9px 16px;background:#dc2626;color:#fff;text-decoration:none;border-radius:4px;font-size:13px}
.foot{font-size:11px;color:#6b7280;margin-top:14px;padding-top:10px;border-top:1px solid #e5e7eb}
</style></head><body>
<div class="h">
<h1>{{total}} SLA-breached vulnerabilities require action</h1>
<div class="sub">Check at {{checked_at}} — recipient: {{recipient_name}}</div>
</div>
<div class="box">
<div class="warn"><strong>Action required.</strong> These findings exceeded their remediation SLA. Please patch or formally accept the risk.</div>
<div class="cnt">
<div class="crit">CRITICAL<br>{{count_critical}}</div>
<div class="high">HIGH<br>{{count_high}}</div>
<div class="med">MEDIUM<br>{{count_medium}}</div>
<div class="low">LOW<br>{{count_low}}</div>
</div>
<table>
<thead><tr><th>CVE</th><th>Severity</th><th>Host</th><th>Detected</th><th>Overdue</th></tr></thead>
<tbody>{{rows}}</tbody>
</table>
<a class="btn" href="{{dashboard_url}}">Open in dashboard</a>
<div class="foot">
You receive this because these vulnerabilities or their affected assets are assigned to you or one of your groups.
To stop receiving alerts for a specific finding, suppress notifications via the bell icon in the dashboard or
mark the vulnerability as a false positive.
</div>
</div></body></html>"""
def render_sla_digest_rows(items: list) -> str:
"""Render <tr> rows for the SLA digest table. Items: list of dicts with
cve_id, severity, asset_hostname, detected_at, hours_overdue. All
dynamic values HTML-escaped to prevent injection."""
rows = []
for it in items:
raw_sev = (it.get("severity") or "none").lower()
sev = raw_sev if raw_sev in {"critical", "high", "medium", "low", "none"} else "none"
hours = int(it.get("hours_overdue") or 0)
if hours >= 48:
overdue_str = f"{hours // 24}d {hours % 24}h"
else:
overdue_str = f"{hours}h"
rows.append(
f"<tr>"
f"<td><strong>{html.escape(str(it.get('cve_id', '')))}</strong></td>"
f"<td><span class='sev sev-{sev}'>{sev.upper()}</span></td>"
f"<td>{html.escape(str(it.get('asset_hostname', '')))}</td>"
f"<td>{html.escape(str(it.get('detected_at', '')))}</td>"
f"<td class='ovd'>{overdue_str}</td>"
f"</tr>"
)
return "".join(rows)
def send_sla_breach_digest(
db: Session,
to_email: str,
recipient_name: str,
items: list,
checked_at: str,
dashboard_url: str,
) -> tuple[bool, str]:
"""One SLA-breach digest mail aggregating all overdue findings for a recipient.
Replaces per-vuln SLA-breach emails when notification_mode='digest'."""
if not items:
return False, "no items"
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "none": 0}
for it in items:
sev = (it.get("severity") or "none").lower()
if sev in counts:
counts[sev] += 1
variables = {
"total": str(len(items)),
"count_critical": str(counts["critical"]),
"count_high": str(counts["high"]),
"count_medium": str(counts["medium"]),
"count_low": str(counts["low"]),
"checked_at": checked_at,
"recipient_name": recipient_name,
"recipient_email": to_email,
"dashboard_url": dashboard_url,
"rows": render_sla_digest_rows(items),
}
subject_template, body_template = get_email_template(db, "email_template_sla_breach_digest")
subject = render_template(subject_template, variables)
body = render_template(body_template, variables)
return send_email(db, to_email, subject, body)
def send_new_vulnerability_digest(
db: Session,
to_email: str,
recipient_name: str,
items: list,
detected_at: str,
dashboard_url: str,
) -> tuple[bool, str]:
"""
Send one digest email aggregating up to N new vulnerabilities for a recipient.
Used by the Wazuh sync when notification_mode = 'digest' (the default).
"""
if not items:
return False, "no items"
# Counts are per DISTINCT CVE, matching the one-row-per-CVE table — a CVE on
# 200 hosts counts once, not 200×. `total` = distinct CVEs; the top-level
# affected-systems tile stays distinct assets across the whole digest.
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "none": 0}
_assets: set = set()
for it in items:
if it.get("asset_id") is not None:
_assets.add(it["asset_id"])
unique = aggregate_by_cve(items)
for it in unique:
sev = (it.get("severity") or "none").lower()
if sev in counts:
counts[sev] += 1
variables = {
"total": str(len(unique)),
# Distinct assets across this digest (top-level "affected systems" tile).
# Per-CVE spread is the #Systems column inside {{rows}}.
"affected_assets_count": str(len(_assets)),
"count_critical": str(counts["critical"]),
"count_high": str(counts["high"]),
"count_medium": str(counts["medium"]),
"count_low": str(counts["low"]),
"detected_at": detected_at,
"recipient_name": recipient_name,
"recipient_email": to_email,
"dashboard_url": dashboard_url,
"rows": render_digest_rows(items, base_url=dashboard_url),
}
subject_template, body_template = get_email_template(db, "email_template_new_vuln_digest")
subject = render_template(subject_template, variables)
body = render_template(body_template, variables)
return send_email(db, to_email, subject, body)
def get_smtp_config(db: Session) -> Optional[dict]:
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, "smtp_config")
if not raw:
return None
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
def get_email_template(db: Session, template_key: str = "email_template_sla_breach") -> tuple[str, str]:
setting = db.query(Setting).filter(Setting.key == template_key).first()
default_subject = DEFAULT_SLA_BREACH_SUBJECT
default_body = DEFAULT_SLA_BREACH_TEMPLATE
if template_key == "email_template_new_vuln":
default_subject = DEFAULT_NEW_VULN_SUBJECT
default_body = DEFAULT_NEW_VULN_TEMPLATE
elif template_key == "email_template_new_vuln_digest":
default_subject = DEFAULT_DIGEST_SUBJECT
default_body = DEFAULT_DIGEST_TEMPLATE
elif template_key == "email_template_sla_breach_digest":
default_subject = DEFAULT_SLA_DIGEST_SUBJECT
default_body = DEFAULT_SLA_DIGEST_TEMPLATE
if setting and setting.value:
try:
data = json.loads(setting.value)
return data.get("subject", default_subject), data.get("body", default_body)
except (json.JSONDecodeError, TypeError):
pass
return default_subject, default_body
# Keys whose values are already trusted HTML (pre-rendered with escaping).
# Everything else is HTML-escaped at substitution time.
_SAFE_HTML_KEYS = {"rows"}
def render_template(template: str, variables: dict) -> str:
def replace_var(match):
key = match.group(1)
if key not in variables:
return f"{{{{{key}}}}}"
value = str(variables[key])
if key in _SAFE_HTML_KEYS:
return value
return html.escape(value)
return re.sub(r'\{\{(\w+)\}\}', replace_var, template)
def send_email(db: Session, to_email: str, subject: str, html_body: str, config_override: Optional[dict] = None) -> tuple[bool, str]:
if config_override:
config = config_override
else:
config = get_smtp_config(db)
if not config:
return False, "SMTP not configured"
host = config.get("host", "")
port = int(config.get("port", 587))
username = config.get("username", "")
password = config.get("password", "")
from_address = config.get("from_address", username)
use_tls = config.get("use_tls", True)
if not host:
return False, "SMTP host not configured"
try:
msg = MIMEMultipart("alternative")
msg["From"] = from_address
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(html_body, "html"))
if use_tls:
server = smtplib.SMTP(host, port, timeout=10)
server.starttls()
else:
server = smtplib.SMTP(host, port, timeout=10)
if username and password:
server.login(username, password)
server.sendmail(from_address, [to_email], msg.as_string())
server.quit()
logger.info(f"Email sent to {to_email}: {subject}")
return True, "OK"
except smtplib.SMTPAuthenticationError as e:
msg = f"SMTP authentication failed: {e}"
logger.error(msg)
return False, msg
except smtplib.SMTPException as e:
msg = f"SMTP error: {e}"
logger.error(msg)
return False, msg
except Exception as e:
msg = f"Email send failed: {e}"
logger.error(msg)
return False, msg
def send_sla_breach_notification(db: Session, to_email: str, variables: dict) -> tuple[bool, str]:
subject_template, body_template = get_email_template(db, "email_template_sla_breach")
subject = render_template(subject_template, variables)
body = render_template(body_template, variables)
return send_email(db, to_email, subject, body)
def send_new_vulnerability_notification(db: Session, to_email: str, variables: dict) -> tuple[bool, str]:
subject_template, body_template = get_email_template(db, "email_template_new_vuln")
subject = render_template(subject_template, variables)
body = render_template(body_template, variables)
return send_email(db, to_email, subject, body)
# Severity rank: higher = more severe.
_SEVERITY_RANK = {
"none": 0,
"low": 1,
"medium": 2,
"high": 3,
"critical": 4,
}
def get_notification_mode(db: Session) -> str:
"""
`notification_mode` setting: 'digest' (default) or 'single'.
digest = one summary email per recipient per sync run.
single = one email per CVE per recipient (legacy, SMTP-spammy).
"""
from app.models.setting import Setting
try:
s = db.query(Setting).filter(Setting.key == "notification_mode").first()
if s and s.value:
val = s.value.strip().lower()
if val in ("digest", "single"):
return val
except Exception:
pass
return "digest"
def _get_setting_str(db: Session, key: str) -> Optional[str]:
from app.models.setting import Setting
try:
s = db.query(Setting).filter(Setting.key == key).first()
return s.value.strip() if s and s.value else None
except Exception:
return None
def is_lifecycle_finding(cve_id: Optional[str]) -> bool:
"""True for pseudo-findings that aren't real CVEs — endoflife.date rows
(EOL-*), Android patch-level staleness (ANDROID-PATCH-*), Nessus plugin
rows (NESSUS-PLUGIN-*), etc. Deliberately 'anything not CVE-*' so a new
pseudo prefix is covered without touching this."""
return not (cve_id or "").upper().startswith("CVE-")
def get_notification_lifecycle_mode(db: Session) -> str:
"""`notification_lifecycle_mode`: 'exclude' (default) or 'include'.
exclude = lifecycle/EOL pseudo-findings never trigger the CVE mails, so
vulnerability reporting stays unmixed with lifecycle hygiene.
include = legacy behaviour, everything in one mail."""
val = (_get_setting_str(db, "notification_lifecycle_mode") or "").lower()
return val if val in ("exclude", "include") else "exclude"
def get_notification_schedule(db: Session) -> str:
"""`notification_schedule`: 'per_sync' (default) or 'nightly'.
per_sync = notify at the end of each sync run (immediate).
nightly = per-sync sends are suppressed; one roundup job at night sends
ALL of the day's new CVEs in a single aggregated mail per
recipient (fewer mails → friendlier to provider anti-spam)."""
val = (_get_setting_str(db, "notification_schedule") or "").lower()
return val if val in ("per_sync", "nightly") else "per_sync"
def get_notification_nightly_hour(db: Session) -> int:
"""Hour (0-23, server local time) the nightly roundup fires. Default 6."""
try:
h = int(_get_setting_str(db, "notification_nightly_hour") or "6")
return h if 0 <= h <= 23 else 6
except (TypeError, ValueError):
return 6
def get_email_rate_limit(db: Session) -> tuple[float, int]:
"""(delay_seconds_between_mails, max_mails_per_run). 0 = unlimited/no delay.
Throttles the send loop so a big batch doesn't trip provider rate limits."""
try:
delay = float(_get_setting_str(db, "email_rate_delay_seconds") or "0")
except (TypeError, ValueError):
delay = 0.0
try:
cap = int(_get_setting_str(db, "email_max_per_run") or "0")
except (TypeError, ValueError):
cap = 0
return max(0.0, delay), max(0, cap)
def get_default_recipients(db: Session) -> list[tuple]:
"""Fallback recipients for findings with NO assignee anywhere in the
cascade. The `notification_default_recipients` setting (comma/semicolon/
space-separated emails) wins; if it's unset/empty, every active admin
user — so 'the admin gets everything' works out of the box, which is
what operators expect after configuring SMTP + an admin email but never
assigning the thousands of scanner findings to anyone.
Returns (user_id_or_None, email, display_name).
"""
from app.models.setting import Setting
from app.models.user import User, UserRole
out: list[tuple] = []
seen: set[str] = set()
raw = None
try:
s = db.query(Setting).filter(Setting.key == "notification_default_recipients").first()
raw = s.value if s else None
except Exception:
raw = None
if raw and raw.strip():
for em in (e.strip() for e in re.split(r"[,;\s]+", raw) if e.strip()):
if em in seen:
continue
u = db.query(User).filter(User.email == em).first()
out.append(((u.id if u else None), em, (u.username if u else em)))
seen.add(em)
return out
# No configured default → all active admins.
try:
admins = db.query(User).filter(
User.role == UserRole.ADMIN, User.is_active.is_(True)).all()
except Exception:
admins = []
for u in admins:
if u.email and u.email not in seen:
out.append((u.id, u.email, u.username))
seen.add(u.email)
return out
def _resolve_recipients_for_vuln(db: Session, vuln) -> list[tuple[int, str, str]]:
"""
Returns list of (user_id, email, username) for a vulnerability following
the cascade: vuln.assigned_user > vuln.assigned_group > asset.assigned_user
> asset.assigned_group. When the cascade is empty, falls back to the
configured default recipients / active admins (get_default_recipients).
"""
from app.models.group import Group
from app.models.user import User
recipients: list[tuple[int, str, str]] = []
seen_emails: set[str] = set()
def _push(u):
if u and u.email and u.email not in seen_emails:
recipients.append((u.id, u.email, u.username))
seen_emails.add(u.email)
if vuln.assigned_user_id:
_push(db.query(User).filter(User.id == vuln.assigned_user_id).first())
elif vuln.assigned_group_id:
g = db.query(Group).filter(Group.id == vuln.assigned_group_id).first()
if g:
for u in g.users:
_push(u)
elif vuln.asset and vuln.asset.assigned_user_id:
_push(db.query(User).filter(User.id == vuln.asset.assigned_user_id).first())
elif vuln.asset and getattr(vuln.asset, "groups", None):
for g in vuln.asset.groups:
for u in g.users:
_push(u)
if not recipients:
for uid, email, uname in get_default_recipients(db):
if email and email not in seen_emails:
recipients.append((uid, email, uname))
seen_emails.add(email)
return recipients
def _affected_counts(db: Session, cve_ids: set) -> dict:
"""{cve_id: number of distinct assets with this CVE in an active state}.
One grouped query for the whole batch."""
if not cve_ids:
return {}
from sqlalchemy import func
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
active = (VulnerabilityStatus.open, VulnerabilityStatus.pending_verification,
VulnerabilityStatus.patch_failed)
rows = (
db.query(Vulnerability.cve_id, func.count(func.distinct(Vulnerability.asset_id)))
.filter(Vulnerability.cve_id.in_(list(cve_ids)), Vulnerability.status.in_(active))
.group_by(Vulnerability.cve_id)
.all()
)
return {cve: cnt for cve, cnt in rows}
def dispatch_new_vuln_notifications(db: Session, new_vulns: list, respect_schedule: bool = True) -> dict:
"""
Shared entry point for every sync (Wazuh/Nessus/app-scan/Defender). Groups
new vulns by recipient, applies the severity threshold, and dispatches either:
- one digest email per recipient (mode='digest', default), or
- one email per CVE per recipient (mode='single', legacy).
respect_schedule: when True (sync callers) and notification_schedule is
'nightly', sending is skipped here — the nightly roundup job sends instead.
The nightly job calls with respect_schedule=False to actually send.
Returns a stats dict for logging.
"""
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
stats = {"emails_sent": 0, "emails_failed": 0, "recipients": 0, "vulns_considered": len(new_vulns)}
if not new_vulns:
return stats
# Keep CVE reporting unmixed with lifecycle/EOL hygiene findings unless the
# operator opts back in. Applies to both delivery modes and both schedules.
if get_notification_lifecycle_mode(db) == "exclude":
kept = [v for v in new_vulns if not is_lifecycle_finding(v.cve_id)]
skipped = len(new_vulns) - len(kept)
if skipped:
stats["lifecycle_skipped"] = skipped
new_vulns = kept
if not new_vulns:
return stats
if respect_schedule and get_notification_schedule(db) == "nightly":
stats["deferred_to_nightly"] = len(new_vulns)
return stats
smtp = get_smtp_config(db)
if not smtp:
logger.info("SMTP not configured — skipping new-vuln notifications")
return stats
rate_delay, rate_cap = get_email_rate_limit(db)
mode = get_notification_mode(db)
dashboard_url = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/") + "/vulnerabilities"
detected_at_str = datetime.now().strftime("%Y-%m-%d %H:%M UTC")
# How many distinct assets each CVE affects (whole inventory, not just this
# batch) — lets the template convey blast radius / spread.
affected = _affected_counts(db, {v.cve_id for v in new_vulns if v.cve_id})
# Bucket vulns per recipient email (only those above the severity threshold).
buckets: dict[str, dict] = {} # email -> {user_id, username, items: [vuln_summary]}
notif_log_anchors: dict[str, list] = {} # email -> list of (vuln_id, asset_id) for logging
for vuln in new_vulns:
if not should_notify_for_severity(db, vuln.severity):
continue
for r_uid, r_email, r_username in _resolve_recipients_for_vuln(db, vuln):
b = buckets.setdefault(r_email, {"user_id": r_uid, "username": r_username, "items": []})
b["items"].append({
"cve_id": vuln.cve_id,
"severity": vuln.severity.value if vuln.severity else "none",
"cvss_score": vuln.cvss_score,
"cpr_score": vuln.cpr_score,
"affected_count": affected.get(vuln.cve_id, 1),
"asset_hostname": vuln.asset.hostname if vuln.asset else "Unknown",
"package_name": vuln.package_name,
"vuln_id": vuln.id,
"asset_id": vuln.asset_id,
})
notif_log_anchors.setdefault(r_email, []).append((vuln.id, vuln.asset_id))
stats["recipients"] = len(buckets)
import time as _time
_sent_this_run = 0
for email, bucket in buckets.items():
# Rate-limit: cap per run + pace between mails (provider anti-spam).
if rate_cap and _sent_this_run >= rate_cap:
stats["rate_capped"] = stats.get("rate_capped", 0) + 1
continue
if rate_delay and _sent_this_run > 0:
_time.sleep(rate_delay)
_sent_this_run += 1
items = bucket["items"]
username = bucket["username"]
user_id = bucket["user_id"]
if mode == "digest":
success, err = send_new_vulnerability_digest(
db,
to_email=email,
recipient_name=username,
items=items,
detected_at=detected_at_str,
dashboard_url=dashboard_url,
)
anchor_vuln_id = items[0]["vuln_id"]
anchor_asset_id = items[0]["asset_id"]
_distinct = len({it.get("cve_id") for it in items})
db.add(NotificationLog(
vulnerability_id=anchor_vuln_id, # anchor — full list is in body
asset_id=anchor_asset_id,
user_id=user_id,
notification_type=NotificationType.NEW_VULNERABILITY,
sent_at=datetime.now(),
subject=f"[TRUEVULN] {_distinct} new vulnerabilities detected",
recipient_email=email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"Digest of {_distinct} distinct CVEs across {len(items)} findings",
error_message=None if success else err,
))
if success:
stats["emails_sent"] += 1
logger.info(f"Digest email sent to {email}: {_distinct} distinct CVEs ({len(items)} findings)")
else:
stats["emails_failed"] += 1
logger.warning(f"Digest email FAILED to {email}: {err}")
else:
# Legacy single mode — one mail per CVE per recipient
for item in items:
_cve = item["cve_id"]
_aid = item.get("asset_id")
_cpr = item.get("cpr_score")
variables = {
"cve_id": _cve,
"severity": item["severity"],
"severity_upper": item["severity"].upper(),
"cvss_score": str(item.get("cvss_score") or "N/A"),
"cpr_score": (f"{_cpr:.1f}" if isinstance(_cpr, (int, float)) else "N/A"),
"affected_assets_count": str(item.get("affected_count") or 1),
"asset_hostname": item["asset_hostname"],
"package_name": item.get("package_name") or "",
"title": _cve,
"detected_at": detected_at_str,
"dashboard_url": dashboard_url,
# Ready-made deep links (no manual ?cve_id= assembly needed):
"cve_link": f"{dashboard_url}?cve_id={quote(_cve)}",
"asset_link": (f"{dashboard_url}?asset_id={_aid}" if _aid else dashboard_url),
"cve_on_asset_link": (f"{dashboard_url}?asset_id={_aid}&cve_id={quote(_cve)}" if _aid else f"{dashboard_url}?cve_id={quote(_cve)}"),
"recipient_name": username,
"recipient_email": email,
}
success, err = send_new_vulnerability_notification(db, email, variables)
db.add(NotificationLog(
vulnerability_id=item["vuln_id"],
asset_id=item["asset_id"],
user_id=user_id,
notification_type=NotificationType.NEW_VULNERABILITY,
sent_at=datetime.now(),
subject=f"[TRUEVULN] New {item['severity'].upper()} Vulnerability: {item['cve_id']}",
recipient_email=email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"New {item['severity']} vulnerability {item['cve_id']} on {item['asset_hostname']}",
error_message=None if success else err,
))
if success:
stats["emails_sent"] += 1
else:
stats["emails_failed"] += 1
db.commit()
return stats
def _set_setting_str(db: Session, key: str, value: str) -> None:
from app.models.setting import Setting
s = db.query(Setting).filter(Setting.key == key).first()
if s:
s.value = value
else:
db.add(Setting(key=key, value=value))
db.commit()
def send_nightly_new_vuln_digest(db: Session) -> dict:
"""Nightly roundup: one aggregated mail per recipient with ALL new CVEs
since the last run. Only active when notification_schedule == 'nightly'
(per-sync sends are suppressed in that mode). Window is tracked in the
`notification_nightly_last_run` setting so nothing is sent twice and
nothing is missed between runs."""
from datetime import timedelta
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
if get_notification_schedule(db) != "nightly":
return {"skipped": "notification_schedule != nightly"}
last_raw = _get_setting_str(db, "notification_nightly_last_run")
since = None
if last_raw:
try:
since = datetime.fromisoformat(last_raw)
except ValueError:
since = None
if since is None:
since = datetime.now() - timedelta(hours=24)
active = (VulnerabilityStatus.open, VulnerabilityStatus.pending_verification,
VulnerabilityStatus.patch_failed)
vulns = (
db.query(Vulnerability)
.filter(Vulnerability.status.in_(active),
Vulnerability.detected_at >= since)
.all()
)
stats = dispatch_new_vuln_notifications(db, vulns, respect_schedule=False)
_set_setting_str(db, "notification_nightly_last_run", datetime.now().isoformat())
logger.info("Nightly new-vuln digest: %s new since %s%s", len(vulns), since, stats)
return stats
def should_notify_for_severity(db: Session, severity) -> bool:
"""
Return True if the configured notification threshold lets this severity
trigger a 'new vulnerability' email.
Threshold is stored in setting `notification_min_severity`
(one of: critical, high, medium, low). Default: critical.
A severity at or above the threshold triggers a notification.
"""
from app.models.setting import Setting
threshold = "critical"
try:
setting = db.query(Setting).filter(Setting.key == "notification_min_severity").first()
if setting and setting.value:
candidate = setting.value.strip().lower()
if candidate in _SEVERITY_RANK:
threshold = candidate
except Exception as e:
logger.warning(f"Failed to read notification_min_severity setting, defaulting to 'critical': {e}")
severity_value = getattr(severity, "value", severity)
if not isinstance(severity_value, str):
return False
return _SEVERITY_RANK.get(severity_value.lower(), -1) >= _SEVERITY_RANK[threshold]