Zion Boggan zionboggan.com ↗

signed-token approval emails (itsdangerous, ttl)

7b747a7   Zion Boggan committed on May 20, 2026 (1 month ago)
src/cti/approval.py +71 -0
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import smtplib
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from pathlib import Path
+
+from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates"
+
+_env = Environment(
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
+ autoescape=select_autoescape(["html"]),
+)
+
+
+def serializer(secret: str) -> URLSafeTimedSerializer:
+ return URLSafeTimedSerializer(secret, salt="cti-rule-approval")
+
+
+def make_token(secret: str, bundle_id: str) -> str:
+ return serializer(secret).dumps({"bundle_id": bundle_id})
+
+
+def verify_token(secret: str, token: str, max_age: int) -> str | None:
+ try:
+ data = serializer(secret).loads(token, max_age=max_age)
+ except (BadSignature, SignatureExpired):
+ return None
+ return data.get("bundle_id")
+
+
+def render_email(context: dict) -> str:
+ template = _env.get_template("approval_email.html")
+ return template.render(**context)
+
+
+def send_email(config: dict, subject: str, html: str, output_dir: Path) -> str:
+ email_cfg = config["email"]
+ backend = email_cfg.get("backend", "file")
+ to_addr = config["approval"]["analyst_email"]
+ from_addr = email_cfg.get("from_addr", "cti-pipeline@lab.local")
+
+ if backend == "console":
+ print(html)
+ return "console"
+
+ if backend == "file":
+ out = output_dir / "emails"
+ out.mkdir(parents=True, exist_ok=True)
+ path = out / f"{subject.replace(' ', '_').replace('/', '-')}.html"
+ path.write_text(html, encoding="utf-8")
+ return str(path)
+
+ message = MIMEMultipart("alternative")
+ message["Subject"] = subject
+ message["From"] = from_addr
+ message["To"] = to_addr
+ message.attach(MIMEText(html, "html"))
+
+ with smtplib.SMTP(email_cfg["smtp_host"], int(email_cfg["smtp_port"])) as server:
+ if email_cfg.get("use_tls"):
+ server.starttls()
+ user = email_cfg.get("smtp_user")
+ password = email_cfg.get("smtp_password")
+ if user and password:
+ server.login(user, password)
+ server.sendmail(from_addr, [to_addr], message.as_string())
+ return to_addr
templates/approval_email.html +96 -0
@@ -0,0 +1,96 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="margin:0;padding:0;background:#f1f3f5;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;">
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f3f5;padding:24px 0;">
+ <tr><td align="center">
+ <table role="presentation" width="640" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid #d9dee3;border-radius:8px;overflow:hidden;">
+ <tr>
+ <td style="background:#16242f;padding:20px 28px;">
+ <div style="color:#9fb3c8;font-size:12px;letter-spacing:1px;text-transform:uppercase;">CTI Detection Automation</div>
+ <div style="color:#ffffff;font-size:20px;font-weight:600;margin-top:4px;">Rule bundle pending approval</div>
+ </td>
+ </tr>
+ <tr>
+ <td style="padding:24px 28px 8px 28px;">
+ <p style="margin:0 0 4px 0;font-size:14px;">A new detection bundle was generated from the live CTI feeds and is waiting for analyst review before it goes live.</p>
+ <table role="presentation" width="100%" style="margin-top:16px;font-size:13px;">
+ <tr>
+ <td style="color:#627d98;padding:2px 0;">Bundle</td>
+ <td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ bundle_id }}</td>
+ </tr>
+ <tr>
+ <td style="color:#627d98;padding:2px 0;">Generated</td>
+ <td>{{ generated_at }}</td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td style="padding:8px 28px;">
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:12px 0;">
+ <tr>
+ <td id="stat-new" width="33%" style="background:#e3f9e5;border:1px solid #c1eac5;border-radius:6px;padding:12px;text-align:center;">
+ <div style="font-size:26px;font-weight:700;color:#0b6b2e;">{{ diff.added }}</div>
+ <div style="font-size:11px;color:#3c6454;text-transform:uppercase;letter-spacing:0.5px;">new indicators</div>
+ </td>
+ <td width="8"></td>
+ <td width="33%" style="background:#fff4e6;border:1px solid #ffd8a8;border-radius:6px;padding:12px;text-align:center;">
+ <div style="font-size:26px;font-weight:700;color:#a14d07;">{{ diff.removed }}</div>
+ <div style="font-size:11px;color:#8a5a1b;text-transform:uppercase;letter-spacing:0.5px;">aged out</div>
+ </td>
+ <td width="8"></td>
+ <td width="33%" style="background:#edf2f7;border:1px solid #d9dee3;border-radius:6px;padding:12px;text-align:center;">
+ <div style="font-size:26px;font-weight:700;color:#334e68;">{{ diff.total }}</div>
+ <div style="font-size:11px;color:#52606d;text-transform:uppercase;letter-spacing:0.5px;">total in bundle</div>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td style="padding:8px 28px;">
+ <div style="font-size:13px;font-weight:600;color:#334e68;margin-bottom:6px;">Indicators by type</div>
+ <table role="presentation" width="100%" style="border-collapse:collapse;font-size:13px;">
+ {% for kind, count in counts.items() %}
+ <tr>
+ <td style="padding:6px 0;border-bottom:1px solid #eef1f4;font-family:ui-monospace,Menlo,monospace;">{{ kind }}</td>
+ <td style="padding:6px 0;border-bottom:1px solid #eef1f4;text-align:right;">{{ count }}</td>
+ </tr>
+ {% endfor %}
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td style="padding:16px 28px;">
+ <div style="font-size:13px;font-weight:600;color:#334e68;margin-bottom:6px;">ATT&amp;CK techniques extracted</div>
+ <table id="ttp-table" role="presentation" width="100%" style="border-collapse:collapse;font-size:12px;">
+ <tr style="color:#627d98;text-align:left;">
+ <th style="padding:4px 0;font-weight:600;">Technique</th>
+ <th style="padding:4px 0;font-weight:600;">Tactic</th>
+ <th style="padding:4px 0;font-weight:600;text-align:right;">Hits</th>
+ </tr>
+ {% for t in techniques %}
+ <tr>
+ <td style="padding:4px 0;border-top:1px solid #eef1f4;font-family:ui-monospace,Menlo,monospace;">{{ t.technique_id }}</td>
+ <td style="padding:4px 0;border-top:1px solid #eef1f4;">{{ t.tactic }}</td>
+ <td style="padding:4px 0;border-top:1px solid #eef1f4;text-align:right;">{{ t.indicator_count }}</td>
+ </tr>
+ {% endfor %}
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td align="center" style="padding:8px 28px 28px 28px;">
+ <a id="cta" href="{{ review_url }}" style="display:inline-block;background:#1c7ed6;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:13px 32px;border-radius:6px;">Review and approve</a>
+ <div style="font-size:11px;color:#9aa5b1;margin-top:12px;">Rules stay in the candidate directory until approved. Nothing is deployed to Wazuh automatically.</div>
+ </td>
+ </tr>
+ </table>
+ </td></tr>
+ </table>
+</body>
+</html>