soc-openenv / soc_env /environment.py
Battlecon's picture
Flatten diretory structure
03b6159
Raw
History Blame Contribute Delete
15.7 kB
"""
SOCEnv — main OpenEnv environment class.
Public interface (OpenEnv spec):
reset() -> Observation
step(action) -> (Observation, Reward, done: bool, info: dict)
state() -> EnvState
grade() -> float [0.0, 1.0]
"""
from __future__ import annotations
import copy
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from .models import (
Action, ActionType, AlertStatus, Observation, Reward, EnvState,
InvestigationNote, MITRETactic, HostStatus, BusinessConstraint,
NetworkHost, SIEMAlert,
)
def _ts() -> str:
return datetime.now(timezone.utc).isoformat()
def _load_scenario(task_id: str, seed: int) -> Dict[str, Any]:
"""Import scenarios module with robust path resolution."""
# Add repo root to path so 'scenarios' package is always found
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if root not in sys.path:
sys.path.insert(0, root)
from scenarios import load_scenario
return load_scenario(task_id, seed=seed)
class SOCEnv:
"""Security Operations Center OpenEnv environment."""
TASK_IDS = [
"alert_triage",
"attack_chain_reconstruction",
"constrained_incident_response",
]
MAX_STEPS = {
"alert_triage": 10,
"attack_chain_reconstruction": 25,
"constrained_incident_response": 40,
}
MAX_MINUTES = {
"alert_triage": 30,
"attack_chain_reconstruction": 120,
"constrained_incident_response": 240,
}
MINUTES_PER_STEP = {
"alert_triage": 3,
"attack_chain_reconstruction": 5,
"constrained_incident_response": 6,
}
def __init__(self, task_id: str = "alert_triage", seed: int = 42):
if task_id not in self.TASK_IDS:
raise ValueError(f"task_id must be one of {self.TASK_IDS}")
self.task_id = task_id
self.seed = seed
self._state: Optional[EnvState] = None
# ------------------------------------------------------------------
# OpenEnv public interface
# ------------------------------------------------------------------
def reset(self) -> Observation:
"""Initialise episode. Returns first Observation."""
scenario = _load_scenario(self.task_id, self.seed)
self._state = EnvState(
task_id=self.task_id,
step=0,
done=False,
cumulative_reward=0.0,
observation=scenario["observation"],
attack_chain=scenario.get("attack_chain"),
agent_classifications={},
identified_stages=[],
isolated_hosts=[],
disabled_accounts=[],
forensics_collected={},
escalated=False,
ticket_created=False,
)
return copy.deepcopy(self._state.observation)
def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
"""Advance environment one step. Returns (obs, reward, done, info)."""
if self._state is None:
raise RuntimeError("Call reset() before step().")
if self._state.done:
raise RuntimeError("Episode done. Call reset() to start a new episode.")
s = self._state
s.step += 1
s.observation.step = s.step
s.observation.steps_remaining = self.MAX_STEPS[self.task_id] - s.step
s.observation.elapsed_minutes = min(
s.observation.elapsed_minutes + self.MINUTES_PER_STEP[self.task_id],
self.MAX_MINUTES[self.task_id],
)
action_result, action_ok = self._apply_action(action, s)
s.observation.last_action_result = action_result
s.observation.last_action_success = action_ok
s.observation.notes.append(InvestigationNote(
step=s.step,
action_taken=action.action_type.value,
finding=action_result,
timestamp=_ts(),
))
reward = self._compute_reward(action, s, action_ok)
s.cumulative_reward = round(s.cumulative_reward + reward.total, 4)
s.done = self._check_done(s)
# Move non-pending alerts to acknowledged list
s.observation.acknowledged_alerts = [
a for a in s.observation.active_alerts + s.observation.acknowledged_alerts
if a.status != AlertStatus.PENDING
]
s.observation.active_alerts = [
a for a in s.observation.active_alerts
if a.status == AlertStatus.PENDING
]
info = {
"cumulative_reward": s.cumulative_reward,
"step": s.step,
"identified_stages": [st.value for st in s.identified_stages],
"isolated_hosts": s.isolated_hosts,
"done": s.done,
}
return copy.deepcopy(s.observation), reward, s.done, info
def state(self) -> EnvState:
"""Return full environment state including ground truth (for grading)."""
if self._state is None:
raise RuntimeError("Call reset() before state().")
return copy.deepcopy(self._state)
def grade(self) -> float:
"""Run the grader on current state. Returns float in [0.0, 1.0]."""
if self._state is None:
raise RuntimeError("Call reset() and run an episode before grading.")
from .graders import grade_task_easy, grade_task_medium, grade_task_hard
s = self._state
if self.task_id == "alert_triage":
return grade_task_easy(s)
elif self.task_id == "attack_chain_reconstruction":
return grade_task_medium(s)
else:
return grade_task_hard(s)
# ------------------------------------------------------------------
# Action handlers
# ------------------------------------------------------------------
def _apply_action(self, action: Action, s: EnvState) -> Tuple[str, bool]:
t = action.action_type
if t == ActionType.ENRICH_ALERT:
return self._do_enrich(action, s)
elif t == ActionType.CORRELATE_ALERTS:
return self._do_correlate(action, s)
elif t == ActionType.ISOLATE_ENDPOINT:
return self._do_isolate(action, s)
elif t == ActionType.DISABLE_ACCOUNT:
return self._do_disable_account(action, s)
elif t == ActionType.COLLECT_FORENSICS:
return self._do_forensics(action, s)
elif t == ActionType.ESCALATE_TO_TIER2:
return self._do_escalate(action, s)
elif t == ActionType.CREATE_TICKET:
return self._do_ticket(action, s)
return "Unknown action type.", False
def _do_enrich(self, action: Action, s: EnvState) -> Tuple[str, bool]:
alert = self._find_alert(action.alert_id, s)
if not alert:
return f"Alert {action.alert_id} not found.", False
alert.status = AlertStatus.INVESTIGATING
source = action.source or "threat_intel"
enrichment: Dict[str, Any] = {"source": source, "retrieved_at": _ts()}
if source == "threat_intel":
enrichment["indicators_checked"] = len(alert.indicators)
enrichment["malicious_hits"] = sum(
1 for i in alert.indicators if i.reputation == "malicious"
)
for ind in alert.indicators:
ind.reputation = ind.reputation or "unknown"
elif source == "user_context":
enrichment["user_id"] = alert.user_id
enrichment["anomalous_behaviour"] = bool(alert.ground_truth)
elif source == "asset_db":
host = self._find_host(alert.host_id, s)
enrichment["host_role"] = host.role if host else "unknown"
enrichment["is_critical"] = host.is_critical if host else False
alert.enrichment = enrichment
return f"Enriched alert {action.alert_id} via {source}: {json.dumps(enrichment)}", True
def _do_correlate(self, action: Action, s: EnvState) -> Tuple[str, bool]:
if not action.alert_ids or len(action.alert_ids) < 2:
return "correlate_alerts requires at least 2 alert_ids.", False
all_alerts = s.observation.active_alerts + s.observation.acknowledged_alerts
alerts = [a for a in all_alerts if a.alert_id in action.alert_ids]
if len(alerts) < 2:
return "Could not find 2+ valid alerts to correlate.", False
new_tactics: List[MITRETactic] = []
for a in alerts:
if a.mitre_tactic and a.mitre_tactic not in s.identified_stages:
s.identified_stages.append(a.mitre_tactic)
new_tactics.append(a.mitre_tactic)
return (
f"Correlated {len(alerts)} alerts. "
f"Hypothesis: {action.correlation_hypothesis or '(none)'}. "
f"New ATT&CK tactics: {[t.value for t in new_tactics] or 'none'}."
), True
def _do_isolate(self, action: Action, s: EnvState) -> Tuple[str, bool]:
host = self._find_host(action.host_id, s)
if not host:
return f"Host {action.host_id} not found.", False
for bc in s.observation.business_constraints:
if bc.host_id == action.host_id and bc.constraint_type == "cannot_isolate":
if bc.severity == "hard_block":
return f"BLOCKED: Cannot isolate {host.hostname}. Reason: {bc.reason}", False
if host.status == HostStatus.ISOLATED:
return f"{host.hostname} is already isolated.", False
host.status = HostStatus.ISOLATED
if action.host_id not in s.isolated_hosts:
s.isolated_hosts.append(action.host_id)
return f"Endpoint {host.hostname} ({host.ip_address}) isolated from network.", True
def _do_disable_account(self, action: Action, s: EnvState) -> Tuple[str, bool]:
if not action.user_id:
return "user_id is required for disable_account.", False
if action.user_id not in s.disabled_accounts:
s.disabled_accounts.append(action.user_id)
return f"Account {action.user_id} disabled. Active sessions terminated.", True
def _do_forensics(self, action: Action, s: EnvState) -> Tuple[str, bool]:
host = self._find_host(action.host_id, s)
if not host:
return f"Host {action.host_id} not found.", False
artifacts = action.artifact_types or ["memory_dump", "event_logs"]
s.forensics_collected[action.host_id] = list(
set(s.forensics_collected.get(action.host_id, []) + artifacts)
)
for bc in s.observation.business_constraints:
if bc.host_id == action.host_id and bc.constraint_type == "legal_hold":
return (
f"Forensics collected from {host.hostname}: {artifacts}. "
f"LEGAL HOLD applied — evidence preserved per: {bc.reason}."
), True
return f"Forensics collected from {host.hostname}: {artifacts}.", True
def _do_escalate(self, action: Action, s: EnvState) -> Tuple[str, bool]:
if s.escalated:
return "Already escalated to Tier 2.", False
s.escalated = True
return f"Escalated to Tier 2. Summary: {action.summary or '(no summary provided)'}.", True
def _do_ticket(self, action: Action, s: EnvState) -> Tuple[str, bool]:
s.ticket_created = True
return (
f"Ticket created — priority {action.priority or 'P2'}. "
f"Summary: {action.summary or '(no summary)'}."
), True
# ------------------------------------------------------------------
# Reward computation
# ------------------------------------------------------------------
def _compute_reward(self, action: Action, s: EnvState, action_ok: bool) -> Reward:
r = Reward(total=0.0)
if not action_ok:
r.total = -0.02
r.info = "invalid_action"
return r
t = action.action_type
if t == ActionType.ENRICH_ALERT:
r.classification_delta = 0.05
r.info = "enrichment"
elif t == ActionType.CORRELATE_ALERTS:
r.chain_coverage_delta = round(len(s.identified_stages) * 0.08, 3)
r.info = f"correlation_stages_{len(s.identified_stages)}"
elif t == ActionType.ISOLATE_ENDPOINT:
host = self._find_host(action.host_id, s)
if host:
chain = s.attack_chain
in_chain = chain and (
action.host_id == chain.patient_zero_host
or action.host_id in chain.lateral_movement_targets
)
if in_chain:
r.containment_delta = 0.25
r.info = "correct_isolation"
else:
r.fp_penalty = -0.20
r.info = "fp_isolation"
elif t == ActionType.DISABLE_ACCOUNT:
all_alerts = s.observation.active_alerts + s.observation.acknowledged_alerts
alert_users = {a.user_id for a in all_alerts if a.user_id}
if action.user_id in alert_users:
r.containment_delta = 0.10
r.info = "correct_account_disable"
else:
r.fp_penalty = -0.05
r.info = "fp_account_disable"
elif t == ActionType.COLLECT_FORENSICS:
r.classification_delta = 0.05
r.info = "forensics_collected"
elif t == ActionType.ESCALATE_TO_TIER2:
if s.observation.business_constraints:
r.compliance_delta = 0.10
r.info = "appropriate_escalation"
else:
r.fp_penalty = -0.05
r.info = "premature_escalation"
elif t == ActionType.CREATE_TICKET:
r.compliance_delta = 0.05
r.info = "ticket_created"
r.dwell_penalty = round(-0.005 * self.MINUTES_PER_STEP[self.task_id], 4)
r.total = round(
r.classification_delta + r.chain_coverage_delta + r.containment_delta
+ r.fp_penalty + r.compliance_delta + r.dwell_penalty,
4,
)
r.total = max(-1.0, min(1.0, r.total))
return r
# ------------------------------------------------------------------
# Done condition
# ------------------------------------------------------------------
def _check_done(self, s: EnvState) -> bool:
if s.step >= self.MAX_STEPS[self.task_id]:
return True
if s.attack_chain and s.attack_chain.exfiltration_complete:
return True
all_alerts = s.observation.active_alerts + s.observation.acknowledged_alerts
closed_statuses = {AlertStatus.CONTAINED, AlertStatus.CLOSED, AlertStatus.FALSE_POSITIVE}
if all_alerts and all(a.status in closed_statuses for a in all_alerts):
return True
return False
# ------------------------------------------------------------------
# Lookup helpers
# ------------------------------------------------------------------
@staticmethod
def _find_alert(alert_id: Optional[str], s: EnvState) -> Optional[SIEMAlert]:
if not alert_id:
return None
for a in s.observation.active_alerts + s.observation.acknowledged_alerts:
if a.alert_id == alert_id:
return a
return None
@staticmethod
def _find_host(host_id: Optional[str], s: EnvState) -> Optional[NetworkHost]:
if not host_id:
return None
for h in s.observation.hosts:
if h.host_id == host_id:
return h
return None