| |
| |
| |
| |
|
|
| """ |
| Data models for the Security Audit Environment. |
| |
| Simulates real-world VAPT (Vulnerability Assessment & Penetration Testing) |
| engagements where an AI agent audits infrastructure for security compliance. |
| """ |
|
|
| import json |
| import re |
| from typing import Any, Dict, List, Literal, Optional, Tuple |
|
|
| from openenv.core.env_server.types import Action, Observation, State |
| from pydantic import BaseModel, ConfigDict, Field, ValidationError |
|
|
|
|
| class SecurityAuditAction(Action): |
| """Action for the Security Audit environment. |
| |
| The agent interacts via tool calls — discover hosts, scan services, |
| test for vulnerabilities, submit findings, and generate reports. |
| """ |
|
|
| action_type: Literal[ |
| "list_tools", |
| "use_tool", |
| "submit_finding", |
| "spawn_subagent", |
| "return_to_parent", |
| "generate_report", |
| ] = Field(..., description="Type of action to take") |
|
|
| tool_name: Optional[str] = Field( |
| default=None, |
| description="Tool to invoke (required when action_type='use_tool')", |
| ) |
|
|
| arguments: Dict[str, Any] = Field( |
| default_factory=dict, |
| description="Tool-specific arguments", |
| ) |
|
|
|
|
| class LLMJsonAction(BaseModel): |
| """Wire JSON for one model turn, validated before ``SecurityAuditAction``. |
| |
| Unknown top-level keys are ignored so minor format drift does not fail parsing. |
| """ |
|
|
| model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) |
|
|
| action_type: Literal[ |
| "list_tools", |
| "use_tool", |
| "submit_finding", |
| "spawn_subagent", |
| "return_to_parent", |
| "generate_report", |
| ] = Field(..., description="Which environment action to take") |
| tool_name: Optional[str] = Field( |
| default=None, |
| description="Tool name when action_type is use_tool", |
| ) |
| arguments: Dict[str, Any] = Field( |
| default_factory=dict, |
| description="Arguments for use_tool or fields for submit_finding", |
| ) |
|
|
| def to_security_audit_action(self) -> SecurityAuditAction: |
| return SecurityAuditAction( |
| action_type=self.action_type, |
| tool_name=self.tool_name, |
| arguments=self.arguments, |
| ) |
|
|
|
|
| def extract_json_object_from_text(raw: str) -> Optional[Dict[str, Any]]: |
| """Return the first JSON object from model text, or None.""" |
| if not (raw and raw.strip()): |
| return None |
|
|
| text = raw.strip() |
| text = re.sub(r"```json\s*", "", text) |
| text = re.sub(r"```\s*$", "", text, flags=re.MULTILINE) |
| text = text.strip() |
|
|
| try: |
| val = json.loads(text) |
| except json.JSONDecodeError: |
| val = None |
|
|
| if isinstance(val, dict): |
| return val |
|
|
| match = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", text, re.DOTALL) |
| if match: |
| try: |
| v2 = json.loads(match.group(0)) |
| except json.JSONDecodeError: |
| return None |
| if isinstance(v2, dict): |
| return v2 |
|
|
| return None |
|
|
|
|
| def parse_llm_action_text(raw: str) -> Tuple[Optional[LLMJsonAction], Optional[str]]: |
| """Parse and validate one action from a chat message. |
| |
| Returns (model, None) on success, or (None, error_message) on failure. |
| """ |
| data = extract_json_object_from_text(raw) |
| if data is None: |
| return None, "Could not extract a JSON object from model response" |
| try: |
| return LLMJsonAction.model_validate(data), None |
| except ValidationError as exc: |
| return None, str(exc) |
|
|
|
|
| class SecurityAuditObservation(Observation): |
| """Observation returned after each step. |
| |
| Contains tool output, current discovery state, and audit progress. |
| """ |
|
|
| tool_output: str = Field( |
| default="", |
| description="Text output from the executed tool", |
| ) |
|
|
| available_tools: Optional[List[Dict[str, Any]]] = Field( |
| default=None, |
| description="List of available tools (populated by list_tools action)", |
| ) |
|
|
| discovered_hosts: List[str] = Field( |
| default_factory=list, |
| description="Hosts discovered so far", |
| ) |
|
|
| discovered_services: Dict[str, List[str]] = Field( |
| default_factory=dict, |
| description="Services discovered per host (host → [service descriptions])", |
| ) |
|
|
| findings_submitted: int = Field( |
| default=0, |
| description="Number of findings submitted so far", |
| ) |
|
|
| steps_remaining: int = Field( |
| default=0, |
| description="Steps remaining before episode ends", |
| ) |
|
|
| message: str = Field( |
| default="", |
| description="Human-readable status message", |
| ) |
|
|
| truncated: bool = Field( |
| default=False, |
| description="True if episode ended due to step limit (truncation), " |
| "False if agent called generate_report (termination). " |
| "Important for RL value function estimation.", |
| ) |
|
|
| current_phase: str = Field( |
| default="reconnaissance", |
| description="Current audit phase: reconnaissance, enumeration, exploitation, or reporting", |
| ) |
|
|
|
|
| class SecurityAuditState(State): |
| """Full episode state for the security audit. |
| |
| Extends base State (episode_id, step_count) with audit-specific tracking. |
| """ |
|
|
| scenario_id: str = Field(default="", description="Current scenario identifier") |
| scenario_name: str = Field(default="", description="Human-readable scenario name") |
| target_network: str = Field(default="", description="Target network CIDR") |
| max_steps: int = Field(default=50, description="Maximum steps allowed") |
| discovered_hosts: List[str] = Field(default_factory=list) |
| discovered_ports: Dict[str, List[int]] = Field(default_factory=dict) |
| discovered_services: Dict[str, List[str]] = Field(default_factory=dict) |
| submitted_findings: List[Dict[str, Any]] = Field(default_factory=list) |
| total_reward: float = Field(default=0.0) |
|
|