|
1 | 1 | """SOC Analyst Dashboard — Flask + psycopg2 (no ORM).""" |
2 | 2 | import hmac |
3 | 3 | import os |
| 4 | +import queue |
| 5 | +import threading |
4 | 6 | from datetime import date, datetime, timezone |
| 7 | +from functools import wraps |
5 | 8 |
|
6 | 9 | import bcrypt |
7 | 10 | import psycopg2 |
8 | 11 | import psycopg2.extras |
9 | 12 | from dotenv import load_dotenv |
10 | 13 | from flask import ( |
11 | 14 | Flask, |
| 15 | + Response, |
12 | 16 | abort, |
13 | 17 | flash, |
14 | 18 | jsonify, |
|
20 | 24 | from flask_login import ( |
21 | 25 | LoginManager, |
22 | 26 | UserMixin, |
| 27 | + current_user, |
23 | 28 | login_required, |
24 | 29 | login_user, |
25 | 30 | logout_user, |
|
72 | 77 | else "[*] Field encryption DISABLED — set DB_ENCRYPTION_KEY to encrypt PII at rest" |
73 | 78 | ) |
74 | 79 |
|
| 80 | +# --------------------------------------------------------------------------- # |
| 81 | +# Role-based access control |
| 82 | +# --------------------------------------------------------------------------- # |
| 83 | +def require_role(*roles): |
| 84 | + """Decorator: require current_user.role to be in roles, or abort 403.""" |
| 85 | + def decorator(f): |
| 86 | + @wraps(f) |
| 87 | + def wrapped(*args, **kwargs): |
| 88 | + if not current_user.is_authenticated: |
| 89 | + abort(401) |
| 90 | + if current_user.role not in roles: |
| 91 | + abort(403, description="insufficient role") |
| 92 | + return f(*args, **kwargs) |
| 93 | + return wrapped |
| 94 | + return decorator |
| 95 | + |
| 96 | + |
| 97 | +# --------------------------------------------------------------------------- # |
| 98 | +# In-process pub/sub for Server-Sent Events |
| 99 | +# --------------------------------------------------------------------------- # |
| 100 | +_sse_subscribers: list = [] |
| 101 | +_sse_lock = threading.Lock() |
| 102 | + |
| 103 | + |
| 104 | +def _sse_publish(event: dict) -> None: |
| 105 | + """Broadcast an event dict to all SSE subscribers.""" |
| 106 | + with _sse_lock: |
| 107 | + dead = [] |
| 108 | + for q in _sse_subscribers: |
| 109 | + try: |
| 110 | + q.put_nowait(event) |
| 111 | + except queue.Full: |
| 112 | + dead.append(q) |
| 113 | + for q in dead: |
| 114 | + _sse_subscribers.remove(q) |
| 115 | + |
| 116 | + |
| 117 | +def _sse_subscribe(): |
| 118 | + q = queue.Queue(maxsize=100) |
| 119 | + with _sse_lock: |
| 120 | + _sse_subscribers.append(q) |
| 121 | + return q |
| 122 | + |
| 123 | + |
| 124 | +def _sse_unsubscribe(q) -> None: |
| 125 | + with _sse_lock: |
| 126 | + if q in _sse_subscribers: |
| 127 | + _sse_subscribers.remove(q) |
| 128 | + |
| 129 | + |
75 | 130 | # --------------------------------------------------------------------------- # |
76 | 131 | # Authentication (Flask-Login) |
77 | 132 | # --------------------------------------------------------------------------- # |
@@ -211,6 +266,11 @@ def alert_filters(extra=None): |
211 | 266 | if value: |
212 | 267 | where_sql.append(f"{column} = %s") |
213 | 268 | params.append(value) |
| 269 | + # created_after: ISO datetime string (additive param, no existing filter affected) |
| 270 | + created_after = (request.args.get("created_after") or "").strip() |
| 271 | + if created_after: |
| 272 | + where_sql.append("created_at >= %s") |
| 273 | + params.append(created_after) |
214 | 274 | sql = (" WHERE " + " AND ".join(where_sql)) if where_sql else "" |
215 | 275 | return sql, params |
216 | 276 |
|
@@ -396,11 +456,19 @@ def api_ingest_alert(): |
396 | 456 | ) |
397 | 457 | created = cur.fetchone() |
398 | 458 |
|
399 | | - return jsonify(serialize(decrypt_alert(created))), 201 |
| 459 | + row = serialize(decrypt_alert(dict(created))) |
| 460 | + _sse_publish({ |
| 461 | + "type": "new_alert", |
| 462 | + "alert_id": row["id"], |
| 463 | + "severity": row["severity"], |
| 464 | + "status": row["status"], |
| 465 | + }) |
| 466 | + return jsonify(row), 201 |
400 | 467 |
|
401 | 468 |
|
402 | 469 | @app.route("/api/alerts/<int:alert_id>/classify", methods=["POST"]) |
403 | 470 | @login_required |
| 471 | +@require_role("analyst", "admin") |
404 | 472 | def api_classify(alert_id): |
405 | 473 | """Classify an alert: update status, record the analyst action + MTTR.""" |
406 | 474 | body = request.get_json(silent=True) or {} |
@@ -445,8 +513,31 @@ def api_classify(alert_id): |
445 | 513 | """, |
446 | 514 | (alert_id, analyst_name, action, alert["created_at"]), |
447 | 515 | ) |
| 516 | + # Audit trail — same transaction as the alert update (atomic). |
| 517 | + cur.execute( |
| 518 | + """ |
| 519 | + INSERT INTO audit_log |
| 520 | + (alert_id, user_id, username, action, from_status, to_status) |
| 521 | + VALUES (%s, %s, %s, %s, %s, %s) |
| 522 | + """, |
| 523 | + ( |
| 524 | + alert_id, |
| 525 | + int(current_user.id), |
| 526 | + current_user.username, |
| 527 | + action, |
| 528 | + alert["status"], |
| 529 | + new_status, |
| 530 | + ), |
| 531 | + ) |
448 | 532 |
|
449 | | - return jsonify(serialize(updated)) |
| 533 | + result = serialize(updated) |
| 534 | + _sse_publish({ |
| 535 | + "type": "status_change", |
| 536 | + "alert_id": alert_id, |
| 537 | + "severity": updated.get("severity"), |
| 538 | + "status": new_status, |
| 539 | + }) |
| 540 | + return jsonify(result) |
450 | 541 |
|
451 | 542 |
|
452 | 543 | @app.route("/api/stats") |
@@ -556,6 +647,159 @@ def api_stats(): |
556 | 647 | ) |
557 | 648 |
|
558 | 649 |
|
| 650 | +# --------------------------------------------------------------------------- # |
| 651 | +# Audit trail routes |
| 652 | +# --------------------------------------------------------------------------- # |
| 653 | +@app.route("/api/alerts/<int:alert_id>/audit") |
| 654 | +@login_required |
| 655 | +def api_alert_audit(alert_id): |
| 656 | + """Return the audit history for a single alert as JSON.""" |
| 657 | + with get_conn() as conn, conn.cursor() as cur: |
| 658 | + cur.execute( |
| 659 | + """ |
| 660 | + SELECT id, alert_id, user_id, username, action, |
| 661 | + from_status, to_status, note, created_at |
| 662 | + FROM audit_log |
| 663 | + WHERE alert_id = %s |
| 664 | + ORDER BY created_at ASC |
| 665 | + """, |
| 666 | + (alert_id,), |
| 667 | + ) |
| 668 | + rows = cur.fetchall() |
| 669 | + result = [] |
| 670 | + for r in rows: |
| 671 | + row = dict(r) |
| 672 | + row["note"] = decrypt_field(FERNET, row["note"]) |
| 673 | + result.append(serialize(row)) |
| 674 | + return jsonify(result) |
| 675 | + |
| 676 | + |
| 677 | +@app.route("/audit") |
| 678 | +@login_required |
| 679 | +@require_role("admin") |
| 680 | +def audit_log_page(): |
| 681 | + """Paginated audit log view, admin-only.""" |
| 682 | + page = max(1, int(request.args.get("page", 1))) |
| 683 | + per_page = 50 |
| 684 | + offset = (page - 1) * per_page |
| 685 | + search = (request.args.get("q") or "").strip() |
| 686 | + |
| 687 | + with get_conn() as conn, conn.cursor() as cur: |
| 688 | + if search: |
| 689 | + cur.execute( |
| 690 | + """ |
| 691 | + SELECT al.id, al.alert_id, al.username, al.action, |
| 692 | + al.from_status, al.to_status, al.created_at, al.note |
| 693 | + FROM audit_log al |
| 694 | + WHERE al.username ILIKE %s OR al.action ILIKE %s |
| 695 | + ORDER BY al.created_at DESC |
| 696 | + LIMIT %s OFFSET %s |
| 697 | + """, |
| 698 | + (f"%{search}%", f"%{search}%", per_page, offset), |
| 699 | + ) |
| 700 | + else: |
| 701 | + cur.execute( |
| 702 | + """ |
| 703 | + SELECT al.id, al.alert_id, al.username, al.action, |
| 704 | + al.from_status, al.to_status, al.created_at, al.note |
| 705 | + FROM audit_log al |
| 706 | + ORDER BY al.created_at DESC |
| 707 | + LIMIT %s OFFSET %s |
| 708 | + """, |
| 709 | + (per_page, offset), |
| 710 | + ) |
| 711 | + rows = cur.fetchall() |
| 712 | + cur.execute("SELECT count(*) AS c FROM audit_log") |
| 713 | + total = cur.fetchone()["c"] |
| 714 | + |
| 715 | + entries = [] |
| 716 | + for r in rows: |
| 717 | + row = dict(r) |
| 718 | + row["note"] = decrypt_field(FERNET, row["note"]) |
| 719 | + entries.append(serialize(row)) |
| 720 | + |
| 721 | + return render_template( |
| 722 | + "audit.html", |
| 723 | + entries=entries, |
| 724 | + page=page, |
| 725 | + per_page=per_page, |
| 726 | + total=total, |
| 727 | + search=search, |
| 728 | + ) |
| 729 | + |
| 730 | + |
| 731 | +@app.route("/api/alerts/<int:alert_id>/notes", methods=["POST"]) |
| 732 | +@login_required |
| 733 | +@require_role("analyst", "admin") |
| 734 | +def api_add_note(alert_id): |
| 735 | + """Add a case note to an alert. Stored encrypted, logged in audit_log.""" |
| 736 | + body = request.get_json(silent=True) or {} |
| 737 | + note = (body.get("note") or "").strip() |
| 738 | + if not note: |
| 739 | + abort(400, description="note is required") |
| 740 | + |
| 741 | + with get_conn() as conn, conn.cursor() as cur: |
| 742 | + cur.execute("SELECT status FROM alerts WHERE id = %s", (alert_id,)) |
| 743 | + alert = cur.fetchone() |
| 744 | + if alert is None: |
| 745 | + abort(404, description="alert not found") |
| 746 | + |
| 747 | + cur.execute( |
| 748 | + """ |
| 749 | + INSERT INTO audit_log |
| 750 | + (alert_id, user_id, username, action, from_status, to_status, note) |
| 751 | + VALUES (%s, %s, %s, 'note_added', %s, %s, %s) |
| 752 | + RETURNING id, created_at |
| 753 | + """, |
| 754 | + ( |
| 755 | + alert_id, |
| 756 | + int(current_user.id), |
| 757 | + current_user.username, |
| 758 | + alert["status"], |
| 759 | + alert["status"], |
| 760 | + encrypt_field(FERNET, note), |
| 761 | + ), |
| 762 | + ) |
| 763 | + row = cur.fetchone() |
| 764 | + |
| 765 | + return jsonify({"id": row["id"], "created_at": row["created_at"].isoformat()}), 201 |
| 766 | + |
| 767 | + |
| 768 | +# --------------------------------------------------------------------------- # |
| 769 | +# Server-Sent Events |
| 770 | +# --------------------------------------------------------------------------- # |
| 771 | +@app.route("/api/stream") |
| 772 | +@login_required |
| 773 | +def api_stream(): |
| 774 | + """SSE endpoint for live queue updates. |
| 775 | +
|
| 776 | + Publishes new_alert and status_change events. Uses an in-process Queue; |
| 777 | + only works within a single worker process. For multi-worker deployments, |
| 778 | + replace with Redis pub/sub. |
| 779 | + """ |
| 780 | + import json as _json |
| 781 | + |
| 782 | + def generate(): |
| 783 | + q = _sse_subscribe() |
| 784 | + try: |
| 785 | + while True: |
| 786 | + try: |
| 787 | + event = q.get(timeout=30) |
| 788 | + yield f"data: {_json.dumps(event)}\n\n" |
| 789 | + except queue.Empty: |
| 790 | + yield ": keepalive\n\n" |
| 791 | + except GeneratorExit: |
| 792 | + pass |
| 793 | + finally: |
| 794 | + _sse_unsubscribe(q) |
| 795 | + |
| 796 | + return Response( |
| 797 | + generate(), |
| 798 | + mimetype="text/event-stream", |
| 799 | + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, |
| 800 | + ) |
| 801 | + |
| 802 | + |
559 | 803 | # Enforce retention once at startup (covers both `python app.py` and gunicorn |
560 | 804 | # import). Guarded so an unreachable DB at boot never crashes the app. |
561 | 805 | if ALERT_RETENTION_DAYS > 0: |
|
0 commit comments