Files
vulncheck/app/services/email_service.py
T
vulncheck 03eef00f31 security: harden auth, secrets, headers and email rendering
Closes 10 findings from the automated security scan (1 critical, 4 high,
5 medium). Operator action required before redeploy — see deploy notes
in chat or README.DEV.md.

Critical:
- TOTP/LDAP Fernet key (AUTH_PROVIDER_CRYPTO_KEY) is now env-only.
  Removed the DB fallback that co-located the key with the ciphertext
  it protects.

High:
- Rate limiter no longer trusts X-Forwarded-For from arbitrary peers.
  TRUSTED_PROXY_CIDRS gates which direct peers may rewrite the client
  IP, and ProxyHeadersMiddleware trusted_hosts is narrowed from "*"
  to FORWARDED_ALLOW_IPS.
- TOTP codes are single-use within their 90s validation window.
  In-memory replay cache keyed on (user_id, code).
- JWTs carry a jti claim; logout revokes both access and refresh JTIs,
  refresh rotates (revokes the presented token), and get_current_user
  rejects any revoked JTI. In-memory store with TTL = token exp.
- Sensitive setting values (wazuh_config, smtp_config, nessus_config)
  are encrypted at rest with an enc:v1: prefix. All read sites go
  through read_setting_value(); legacy plaintext rows still readable
  until next write. GET responses redact secret subfields so admins
  cannot accidentally exfiltrate stored credentials.

Medium:
- Email template rendering HTML-escapes all dynamic values. The "rows"
  variable is whitelisted as pre-escaped HTML. Severity CSS class is
  whitelisted to prevent attribute breakout via crafted package data.
- Request logging redacts sensitive query parameters (token, password,
  code, mfa_token, ...). Validation-error handler no longer logs or
  returns the offending request body.
- /health returns only {"status":"healthy"} — environment and version
  no longer leak to unauthenticated callers.
- SETUP_ADMIN_TOKEN comparison uses hmac.compare_digest.
- Settings PUT denylists auth_provider_crypto_key (env-only) and
  refuses to store the "***set***" redaction placeholder back into
  protected configs.
2026-05-16 09:25:22 +02:00

724 lines
32 KiB
Python

"""
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 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>VulnCheck 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">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="{{dashboard_url}}" class="action-btn">View Details & Remediate</a>
</div>
<div class="footer">
Generated by VulnCheck 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 = "[VULNCHECK] {{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>Host</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 VulnCheck UI.
</div>
</div></body></html>"""
def render_digest_rows(items: list) -> str:
"""Render the <tr> rows for the digest table. Items: list of dicts with
cve_id, severity, cvss_score, asset_hostname, package_name. All dynamic
values HTML-escaped to prevent injection from compromised scanner data."""
rows = []
for it in items:
raw_sev = (it.get("severity") or "none").lower()
# whitelist severity for CSS class — anything else falls back to 'none'
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 "-"
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>{cvss_str}</td>"
f"<td>{html.escape(str(it.get('asset_hostname', '')))}</td>"
f"<td>{html.escape(str(it.get('package_name') or '')[:60])}</td>"
f"</tr>"
)
return "".join(rows)
DEFAULT_SLA_DIGEST_SUBJECT = "[VULNCHECK] {{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 = {"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"]),
"detected_at": detected_at,
"recipient_name": recipient_name,
"recipient_email": to_email,
"dashboard_url": dashboard_url,
"rows": render_digest_rows(items),
}
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 _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. Empty list when nothing matches.
"""
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)
return recipients
def dispatch_new_vuln_notifications(db: Session, new_vulns: list) -> dict:
"""
Entry point for the Wazuh sync. 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).
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
smtp = get_smtp_config(db)
if not smtp:
logger.info("SMTP not configured — skipping new-vuln notifications")
return stats
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")
# 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,
"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)
for email, bucket in buckets.items():
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"]
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"[VULNCHECK] {len(items)} new vulnerabilities detected",
recipient_email=email,
status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
message_body=f"Digest of {len(items)} new vulnerabilities",
error_message=None if success else err,
))
if success:
stats["emails_sent"] += 1
logger.info(f"Digest email sent to {email}: {len(items)} CVEs")
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:
variables = {
"cve_id": item["cve_id"],
"severity": item["severity"],
"severity_upper": item["severity"].upper(),
"cvss_score": str(item.get("cvss_score") or "N/A"),
"asset_hostname": item["asset_hostname"],
"package_name": item.get("package_name") or "",
"title": item["cve_id"],
"detected_at": detected_at_str,
"dashboard_url": dashboard_url,
"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"[VULNCHECK] 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 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]