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.
724 lines
32 KiB
Python
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]
|