| @@ -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() |
| @@ -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> |
| @@ -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 %} |
| @@ -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 %} |
| @@ -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 }} · {{ manifest.indicator_count }} indicators · {{ 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&CK coverage</h2> | |
| + | <pre class="mono" style="white-space:pre-wrap;font-size:12px;line-height:1.5;margin:0;">{{ coverage }}</pre> | |
| + | </div> | |
| + | {% endblock %} |