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 Review → Validated
+ - 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({