Zion Boggan zionboggan.com ↗

flask approval console: dashboard, review, approve/reject

303769d   Zion Boggan committed on May 24, 2026 (4 weeks ago)
src/cti/web.py +104 -0
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from flask import Flask, abort, render_template, request
+
+from cti import approval, pipeline
+from cti.config import load_config
+
+
+def create_app(config: dict | None = None) -> Flask:
+ config = config or load_config(os.environ.get("CTI_CONFIG"))
+ output_dir = Path(config["output_dir"])
+ secret = config["approval"].get("secret", "insecure-dev-secret")
+ ttl = int(config["approval"]["token_ttl"])
+
+ app = Flask(__name__, template_folder=str(_templates_dir()))
+
+ def load_manifest(bundle_id: str) -> dict | None:
+ manifest = output_dir / pipeline.CANDIDATES / bundle_id / "manifest.json"
+ if not manifest.exists():
+ return None
+ return json.loads(manifest.read_text(encoding="utf-8"))
+
+ def load_coverage(bundle_id: str) -> str:
+ path = output_dir / pipeline.CANDIDATES / bundle_id / "ttp_coverage.md"
+ return path.read_text(encoding="utf-8") if path.exists() else ""
+
+ def bundle_status(bundle_id: str) -> str:
+ candidate = output_dir / pipeline.CANDIDATES / bundle_id
+ if (candidate / "REJECTED").exists():
+ return "rejected"
+ state = output_dir / pipeline.ACTIVE / pipeline.STATE_FILE
+ if state.exists():
+ data = json.loads(state.read_text(encoding="utf-8"))
+ if data.get("bundle_id") == bundle_id:
+ return "approved"
+ return "pending"
+
+ @app.get("/")
+ def index():
+ candidates_dir = output_dir / pipeline.CANDIDATES
+ bundles = []
+ if candidates_dir.exists():
+ for path in sorted(candidates_dir.iterdir(), reverse=True):
+ manifest = load_manifest(path.name)
+ if manifest:
+ manifest["status"] = bundle_status(path.name)
+ bundles.append(manifest)
+ return render_template("dashboard.html", bundles=bundles)
+
+ @app.get("/review/<token>")
+ def review(token):
+ bundle_id = approval.verify_token(secret, token, ttl)
+ if not bundle_id:
+ abort(403)
+ manifest = load_manifest(bundle_id)
+ if not manifest:
+ abort(404)
+ return render_template(
+ "review.html",
+ manifest=manifest,
+ coverage=load_coverage(bundle_id),
+ status=bundle_status(bundle_id),
+ token=token,
+ )
+
+ @app.post("/approve/<token>")
+ def approve(token):
+ bundle_id = approval.verify_token(secret, token, ttl)
+ if not bundle_id:
+ abort(403)
+ active_dir = config.get("wazuh_etc_dir")
+ result = pipeline.promote(
+ bundle_id, output_dir, Path(active_dir) if active_dir else None
+ )
+ return render_template("result.html", action="approved", result=result)
+
+ @app.post("/reject/<token>")
+ def reject(token):
+ bundle_id = approval.verify_token(secret, token, ttl)
+ if not bundle_id:
+ abort(403)
+ marker = output_dir / pipeline.CANDIDATES / bundle_id / "REJECTED"
+ reason = request.form.get("reason", "").strip()
+ marker.write_text(reason or "rejected by analyst", encoding="utf-8")
+ return render_template(
+ "result.html", action="rejected", result={"bundle_id": bundle_id}
+ )
+
+ @app.get("/healthz")
+ def healthz():
+ return {"status": "ok"}
+
+ return app
+
+
+def _templates_dir() -> Path:
+ return Path(__file__).resolve().parents[2] / "templates"
+
+
+app = create_app()
templates/base.html +51 -0
@@ -0,0 +1,51 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>{% block title %}CTI Detection Automation{% endblock %}</title>
+<style>
+ :root { --bg:#f1f3f5; --panel:#ffffff; --border:#d9dee3; --ink:#1f2933; --muted:#627d98; --accent:#1c7ed6; --dark:#16242f; }
+ * { box-sizing: border-box; }
+ body { margin:0; background:var(--bg); color:var(--ink); font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; font-size:14px; }
+ header.top { background:var(--dark); color:#fff; padding:14px 28px; display:flex; align-items:baseline; gap:14px; }
+ header.top .label { color:#9fb3c8; font-size:11px; letter-spacing:1px; text-transform:uppercase; }
+ header.top .title { font-size:17px; font-weight:600; }
+ .wrap { max-width:960px; margin:24px auto; padding:0 20px; }
+ .panel { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:22px 24px; margin-bottom:18px; }
+ .mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .muted { color:var(--muted); }
+ h2 { font-size:16px; margin:0 0 14px 0; }
+ h3 { font-size:13px; text-transform:uppercase; letter-spacing:0.5px; color:var(--muted); margin:18px 0 8px 0; }
+ table { width:100%; border-collapse:collapse; font-size:13px; }
+ th { text-align:left; color:var(--muted); font-weight:600; padding:6px 8px; border-bottom:1px solid var(--border); }
+ td { padding:6px 8px; border-bottom:1px solid #eef1f4; }
+ .cards { display:flex; gap:12px; margin:6px 0 4px 0; }
+ .card { flex:1; border-radius:6px; padding:14px; text-align:center; border:1px solid var(--border); }
+ .card .n { font-size:28px; font-weight:700; }
+ .card .k { font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
+ .card.add { background:#e3f9e5; border-color:#c1eac5; } .card.add .n { color:#0b6b2e; }
+ .card.rem { background:#fff4e6; border-color:#ffd8a8; } .card.rem .n { color:#a14d07; }
+ .card.tot { background:#edf2f7; } .card.tot .n { color:#334e68; }
+ .actions { display:flex; gap:12px; margin-top:18px; align-items:center; }
+ button { font-size:14px; font-weight:600; padding:11px 26px; border-radius:6px; border:none; cursor:pointer; }
+ .approve { background:#2f9e44; color:#fff; }
+ .reject { background:#fff; color:#c92a2a; border:1px solid #ffc9c9; }
+ input[type=text] { flex:1; padding:10px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
+ .badge { display:inline-block; font-size:11px; padding:2px 9px; border-radius:11px; font-weight:600; }
+ .badge.pending { background:#fff3bf; color:#846a00; }
+ .badge.approved { background:#d3f9d8; color:#2b8a3e; }
+ .badge.rejected { background:#ffe3e3; color:#c92a2a; }
+ a.row { color:var(--accent); text-decoration:none; }
+</style>
+</head>
+<body>
+<header class="top">
+ <span class="label">CTI Detection Automation</span>
+ <span class="title">{% block heading %}Analyst console{% endblock %}</span>
+</header>
+<div class="wrap">
+{% block content %}{% endblock %}
+</div>
+</body>
+</html>
templates/dashboard.html +24 -0
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% block heading %}Pending bundles{% endblock %}
+{% block content %}
+<div class="panel">
+ <h2>Candidate rule bundles</h2>
+ {% if bundles %}
+ <table>
+ <tr><th>Bundle</th><th>Generated</th><th>Indicators</th><th>New</th><th>Techniques</th><th>Status</th></tr>
+ {% for b in bundles %}
+ <tr>
+ <td class="mono">{{ b.bundle_id }}</td>
+ <td class="muted">{{ b.generated_at }}</td>
+ <td>{{ b.indicator_count }}</td>
+ <td>{{ b.diff.added }}</td>
+ <td>{{ b.technique_count }}</td>
+ <td><span class="badge {{ b.status }}">{{ b.status }}</span></td>
+ </tr>
+ {% endfor %}
+ </table>
+ {% else %}
+ <p class="muted">No bundles generated yet. Run the pipeline to produce one.</p>
+ {% endif %}
+</div>
+{% endblock %}
templates/result.html +12 -0
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+{% block heading %}{{ action|capitalize }}{% endblock %}
+{% block content %}
+<div class="panel">
+ <h2>Bundle {{ action }}</h2>
+ <p>Bundle <span class="mono">{{ result.bundle_id }}</span> was {{ action }}.</p>
+ {% if action == 'approved' %}
+ <p class="muted">{{ result.indicators }} indicators promoted to the active set{% if result.deployed %} and written to the Wazuh rules directory{% endif %}.</p>
+ {% endif %}
+ <p><a class="row" href="{{ url_for('index') }}">Back to bundles</a></p>
+</div>
+{% endblock %}
templates/review.html +49 -0
@@ -0,0 +1,49 @@
+{% extends "base.html" %}
+{% block heading %}Rule bundle review{% endblock %}
+{% block content %}
+<div class="panel">
+ <h2>Bundle <span class="mono">{{ manifest.bundle_id }}</span>
+ <span class="badge {{ status }}">{{ status }}</span>
+ </h2>
+ <div class="muted">Generated {{ manifest.generated_at }} &middot; {{ manifest.indicator_count }} indicators &middot; {{ manifest.technique_count }} techniques</div>
+
+ <div class="cards">
+ <div class="card add"><div class="n">{{ manifest.diff.added }}</div><div class="k">new</div></div>
+ <div class="card rem"><div class="n">{{ manifest.diff.removed }}</div><div class="k">aged out</div></div>
+ <div class="card tot"><div class="n">{{ manifest.diff.total }}</div><div class="k">total</div></div>
+ </div>
+
+ <h3>Indicators by type</h3>
+ <table>
+ <tr><th>Type</th><th>Count</th></tr>
+ {% for kind, count in manifest.counts_by_type.items() %}
+ <tr><td class="mono">{{ kind }}</td><td>{{ count }}</td></tr>
+ {% endfor %}
+ </table>
+
+ <h3>Generated CDB lists</h3>
+ <table>
+ <tr><th>List</th><th>Entries</th></tr>
+ {% for name, size in manifest.cdb_lists.items() %}
+ <tr><td class="mono">etc/lists/{{ name }}</td><td>{{ size }}</td></tr>
+ {% endfor %}
+ </table>
+
+ {% if status == 'pending' %}
+ <form class="actions" method="post" action="{{ url_for('approve', token=token) }}">
+ <button class="approve" type="submit">Approve and deploy</button>
+ </form>
+ <form class="actions" method="post" action="{{ url_for('reject', token=token) }}">
+ <input type="text" name="reason" placeholder="Reason (optional)">
+ <button class="reject" type="submit">Reject</button>
+ </form>
+ {% else %}
+ <p class="muted" style="margin-top:18px;">This bundle has already been {{ status }}. No further action available.</p>
+ {% endif %}
+</div>
+
+<div class="panel">
+ <h2>ATT&amp;CK coverage</h2>
+ <pre class="mono" style="white-space:pre-wrap;font-size:12px;line-height:1.5;margin:0;">{{ coverage }}</pre>
+</div>
+{% endblock %}