fix(tests): remove blind visibility, both teams always see each other's fields

Red and Blue could not see each other's real field data until both
reviews passed, showing a placeholder message instead. This kept
detection testing blind to what Red actually did; both sides now see
the full, live test data at all times.
This commit is contained in:
kitos
2026-07-14 13:36:19 +02:00
parent bdc6579c10
commit f87959369b
3 changed files with 21 additions and 101 deletions
+3 -44
View File
@@ -163,46 +163,6 @@ from app.services.test_workflow_service import (
router = APIRouter(prefix="/tests", tags=["tests"]) router = APIRouter(prefix="/tests", tags=["tests"])
# ---------------------------------------------------------------------------
# Blind visibility — hide the other team's fields until both reviews pass
# ---------------------------------------------------------------------------
_RED_ONLY_FIELDS = [
"procedure_text", "tool_used", "attack_success",
"execution_start_time", "execution_end_time", "red_summary",
"red_validation_status", "red_validated_by", "red_validated_at", "red_validation_notes",
]
_BLUE_ONLY_FIELDS = [
"detection_result", "containment_result", "detection_time", "containment_time",
"blue_summary", "blue_validation_status", "blue_validated_by", "blue_validated_at",
"blue_validation_notes", "system_gaps",
]
_BLIND_STATES = {"draft", "red_executing", "red_review", "blue_evaluating", "blue_review"}
def _mask_for_team_blindness(test_out: TestOut, *, viewer_role: str) -> TestOut:
"""Null out the other team's fields while the test is still blind.
admin and viewer are never blinded. Once the test reaches in_review or
beyond, both sides see everything (existing cross-validation behavior).
"""
if viewer_role in ("admin", "viewer"):
return test_out
test_state = test_out.state.value if hasattr(test_out.state, "value") else str(test_out.state)
if test_state not in _BLIND_STATES:
return test_out
if viewer_role in ("blue_tech", "blue_lead"):
hide_fields = _RED_ONLY_FIELDS
elif viewer_role in ("red_tech", "red_lead"):
hide_fields = _BLUE_ONLY_FIELDS
else:
return test_out
return test_out.model_copy(update={f: None for f in hide_fields})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /tests — list with filters # GET /tests — list with filters
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -504,12 +464,11 @@ def get_test(
Returns: Returns:
TestOut: Full test detail including split red/blue evidence lists. TestOut: Full test detail including split red/blue evidence lists.
Fields belonging to the other team are nulled out while the Both teams see each other's fields regardless of state — Red
test is blind (see :func:`_mask_for_team_blindness`). and Blue are meant to see each other's work, not test blind.
""" """
test = crud_get_test_detail(db, test_id) test = crud_get_test_detail(db, test_id)
test_out = TestOut.model_validate(test) return TestOut.model_validate(test)
return _mask_for_team_blindness(test_out, viewer_role=current_user.role)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+18 -28
View File
@@ -1,9 +1,10 @@
"""Tests for red/blue blind visibility on GET /tests/{id}. """Regression: Red and Blue must see each other's fields at every stage.
Neither team should see the other team's data while a test is in Blind visibility (hiding the other team's fields until both reviews pass)
draft/red_executing/red_review/blue_evaluating/blue_review. Both sides was a deliberate design in an earlier phase, but the org decided both teams
see everything once the test reaches in_review (and beyond). admin and should see each other's work throughout — detection testing isn't meant to
viewer are never blinded. be blind here. This confirms neither the frontend nor backend hides
anything based on role/state anymore.
""" """
import uuid import uuid
@@ -46,11 +47,11 @@ def technique(api, auth_headers):
@pytest.fixture @pytest.fixture
def blind_test(client, db, api, red_lead_headers, red_lead_user, red_tech_headers, technique): def in_progress_test(client, db, api, red_lead_headers, red_lead_user, red_tech_headers, technique):
"""A test in red_executing with red-side content already filled in.""" """A test in red_executing with red-side content already filled in."""
resp = api( resp = api(
"post", "/api/v1/tests", red_lead_headers, "post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Blind visibility test"}, json={"technique_id": technique, "name": "Visibility test"},
) )
test_id = resp.json()["id"] test_id = resp.json()["id"]
@@ -62,32 +63,21 @@ def blind_test(client, db, api, red_lead_headers, red_lead_user, red_tech_header
return test_id return test_id
def test_blue_viewer_cannot_see_red_fields_during_red_executing( def test_blue_viewer_sees_red_fields_during_red_executing(
client, db, api, blind_test, blue_tech_headers client, db, api, in_progress_test, blue_tech_headers
): ):
resp = api("get", f"/api/v1/tests/{blind_test}", blue_tech_headers) resp = api("get", f"/api/v1/tests/{in_progress_test}", blue_tech_headers)
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
for field in _RED_FIELDS:
assert body[field] is None, f"{field} should be hidden from blue viewer"
def test_red_viewer_cannot_see_blue_fields_during_red_executing(
client, db, api, blind_test, red_tech_headers
):
resp = api("get", f"/api/v1/tests/{blind_test}", red_tech_headers)
assert resp.status_code == 200
body = resp.json()
for field in _BLUE_FIELDS:
assert body[field] is None, f"{field} should be hidden from red viewer"
# Red's own fields ARE visible to red
assert body["procedure_text"] == "run mimikatz" assert body["procedure_text"] == "run mimikatz"
assert body["tool_used"] == "mimikatz"
assert body["red_summary"] == "dumped creds"
def test_admin_sees_everything_during_blind_states( def test_red_viewer_sees_own_fields_during_red_executing(
client, db, api, blind_test, auth_headers client, db, api, in_progress_test, red_tech_headers
): ):
resp = api("get", f"/api/v1/tests/{blind_test}", auth_headers) resp = api("get", f"/api/v1/tests/{in_progress_test}", red_tech_headers)
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["procedure_text"] == "run mimikatz" assert body["procedure_text"] == "run mimikatz"
@@ -95,9 +85,9 @@ def test_admin_sees_everything_during_blind_states(
def test_both_sides_see_everything_once_in_review( def test_both_sides_see_everything_once_in_review(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, blind_test, blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, in_progress_test,
): ):
test_id = blind_test test_id = in_progress_test
_add_evidence(db, test_id, TeamSide.red) _add_evidence(db, test_id, TeamSide.red)
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers) submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
assert submit_red.status_code == 200, submit_red.text assert submit_red.status_code == 200, submit_red.text
@@ -177,13 +177,6 @@ export default function TeamTabs({
(role === "blue_lead" || (role === "blue_lead" ||
(role === "blue_tech" && !!test.blue_work_started_at)); (role === "blue_tech" && !!test.blue_work_started_at));
// Blind visibility: neither side sees the other's data until both reviews
// pass (matches the backend's field-masking on GET /tests/{id}).
const BLIND_STATES = ["draft", "red_executing", "red_review", "blue_evaluating", "blue_review"];
const isBlind = BLIND_STATES.includes(test.state) && role !== "admin" && role !== "viewer";
const hideBlueFromMe = isBlind && (role === "red_tech" || role === "red_lead");
const hideRedFromMe = isBlind && (role === "blue_tech" || role === "blue_lead");
// Containment fields only visible when attack was detected (draft or saved value) // Containment fields only visible when attack was detected (draft or saved value)
const isDetected = canEditBlue const isDetected = canEditBlue
? blueDraft.detection_result === "detected" || blueDraft.detection_result === "partially_detected" ? blueDraft.detection_result === "detected" || blueDraft.detection_result === "partially_detected"
@@ -211,17 +204,6 @@ export default function TeamTabs({
// ── Red Team Tab ───────────────────────────────────────────────── // ── Red Team Tab ─────────────────────────────────────────────────
const renderRedTab = () => { const renderRedTab = () => {
if (hideRedFromMe) {
return (
<div className="py-12 text-center">
<Shield className="mx-auto h-10 w-10 text-gray-600" />
<p className="mt-2 text-sm text-gray-400">
Red Team work is hidden until both teams' reviews are complete — this keeps
detection testing blind.
</p>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Locked hint for red_tech in draft state */} {/* Locked hint for red_tech in draft state */}
@@ -420,17 +402,6 @@ export default function TeamTabs({
// ── Blue Team Tab ──────────────────────────────────────────────── // ── Blue Team Tab ────────────────────────────────────────────────
const renderBlueTab = () => { const renderBlueTab = () => {
if (hideBlueFromMe) {
return (
<div className="py-12 text-center">
<ShieldCheck className="mx-auto h-10 w-10 text-gray-600" />
<p className="mt-2 text-sm text-gray-400">
Blue Team work is hidden until both teams' reviews are complete this keeps
detection testing blind.
</p>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Locked hint for blue_tech before Start Evaluation */} {/* Locked hint for blue_tech before Start Evaluation */}