a3f86c7b31
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Admin still bypassed require_any_role (non-strict) on the pure operator actions: start-execution, submit-red, start-blue-work, submit-blue, pause/resume-timer, and the red/blue field-edit + general test create/update/remediation/import-rt/template endpoints all used the loose dependency even where admin wasn't in the role tuple. Switched every one of these to require_any_role_strict, matching the review/validate/dispute/hold/assign endpoints already fixed earlier. Frontend: removed every remaining role === "admin" disjunct gating Start Execution, Submit to Blue Team, blue evaluating actions, timer control, red/blue field editing, test creation, and template creation. Team-blindness visibility (isBlind) deliberately keeps the admin bypass — admin still sees both sides unmasked for oversight, since that's visibility, not operating. Updated ~20 tests whose fixtures used the admin account as a convenience shortcut to create/drive tests through the workflow — switched them to a red_lead account, fixing an incidental fixture-resolution-order cookie bug along the way (client's cookie jar prefers the last-logged-in role's cookie over any Bearer header explicitly passed to a later request).
244 lines
10 KiB
Python
244 lines
10 KiB
Python
"""HTTP-level tests for the red_review / blue_review lead-review gates.
|
|
|
|
Covers POST /tests/{id}/review-red and POST /tests/{id}/review-blue: the
|
|
assigned-reviewer-only guard, approve/reopen/gap decisions, and the state
|
|
transitions they drive. Uses the ``api`` fixture (see conftest.py) for any
|
|
step that switches role, since the TestClient cookie jar otherwise bleeds
|
|
one role's session into the next request.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from app.models.evidence import Evidence
|
|
from app.models.enums import TeamSide
|
|
|
|
|
|
def _create_technique(api, auth_headers, mitre_id="T1059.099"):
|
|
resp = api(
|
|
"post", "/api/v1/techniques", auth_headers,
|
|
json={"mitre_id": mitre_id, "name": "Command Line"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
return resp.json()["id"]
|
|
|
|
|
|
def _create_test(api, auth_headers, technique_id, name="Review gate test"):
|
|
resp = api(
|
|
"post", "/api/v1/tests", auth_headers,
|
|
json={"technique_id": technique_id, "name": name},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
return resp.json()["id"]
|
|
|
|
|
|
def _add_evidence(db, test_id, team: TeamSide):
|
|
ev = Evidence(
|
|
test_id=uuid.UUID(test_id),
|
|
file_name="proof.txt",
|
|
file_path="s3://bucket/proof.txt",
|
|
sha256_hash="a" * 64,
|
|
team=team,
|
|
)
|
|
db.add(ev)
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture
|
|
def technique(api, auth_headers):
|
|
return _create_technique(api, auth_headers)
|
|
|
|
|
|
def _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique_id, name="Review gate test"):
|
|
"""Drive a fresh test from draft to red_review via real HTTP calls."""
|
|
test_id = _create_test(api, auth_headers, technique_id, name)
|
|
|
|
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
|
_add_evidence(db, test_id, TeamSide.red)
|
|
resp = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["state"] == "red_review"
|
|
return test_id
|
|
|
|
|
|
def _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique_id, name="Review gate test"):
|
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique_id, name)
|
|
|
|
approve = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
|
|
assert approve.status_code == 200, approve.text
|
|
assert approve.json()["state"] == "blue_evaluating"
|
|
|
|
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
|
|
_add_evidence(db, test_id, TeamSide.blue)
|
|
resp = api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["state"] == "blue_review"
|
|
return test_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# review-red
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_review_red_forbidden_for_non_assigned_lead(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
|
):
|
|
"""A red_lead who isn't the assigned reviewer gets 403."""
|
|
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
|
# red_lead_user IS the only red_lead fixture, so it WILL be auto-assigned
|
|
# as reviewer (single-candidate load balancing). To exercise the 403
|
|
# path we need a second, non-assigned red_lead.
|
|
from app.auth import hash_password
|
|
from app.models.user import User
|
|
|
|
other_lead = User(
|
|
username="otherredlead", email="otherredlead@test.com",
|
|
hashed_password=hash_password("x"), role="red_lead", is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(other_lead)
|
|
db.commit()
|
|
|
|
login = client.post("/api/v1/auth/login", data={"username": "otherredlead", "password": "x"})
|
|
assert login.status_code == 200
|
|
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", other_headers, json={"decision": "approve"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_review_red_approve_moves_to_blue_evaluating(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
|
):
|
|
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve", "notes": "LGTM"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["state"] == "blue_evaluating"
|
|
assert body["red_review_notes"] == "LGTM"
|
|
assert body["blue_started_at"] is not None
|
|
|
|
|
|
def test_review_red_reopen_requires_notes(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
|
):
|
|
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen"})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_review_red_reopen_moves_to_red_executing(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
|
):
|
|
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen", "notes": "add more detail"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["state"] == "red_executing"
|
|
assert body["red_review_notes"] == "add more detail"
|
|
|
|
|
|
def test_review_red_forbidden_for_admin(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
|
):
|
|
"""Admin administers the site, not the test workflow — reviewing is a
|
|
red_lead-only action now, with no admin override."""
|
|
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", auth_headers, json={"decision": "approve"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# review-blue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_review_blue_forbidden_for_non_assigned_lead(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
|
red_lead_user, blue_lead_user, technique,
|
|
):
|
|
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
|
|
|
from app.auth import hash_password
|
|
from app.models.user import User
|
|
|
|
other_lead = User(
|
|
username="otherbluelead", email="otherbluelead@test.com",
|
|
hashed_password=hash_password("x"), role="blue_lead", is_active=True,
|
|
must_change_password=False,
|
|
)
|
|
db.add(other_lead)
|
|
db.commit()
|
|
|
|
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead", "password": "x"})
|
|
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", other_headers, json={"decision": "approve"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_review_blue_approve_moves_to_in_review(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
|
red_lead_user, blue_lead_user, technique,
|
|
):
|
|
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["state"] == "in_review"
|
|
|
|
|
|
def test_review_blue_reopen_requires_notes(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
|
red_lead_user, blue_lead_user, technique,
|
|
):
|
|
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen"})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_review_blue_reopen_moves_to_blue_evaluating(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
|
red_lead_user, blue_lead_user, technique,
|
|
):
|
|
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen", "notes": "redo detection"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["state"] == "blue_evaluating"
|
|
assert body["blue_work_started_at"] is None
|
|
|
|
|
|
def test_review_blue_gap_requires_system_gaps(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
|
red_lead_user, blue_lead_user, technique,
|
|
):
|
|
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
|
|
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "gap"})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_review_blue_gap_moves_to_in_review_with_system_gaps(
|
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
|
red_lead_user, blue_lead_user, technique,
|
|
):
|
|
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
|
|
|
resp = api(
|
|
"post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers,
|
|
json={"decision": "gap", "system_gaps": "Missing EDR agent on host X"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["state"] == "in_review"
|
|
assert body["system_gaps"] == "Missing EDR agent on host X"
|