Compare commits

..

2 Commits

Author SHA1 Message Date
kitos 11080bd627 fix(tests): recognize sysmon, wevtutil, auditpol as detection commands
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
Blue's detect_procedure extraction had zero Blue-team-oriented tool
names in its known-binaries list — sysmon.exe, one of the most common
Windows detection tools, silently produced no suggestion at all.
Verified against the exact production input that surfaced this.
2026-07-14 17:24:14 +02:00
kitos 1b7fd6fb36 fix(tests): restrict On Hold/Resume to whichever team currently owns the test
canHold checked role OR role instead of matching the current phase, so
a red_tech still saw (and could call) Hold/Resume on a test already
sitting in blue_evaluating. Fixed on both frontend and backend — the
API had the same gap, not just the button visibility.
2026-07-14 17:24:05 +02:00
5 changed files with 100 additions and 4 deletions
+19
View File
@@ -1310,6 +1310,21 @@ def assign_test_operators(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _check_hold_permission(current_user: User, test) -> None:
"""Only whichever team currently owns the test may hold/resume it.
``require_any_role_strict("red_tech", "blue_tech")`` alone lets either
role through regardless of phase — a red_tech could otherwise hold (or
resume) a test that has already moved into blue_evaluating.
"""
expected_role = "blue_tech" if test.state == TestState.blue_evaluating else "red_tech"
if current_user.role != expected_role:
raise HTTPException(
status_code=403,
detail=f"Only {expected_role.replace('_', ' ')} can do this while the test is in '{test.state}'.",
)
@router.post("/{test_id}/hold", response_model=TestOut) @router.post("/{test_id}/hold", response_model=TestOut)
def hold_test( def hold_test(
test_id: uuid.UUID, test_id: uuid.UUID,
@@ -1330,6 +1345,8 @@ def hold_test(
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.", detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
) )
_check_hold_permission(current_user, test)
if test.is_on_hold: if test.is_on_hold:
raise HTTPException(status_code=400, detail="Test is already on hold") raise HTTPException(status_code=400, detail="Test is already on hold")
@@ -1370,6 +1387,8 @@ def resume_test(
if not test.is_on_hold: if not test.is_on_hold:
raise HTTPException(status_code=400, detail="Test is not on hold") raise HTTPException(status_code=400, detail="Test is not on hold")
_check_hold_permission(current_user, test)
test.is_on_hold = False test.is_on_hold = False
test.hold_reason = None test.hold_reason = None
test.held_at = None test.held_at = None
@@ -48,7 +48,9 @@ _COMMAND_LINE_RE = re.compile(
netsh|wmic|schtasks|sc|rundll32|regsvr32|msiexec|certutil| netsh|wmic|schtasks|sc|rundll32|regsvr32|msiexec|certutil|
mimikatz\S*|cscript|wscript|osascript|openssl|systemctl|service| mimikatz\S*|cscript|wscript|osascript|openssl|systemctl|service|
reg\s+query|whoami|nslookup|dig|ping|tasklist|ps\s|kill| reg\s+query|whoami|nslookup|dig|ping|tasklist|ps\s|kill|
Invoke-\S+|iex|dsquery|nltest)\b Invoke-\S+|iex|dsquery|nltest|
sysmon\S*|wevtutil|auditpol|tcpdump|tshark|procmon\S*|autorunsc\S*|
volatility\S*)\b
| |
\bSELECT\s+.+\s+FROM\b # SQL \bSELECT\s+.+\s+FROM\b # SQL
| |
+60
View File
@@ -31,6 +31,20 @@ def _seed_executing_test(db, technique, created_by) -> Test:
return test return test
def _seed_blue_evaluating_test(db, technique, created_by) -> Test:
test = Test(
technique_id=technique.id,
name="Holdable test (blue)",
created_by=created_by,
state=TestState.blue_evaluating,
blue_started_at=datetime.utcnow() - timedelta(minutes=10),
)
db.add(test)
db.commit()
db.refresh(test)
return test
def test_hold_pauses_the_running_timer(client, db, red_tech_headers, red_tech_user): def test_hold_pauses_the_running_timer(client, db, red_tech_headers, red_tech_user):
technique = _seed_technique(db) technique = _seed_technique(db)
test = _seed_executing_test(db, technique, red_tech_user.id) test = _seed_executing_test(db, technique, red_tech_user.id)
@@ -88,3 +102,49 @@ def test_hold_does_not_double_pause_an_already_paused_timer(client, db, red_tech
db.refresh(test) db.refresh(test)
assert test.paused_at == manual_pause_time assert test.paused_at == manual_pause_time
def test_red_tech_cannot_hold_a_test_in_blue_evaluating(
client, db, red_tech_headers, red_tech_user,
):
"""Once a test has moved into Blue's queue, only blue_tech may hold it —
a red_tech is no longer involved at this stage."""
technique = _seed_technique(db)
test = _seed_blue_evaluating_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on lab access"},
headers=red_tech_headers,
)
assert resp.status_code == 403
def test_blue_tech_can_hold_a_test_in_blue_evaluating(
client, db, blue_tech_headers, red_tech_user,
):
technique = _seed_technique(db)
test = _seed_blue_evaluating_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on EDR access"},
headers=blue_tech_headers,
)
assert resp.status_code == 200, resp.text
def test_blue_tech_cannot_resume_a_test_that_was_held_during_red_executing(
client, db, api, red_tech_headers, blue_tech_headers, red_tech_user,
):
technique = _seed_technique(db)
test = _seed_executing_test(db, technique, red_tech_user.id)
resp = api(
"post", f"/api/v1/tests/{test.id}/hold", red_tech_headers,
json={"reason": "waiting on lab access"},
)
assert resp.status_code == 200, resp.text
resp = api("post", f"/api/v1/tests/{test.id}/resume", blue_tech_headers)
assert resp.status_code == 403
@@ -95,3 +95,15 @@ def test_known_binary_at_start_of_line_is_recognized():
text = "Notes: used the following.\nsudo tcpdump -i eth0 -w capture.pcap\nCaptured 500 packets." text = "Notes: used the following.\nsudo tcpdump -i eth0 -w capture.pcap\nCaptured 500 packets."
result = extract_commands(text) result = extract_commands(text)
assert result == "sudo tcpdump -i eth0 -w capture.pcap" assert result == "sudo tcpdump -i eth0 -w capture.pcap"
def test_recognizes_sysmon_as_a_detection_command():
text = "launch sysmon to detect\nsysmon.exe\nthen search this query\n\\? mimikatz \\n"
result = extract_commands(text)
assert result == "sysmon.exe"
def test_recognizes_wevtutil_and_auditpol():
text = "Pulled the security log.\nwevtutil qe Security /c:5\nThen checked audit policy.\nauditpol /get /category:*"
result = extract_commands(text)
assert result == "wevtutil qe Security /c:5\nauditpol /get /category:*"
@@ -158,10 +158,13 @@ export default function TestDetailHeader({
// ── Contextual action buttons ──────────────────────────────────── // ── Contextual action buttons ────────────────────────────────────
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"]; // Whichever team currently "owns" the test is the only one who should see
// Hold — a red_tech shouldn't be able to hold a test that's already moved
// into blue_evaluating (and vice versa), even though both roles can hold
// during their own phase.
const canHold = const canHold =
HOLDABLE_STATES.includes(test.state) && (["draft", "red_executing"].includes(test.state) && role === "red_tech") ||
(role === "red_tech" || role === "blue_tech"); (test.state === "blue_evaluating" && role === "blue_tech");
const renderActions = () => { const renderActions = () => {
const buttons: React.ReactNode[] = []; const buttons: React.ReactNode[] = [];