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:
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user