diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 33c9b6a..b58b22d 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -632,6 +632,121 @@ def check_new_evaluation_round( return check_for_new_round(db) +@router.post("/attck-evaluations/bulk-approve") +def bulk_approve_evaluation_tests( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Bulk-approve all Blue Team validation for ATT&CK Evaluation imported tests. + + Finds every test in ``in_review`` state whose name starts with ``[EVAL R`` + and approves the Blue Team side. Because all evaluation imports pre-approve + the Red Team side, this moves every matched test to ``validated`` state. + + **Important caveats** (enforced by UI warnings before this is called): + - Results come from a controlled MITRE lab, NOT the organisation's env. + - Validated tests will immediately affect coverage metrics and dashboards. + - Blue Leads should still spot-check high-priority techniques individually. + """ + from datetime import datetime + from app.models.test import Test + from app.models.enums import TestState + from app.models.technique import Technique + from app.services.status_service import recalculate_technique_status + from app.services.audit_service import log_action + + # Find all pending evaluation tests + pending = ( + db.query(Test) + .filter( + Test.state == TestState.in_review, + Test.name.like("[EVAL R%"), + ) + .all() + ) + + if not pending: + return { + "approved": 0, + "techniques_recalculated": 0, + "message": "No pending evaluation tests found — nothing to approve.", + } + + now = datetime.utcnow() + affected_technique_ids: set = set() + + for test in pending: + # Approve blue side + test.blue_validation_status = "approved" + test.blue_validated_by = current_user.id + test.blue_validated_at = now + test.blue_validation_notes = ( + "Bulk-approved via ATT&CK Evaluations admin panel. " + "Source: MITRE lab environment — not organisational detection." + ) + + # Red side was pre-approved during import → move to validated + if test.red_validation_status == "approved": + test.state = TestState.validated + # else stays in_review (shouldn't happen for eval imports, but be safe) + + if test.technique_id: + affected_technique_ids.add(test.technique_id) + + log_action( + db, + user_id=current_user.id, + action="bulk_eval_approve", + entity_type="test", + entity_id=test.id, + details={"source": "attck_evaluation_bulk_approve"}, + ) + + db.flush() + + # Recalculate coverage for every touched technique + for tech_id in affected_technique_ids: + tech = db.query(Technique).filter(Technique.id == tech_id).first() + if tech: + recalculate_technique_status(db, tech) + + db.commit() + + logger.info( + "Bulk eval approval: %d tests validated, %d techniques recalculated (by %s)", + len(pending), len(affected_technique_ids), current_user.email, + ) + + return { + "approved": len(pending), + "techniques_recalculated": len(affected_technique_ids), + "message": ( + f"{len(pending)} evaluation tests approved and moved to Validated. " + f"{len(affected_technique_ids)} technique statuses recalculated." + ), + } + + +@router.get("/attck-evaluations/pending-count") +def get_pending_evaluation_count( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Return the number of imported evaluation tests still awaiting Blue approval.""" + from app.models.test import Test + from app.models.enums import TestState + + count = ( + db.query(Test) + .filter( + Test.state == TestState.in_review, + Test.name.like("[EVAL R%"), + ) + .count() + ) + return {"pending": count} + + @router.post("/email-test") def send_test_email( payload: EmailTestRequest, diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 63bc7b3..c802c17 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -102,3 +102,21 @@ export async function checkNewEvaluationRound(): Promise { const { data } = await client.get("/system/attck-evaluations/check-new"); return data; } + +export interface BulkApproveResult { + approved: number; + techniques_recalculated: number; + message: string; +} + +/** Bulk-approve all in-review evaluation tests (Blue Team side). */ +export async function bulkApproveEvaluationTests(): Promise { + const { data } = await client.post("/system/attck-evaluations/bulk-approve"); + return data; +} + +/** Get the count of evaluation tests still awaiting Blue approval. */ +export async function getEvalPendingCount(): Promise<{ pending: number }> { + const { data } = await client.get<{ pending: number }>("/system/attck-evaluations/pending-count"); + return data; +} diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index 1182dd1..045367e 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -26,6 +26,7 @@ import { AlertTriangle, ExternalLink, CalendarCheck, + ShieldCheck, } from "lucide-react"; import client from "../api/client"; import { @@ -36,12 +37,15 @@ import { importEvaluationRound, importLatestEvaluation, checkNewEvaluationRound, + bulkApproveEvaluationTests, + getEvalPendingCount, type SyncMitreResponse, type IntelScanResponse, type EvaluationRound, type EvaluationRoundsResponse, type EvaluationImportResult, type NewRoundCheckResult, + type BulkApproveResult, } from "../api/system"; import { getTemplateStats, @@ -68,6 +72,8 @@ export default function SystemPage() { const [evalImportResult, setEvalImportResult] = useState(null); const [evalCheckResult, setEvalCheckResult] = useState(null); const [evalImportingRound, setEvalImportingRound] = useState(null); + const [showBulkApproveModal, setShowBulkApproveModal] = useState(false); + const [bulkApproveResult, setBulkApproveResult] = useState(null); // ── Existing queries ───────────────────────────────────────────── const { @@ -214,6 +220,27 @@ export default function SystemPage() { }, }); + const { + data: evalPendingData, + refetch: refetchPendingCount, + } = useQuery({ + queryKey: ["eval-pending-count"], + queryFn: getEvalPendingCount, + }); + + const bulkApproveMutation = useMutation({ + mutationFn: bulkApproveEvaluationTests, + onSuccess: (data) => { + setBulkApproveResult(data); + setShowBulkApproveModal(false); + refetchPendingCount(); + refetchEvalRounds(); + queryClient.invalidateQueries({ queryKey: ["techniques"] }); + queryClient.invalidateQueries({ queryKey: ["metrics"] }); + queryClient.invalidateQueries({ queryKey: ["review-queue"] }); + }, + }); + const formatNextRun = (dateStr: string | null) => { if (!dateStr) return "Not scheduled"; const date = new Date(dateStr); @@ -384,7 +411,7 @@ export default function SystemPage() {

-
+
+ {(evalPendingData?.pending ?? 0) > 0 && ( + + )}
@@ -495,6 +534,33 @@ export default function SystemPage() { )} + {/* Bulk approve result */} + {bulkApproveResult && ( +
+
+ + Bulk approval complete +
+
+
+

{bulkApproveResult.approved}

+

Tests validated

+
+
+

{bulkApproveResult.techniques_recalculated}

+

Technique statuses updated

+
+
+

{bulkApproveResult.message}

+ +
+ )} + {/* Import result feedback */} {evalImportResult && (
@@ -661,6 +727,17 @@ export default function SystemPage() {
+ {/* ── Bulk Approve Modal ──────────────────────────────────────── */} + {showBulkApproveModal && ( + bulkApproveMutation.mutate()} + onClose={() => setShowBulkApproveModal(false)} + /> + )} + {/* ──────────────────────────────────────────────────────────────── TEMPLATE ADMINISTRATION (T-124) ──────────────────────────────────────────────────────────────── */} @@ -1243,6 +1320,185 @@ function ExportImportSection() { ); } +/* ── Bulk Approve Evaluation Tests Modal ─────────────────────────── */ + +function BulkApproveModal({ + pendingCount, + isPending, + error, + onConfirm, + onClose, +}: { + pendingCount: number; + isPending: boolean; + error: string | null; + onConfirm: () => void; + onClose: () => void; +}) { + const [checks, setChecks] = useState({ + labEnv: false, + notOrg: false, + metricsImpact: false, + spotCheck: false, + }); + + const allChecked = Object.values(checks).every(Boolean); + + const toggle = (key: keyof typeof checks) => + setChecks((prev) => ({ ...prev, [key]: !prev[key] })); + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Bulk Approve Evaluation Tests

+

+ {pendingCount} test{pendingCount !== 1 ? "s" : ""} awaiting Blue Team approval +

+
+
+ +
+ + {/* Body */} +
+ {/* What will happen */} +
+

What this action does:

+
    +
  • Approves the Blue Team validation for {pendingCount} imported evaluation tests
  • +
  • Moves all of them from In ReviewValidated
  • +
  • Recalculates technique coverage across the ATT&CK matrix immediately
  • +
  • Updates programme score, dashboards, and executive metrics
  • +
+
+ + {/* Warnings — must all be checked */} +

+ Read and confirm each point before proceeding +

+ + + + + + + + + + {/* Error */} + {error && ( +
+ {error} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} + /* ── Create Template Form (inline modal) ──────────────────────────── */ function CreateTemplateForm({