Spaces:
Paused
Major: Self-evolving RL engine + OpenAI-compatible API simplification
Browse filesSelf-Evolving Environment Engine (self_evolving/):
- α-Curriculum Reward from GenEnv: R_env(p̂) = -|p̂ - α|, targets zone of
proximal development (α≈0.5 success rate)
- POET-inspired mutation operators with adaptive σ (Gaussian mutation,
uniform crossover on scenario genomes)
- Fitness-proportionate selection with novelty search bonus
- Elo rating system for agent and scenario difficulty calibration
- Procedural scenario generator with 6 attack archetypes:
phishing, lateral movement, insider threat, ransomware, supply chain, APT
- ScenarioGenome: 15-parameter genetic representation with 5-dimensional
difficulty vector (evidence_obscurity, ioc_complexity, containment_complexity,
report_detail_required, time_pressure)
- Population-based evolution with elite preservation and archive
Environment Integration:
- task_id='evolved' activates self-evolving mode
- /env/evolve endpoint triggers population evolution
- /env/evolution-stats returns generation, Elo, difficulty range
- Performance auto-recorded after grading for fitness evaluation
- All 6 existing static tasks unchanged (mean baseline: 0.9442)
API Simplification:
- Replaced multi-provider auto-detection (OPENROUTER_API_KEY, ANTHROPIC_API_KEY,
LLM_API_KEY) with simple OpenAI-compatible pattern
- OPENAI_API_KEY + optional OPENAI_BASE_URL (defaults to api.openai.com/v1)
- Works with any OpenAI-compatible provider via --api-base flag
- README.md +36 -15
- baseline_inference.py +44 -65
- self_evolving/__init__.py +42 -0
- self_evolving/evolution_engine.py +589 -0
- self_evolving/scenario_generator.py +740 -0
- server/app.py +21 -0
- server/incident_response_env_environment.py +132 -22
|
@@ -37,6 +37,26 @@ The agent receives a cybersecurity alert and must:
|
|
| 37 |
- **Anti-loop penalties**: Repeated identical actions receive increasing penalties, encouraging diverse investigation strategies.
|
| 38 |
- **Phase discipline**: Agents are rewarded for following proper IR workflow (investigate before classify, classify before contain) and penalized for phase violations.
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
---
|
| 41 |
|
| 42 |
## Action / Observation Space
|
|
@@ -257,35 +277,30 @@ OPENAI_API_KEY=sk-... python baseline_inference.py --verbose
|
|
| 257 |
|
| 258 |
### Supported LLM Providers
|
| 259 |
|
| 260 |
-
The baseline inference script works with **any OpenAI-compatible API**.
|
| 261 |
|
| 262 |
```bash
|
| 263 |
-
# OpenAI (default)
|
| 264 |
OPENAI_API_KEY=sk-... python baseline_inference.py --model gpt-4o-mini
|
| 265 |
|
| 266 |
# OpenRouter — access 200+ models (Gemini, Claude, Llama, Mistral, etc.)
|
| 267 |
-
|
| 268 |
|
| 269 |
# Anthropic (OpenAI-compatible endpoint)
|
| 270 |
-
|
| 271 |
|
| 272 |
# Local models via Ollama
|
| 273 |
python baseline_inference.py --api-base http://localhost:11434/v1 --api-key dummy --model llama3
|
| 274 |
|
| 275 |
-
# Any OpenAI-compatible provider
|
| 276 |
python baseline_inference.py --api-key YOUR_KEY --api-base https://your-provider/v1 --model your-model
|
| 277 |
-
|
| 278 |
-
# Universal override (works with any provider)
|
| 279 |
-
LLM_API_KEY=... LLM_API_BASE=https://provider/v1 python baseline_inference.py --model model-name
|
| 280 |
```
|
| 281 |
|
| 282 |
-
**Environment variables**
|
| 283 |
-
| Variable |
|
| 284 |
-
|----------|----------
|
| 285 |
-
| `
|
| 286 |
-
| `
|
| 287 |
-
| `ANTHROPIC_API_KEY` | Anthropic | `https://api.anthropic.com/v1` |
|
| 288 |
-
| `OPENAI_API_KEY` | OpenAI | `https://api.openai.com/v1` |
|
| 289 |
|
| 290 |
### Docker
|
| 291 |
|
|
@@ -326,6 +341,8 @@ openenv push
|
|
| 326 |
| `/tasks` | GET | List all 6 tasks with descriptions and action schema |
|
| 327 |
| `/grader` | GET | Get grader score after episode completion |
|
| 328 |
| `/baseline` | POST | Run deterministic baseline on all 6 tasks |
|
|
|
|
|
|
|
| 329 |
|
| 330 |
### Example Interaction
|
| 331 |
|
|
@@ -401,6 +418,10 @@ incident-response-env/
|
|
| 401 |
│ ├── task_medium_ransomware.py # Medium-Hard: Ransomware deployment
|
| 402 |
│ ├── task_hard_supply_chain.py # Hard-Plus: Supply chain compromise
|
| 403 |
│ └── task_expert_apt_zeroday.py # Expert: APT with zero-day exploitation
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
├── client.py # EnvClient implementation
|
| 405 |
├── openenv.yaml # OpenEnv configuration
|
| 406 |
├── pyproject.toml # Python project metadata
|
|
|
|
| 37 |
- **Anti-loop penalties**: Repeated identical actions receive increasing penalties, encouraging diverse investigation strategies.
|
| 38 |
- **Phase discipline**: Agents are rewarded for following proper IR workflow (investigate before classify, classify before contain) and penalized for phase violations.
|
| 39 |
|
| 40 |
+
### Self-Evolving Environment (Unique Feature)
|
| 41 |
+
|
| 42 |
+
This environment features a **self-evolving scenario generation engine** that creates an open-ended curriculum of cybersecurity incidents:
|
| 43 |
+
|
| 44 |
+
- **α-Curriculum Reward**: Automatically generates scenarios in the agent's "zone of proximal development" (target success rate ~50%)
|
| 45 |
+
- **POET-inspired Evolution**: Population of scenario genomes with mutation, crossover, and fitness-proportionate selection
|
| 46 |
+
- **Novelty Search**: Diversity pressure ensures the environment explores the full space of possible incidents
|
| 47 |
+
- **Elo Rating System**: Both agent and scenarios are rated, providing natural difficulty calibration
|
| 48 |
+
- **Procedural Generation**: 6 attack archetypes (phishing, lateral movement, insider threat, ransomware, supply chain, APT) with parameterized complexity
|
| 49 |
+
|
| 50 |
+
Use `task_id="evolved"` to activate self-evolving mode:
|
| 51 |
+
```python
|
| 52 |
+
# Activate self-evolving mode
|
| 53 |
+
obs = env.reset(task_id="evolved")
|
| 54 |
+
# ... run agent ...
|
| 55 |
+
# Trigger evolution based on performance
|
| 56 |
+
requests.post(f"{base_url}/env/evolve")
|
| 57 |
+
stats = requests.get(f"{base_url}/env/evolution-stats").json()
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
---
|
| 61 |
|
| 62 |
## Action / Observation Space
|
|
|
|
| 277 |
|
| 278 |
### Supported LLM Providers
|
| 279 |
|
| 280 |
+
The baseline inference script works with **any OpenAI-compatible API**. Just set `OPENAI_API_KEY` and optionally `OPENAI_BASE_URL`:
|
| 281 |
|
| 282 |
```bash
|
| 283 |
+
# OpenAI (default — just set API key)
|
| 284 |
OPENAI_API_KEY=sk-... python baseline_inference.py --model gpt-4o-mini
|
| 285 |
|
| 286 |
# OpenRouter — access 200+ models (Gemini, Claude, Llama, Mistral, etc.)
|
| 287 |
+
OPENAI_API_KEY=sk-or-... OPENAI_BASE_URL=https://openrouter.ai/api/v1 python baseline_inference.py --model google/gemini-2.5-flash
|
| 288 |
|
| 289 |
# Anthropic (OpenAI-compatible endpoint)
|
| 290 |
+
OPENAI_API_KEY=sk-ant-... OPENAI_BASE_URL=https://api.anthropic.com/v1 python baseline_inference.py --model claude-sonnet-4-20250514
|
| 291 |
|
| 292 |
# Local models via Ollama
|
| 293 |
python baseline_inference.py --api-base http://localhost:11434/v1 --api-key dummy --model llama3
|
| 294 |
|
| 295 |
+
# Any OpenAI-compatible provider (explicit flags)
|
| 296 |
python baseline_inference.py --api-key YOUR_KEY --api-base https://your-provider/v1 --model your-model
|
|
|
|
|
|
|
|
|
|
| 297 |
```
|
| 298 |
|
| 299 |
+
**Environment variables:**
|
| 300 |
+
| Variable | Description |
|
| 301 |
+
|----------|-------------|
|
| 302 |
+
| `OPENAI_API_KEY` | API key (required) |
|
| 303 |
+
| `OPENAI_BASE_URL` | API base URL (optional, defaults to `https://api.openai.com/v1`) |
|
|
|
|
|
|
|
| 304 |
|
| 305 |
### Docker
|
| 306 |
|
|
|
|
| 341 |
| `/tasks` | GET | List all 6 tasks with descriptions and action schema |
|
| 342 |
| `/grader` | GET | Get grader score after episode completion |
|
| 343 |
| `/baseline` | POST | Run deterministic baseline on all 6 tasks |
|
| 344 |
+
| `/env/evolve` | POST | Trigger evolution of the scenario population |
|
| 345 |
+
| `/env/evolution-stats` | GET | Get evolution engine statistics |
|
| 346 |
|
| 347 |
### Example Interaction
|
| 348 |
|
|
|
|
| 418 |
│ ├── task_medium_ransomware.py # Medium-Hard: Ransomware deployment
|
| 419 |
│ ├── task_hard_supply_chain.py # Hard-Plus: Supply chain compromise
|
| 420 |
│ └── task_expert_apt_zeroday.py # Expert: APT with zero-day exploitation
|
| 421 |
+
├── self_evolving/ # Self-evolving scenario generation engine
|
| 422 |
+
│ ├── __init__.py # Package exports
|
| 423 |
+
│ ├── evolution_engine.py # α-Curriculum, POET mutations, Elo ratings
|
| 424 |
+
│ └── scenario_generator.py # Procedural scenario generation from genomes
|
| 425 |
├── client.py # EnvClient implementation
|
| 426 |
├── openenv.yaml # OpenEnv configuration
|
| 427 |
├── pyproject.toml # Python project metadata
|
|
@@ -11,7 +11,7 @@ and produces reproducible baseline scores. It demonstrates how an AI agent
|
|
| 11 |
interacts with the environment through the step/reset/state API.
|
| 12 |
|
| 13 |
Supports ANY OpenAI-compatible LLM provider via the --api-base flag or
|
| 14 |
-
|
| 15 |
|
| 16 |
Architecture:
|
| 17 |
This agent implements a reasoning loop with state-goal reflection,
|
|
@@ -32,29 +32,21 @@ Architecture:
|
|
| 32 |
in the prompt context to avoid wasting steps on duplicate actions.
|
| 33 |
|
| 34 |
Usage:
|
| 35 |
-
# OpenAI (default):
|
| 36 |
OPENAI_API_KEY=sk-... python baseline_inference.py
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
# Any provider via explicit flags:
|
| 45 |
-
python baseline_inference.py --api-key KEY --api-base https://my-provider/v1 --model my-model
|
| 46 |
|
| 47 |
# Local models (Ollama, vLLM, etc.):
|
| 48 |
python baseline_inference.py --api-base http://localhost:11434/v1 --api-key dummy --model llama3
|
| 49 |
|
| 50 |
-
|
| 51 |
-
OPENAI_API_KEY
|
| 52 |
-
|
| 53 |
-
Environment Variables (checked in order):
|
| 54 |
-
LLM_API_KEY + LLM_API_BASE : Universal override for any provider
|
| 55 |
-
OPENROUTER_API_KEY : OpenRouter (https://openrouter.ai/api/v1)
|
| 56 |
-
ANTHROPIC_API_KEY : Anthropic (https://api.anthropic.com/v1)
|
| 57 |
-
OPENAI_API_KEY : OpenAI (https://api.openai.com/v1) [default]
|
| 58 |
|
| 59 |
Output:
|
| 60 |
Prints scores for each task and aggregate results.
|
|
@@ -752,61 +744,48 @@ def run_llm_agent(
|
|
| 752 |
|
| 753 |
|
| 754 |
# =============================================================================
|
| 755 |
-
#
|
| 756 |
# =============================================================================
|
| 757 |
-
|
| 758 |
-
#
|
| 759 |
-
|
| 760 |
-
("LLM_API_KEY", os.environ.get("LLM_API_BASE", ""), "Custom"),
|
| 761 |
-
("OPENROUTER_API_KEY", "https://openrouter.ai/api/v1", "OpenRouter"),
|
| 762 |
-
("ANTHROPIC_API_KEY", "https://api.anthropic.com/v1", "Anthropic"),
|
| 763 |
-
("OPENAI_API_KEY", "https://api.openai.com/v1", "OpenAI"),
|
| 764 |
-
]
|
| 765 |
|
| 766 |
|
| 767 |
def resolve_llm_config(
|
| 768 |
cli_api_key: Optional[str] = None,
|
| 769 |
cli_api_base: Optional[str] = None,
|
| 770 |
-
) -> Tuple[str, str
|
| 771 |
"""
|
| 772 |
-
Resolve LLM API key
|
| 773 |
|
| 774 |
Priority:
|
| 775 |
-
1.
|
| 776 |
-
2.
|
| 777 |
|
| 778 |
-
|
| 779 |
-
(api_key, api_base, provider_name)
|
| 780 |
|
| 781 |
-
|
| 782 |
-
|
| 783 |
"""
|
| 784 |
-
# 1. CLI flags
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
print(" OPENAI_API_KEY - for OpenAI models")
|
| 804 |
-
print(" OPENROUTER_API_KEY - for OpenRouter (any model)")
|
| 805 |
-
print(" ANTHROPIC_API_KEY - for Anthropic models")
|
| 806 |
-
print(" LLM_API_KEY - for any provider (set LLM_API_BASE too)")
|
| 807 |
-
print()
|
| 808 |
-
print("Or pass explicitly: --api-key YOUR_KEY --api-base https://provider/v1")
|
| 809 |
-
sys.exit(1)
|
| 810 |
|
| 811 |
|
| 812 |
def main():
|
|
@@ -849,17 +828,17 @@ def main():
|
|
| 849 |
)
|
| 850 |
args = parser.parse_args()
|
| 851 |
|
| 852 |
-
# Resolve LLM provider
|
| 853 |
-
api_key, api_base
|
| 854 |
cli_api_key=args.api_key,
|
| 855 |
cli_api_base=args.api_base,
|
| 856 |
)
|
| 857 |
|
| 858 |
client = OpenAI(api_key=api_key, base_url=api_base)
|
| 859 |
|
| 860 |
-
# Build extra headers for
|
| 861 |
extra_headers: Optional[Dict[str, str]] = None
|
| 862 |
-
if
|
| 863 |
extra_headers = {
|
| 864 |
"HTTP-Referer": "https://github.com/incident-response-env",
|
| 865 |
"X-Title": "Incident Response Triage Environment",
|
|
@@ -867,7 +846,7 @@ def main():
|
|
| 867 |
|
| 868 |
print("=" * 60)
|
| 869 |
print("Incident Response Triage - Baseline Inference")
|
| 870 |
-
print(f"
|
| 871 |
print(f"Server: {args.base_url}")
|
| 872 |
print(f"Model: {args.model}")
|
| 873 |
print(f"Tasks: {args.tasks}")
|
|
|
|
| 11 |
interacts with the environment through the step/reset/state API.
|
| 12 |
|
| 13 |
Supports ANY OpenAI-compatible LLM provider via the --api-base flag or
|
| 14 |
+
environment variables.
|
| 15 |
|
| 16 |
Architecture:
|
| 17 |
This agent implements a reasoning loop with state-goal reflection,
|
|
|
|
| 32 |
in the prompt context to avoid wasting steps on duplicate actions.
|
| 33 |
|
| 34 |
Usage:
|
| 35 |
+
# OpenAI (default — just set API key):
|
| 36 |
OPENAI_API_KEY=sk-... python baseline_inference.py
|
| 37 |
|
| 38 |
+
# Any OpenAI-compatible provider (set base URL):
|
| 39 |
+
OPENAI_API_KEY=sk-or-... OPENAI_BASE_URL=https://openrouter.ai/api/v1 python baseline_inference.py --model google/gemini-2.5-flash
|
| 40 |
|
| 41 |
+
# Explicit flags:
|
| 42 |
+
python baseline_inference.py --api-key KEY --api-base https://provider/v1 --model my-model
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
# Local models (Ollama, vLLM, etc.):
|
| 45 |
python baseline_inference.py --api-base http://localhost:11434/v1 --api-key dummy --model llama3
|
| 46 |
|
| 47 |
+
Environment Variables:
|
| 48 |
+
OPENAI_API_KEY : API key (required)
|
| 49 |
+
OPENAI_BASE_URL : API base URL (optional, defaults to https://api.openai.com/v1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
Output:
|
| 52 |
Prints scores for each task and aggregate results.
|
|
|
|
| 744 |
|
| 745 |
|
| 746 |
# =============================================================================
|
| 747 |
+
# Configuration — OpenAI-Compatible API
|
| 748 |
# =============================================================================
|
| 749 |
+
# Simple: provide an API key + optional base URL.
|
| 750 |
+
# If only API key given → defaults to OpenAI (https://api.openai.com/v1)
|
| 751 |
+
# If base URL also given → uses that provider (OpenRouter, Anthropic, local, etc.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
|
| 753 |
|
| 754 |
def resolve_llm_config(
|
| 755 |
cli_api_key: Optional[str] = None,
|
| 756 |
cli_api_base: Optional[str] = None,
|
| 757 |
+
) -> Tuple[str, str]:
|
| 758 |
"""
|
| 759 |
+
Resolve LLM API key and base URL.
|
| 760 |
|
| 761 |
Priority:
|
| 762 |
+
1. CLI flags (--api-key, --api-base)
|
| 763 |
+
2. Environment variables (OPENAI_API_KEY + OPENAI_BASE_URL)
|
| 764 |
|
| 765 |
+
If only an API key is given (no base URL), defaults to https://api.openai.com/v1
|
|
|
|
| 766 |
|
| 767 |
+
Returns:
|
| 768 |
+
(api_key, api_base)
|
| 769 |
"""
|
| 770 |
+
# 1. CLI flags
|
| 771 |
+
api_key = cli_api_key or os.environ.get("OPENAI_API_KEY", "")
|
| 772 |
+
api_base = cli_api_base or os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 773 |
+
|
| 774 |
+
if not api_key:
|
| 775 |
+
print("ERROR: No API key found.")
|
| 776 |
+
print()
|
| 777 |
+
print("Provide an OpenAI-compatible API key:")
|
| 778 |
+
print(" OPENAI_API_KEY=sk-... python baseline_inference.py")
|
| 779 |
+
print()
|
| 780 |
+
print("For other providers, also set the base URL:")
|
| 781 |
+
print(" OPENAI_API_KEY=sk-or-... OPENAI_BASE_URL=https://openrouter.ai/api/v1 python baseline_inference.py")
|
| 782 |
+
print(" OPENAI_API_KEY=sk-ant-... OPENAI_BASE_URL=https://api.anthropic.com/v1 python baseline_inference.py")
|
| 783 |
+
print()
|
| 784 |
+
print("Or pass explicitly:")
|
| 785 |
+
print(" python baseline_inference.py --api-key KEY --api-base https://provider/v1")
|
| 786 |
+
sys.exit(1)
|
| 787 |
+
|
| 788 |
+
return api_key, api_base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
|
| 790 |
|
| 791 |
def main():
|
|
|
|
| 828 |
)
|
| 829 |
args = parser.parse_args()
|
| 830 |
|
| 831 |
+
# Resolve LLM provider (OpenAI-compatible)
|
| 832 |
+
api_key, api_base = resolve_llm_config(
|
| 833 |
cli_api_key=args.api_key,
|
| 834 |
cli_api_base=args.api_base,
|
| 835 |
)
|
| 836 |
|
| 837 |
client = OpenAI(api_key=api_key, base_url=api_base)
|
| 838 |
|
| 839 |
+
# Build extra headers for OpenRouter if detected
|
| 840 |
extra_headers: Optional[Dict[str, str]] = None
|
| 841 |
+
if "openrouter.ai" in api_base:
|
| 842 |
extra_headers = {
|
| 843 |
"HTTP-Referer": "https://github.com/incident-response-env",
|
| 844 |
"X-Title": "Incident Response Triage Environment",
|
|
|
|
| 846 |
|
| 847 |
print("=" * 60)
|
| 848 |
print("Incident Response Triage - Baseline Inference")
|
| 849 |
+
print(f"API: {api_base}")
|
| 850 |
print(f"Server: {args.base_url}")
|
| 851 |
print(f"Model: {args.model}")
|
| 852 |
print(f"Tasks: {args.tasks}")
|
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) 2026 - OpenEnv Hackathon Submission
|
| 2 |
+
# Incident Response Triage Environment - Self-Evolving Engine Package
|
| 3 |
+
# BSD-3-Clause License
|
| 4 |
+
|
| 5 |
+
"""
|
| 6 |
+
Self-Evolving Scenario Generation Engine.
|
| 7 |
+
|
| 8 |
+
Exports the core evolution engine classes and scenario generator
|
| 9 |
+
for procedurally generating, mutating, and adapting cybersecurity
|
| 10 |
+
scenarios based on agent performance.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from .evolution_engine import (
|
| 15 |
+
AgentPerformanceRecord,
|
| 16 |
+
EvolutionEngine,
|
| 17 |
+
EvolutionState,
|
| 18 |
+
FitnessEvaluator,
|
| 19 |
+
MutationOperator,
|
| 20 |
+
ScenarioGenome,
|
| 21 |
+
)
|
| 22 |
+
from .scenario_generator import ScenarioGenerator
|
| 23 |
+
except ImportError:
|
| 24 |
+
from self_evolving.evolution_engine import (
|
| 25 |
+
AgentPerformanceRecord,
|
| 26 |
+
EvolutionEngine,
|
| 27 |
+
EvolutionState,
|
| 28 |
+
FitnessEvaluator,
|
| 29 |
+
MutationOperator,
|
| 30 |
+
ScenarioGenome,
|
| 31 |
+
)
|
| 32 |
+
from self_evolving.scenario_generator import ScenarioGenerator
|
| 33 |
+
|
| 34 |
+
__all__ = [
|
| 35 |
+
"AgentPerformanceRecord",
|
| 36 |
+
"EvolutionEngine",
|
| 37 |
+
"EvolutionState",
|
| 38 |
+
"FitnessEvaluator",
|
| 39 |
+
"MutationOperator",
|
| 40 |
+
"ScenarioGenome",
|
| 41 |
+
"ScenarioGenerator",
|
| 42 |
+
]
|
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Self-Evolving Scenario Generation Engine
|
| 3 |
+
|
| 4 |
+
Implements:
|
| 5 |
+
1. α-Curriculum Reward (from GenEnv): R_env(p̂) = -|p̂ - α| where α≈0.5
|
| 6 |
+
Rewards environment for generating scenarios in agent's "zone of proximal development"
|
| 7 |
+
2. POET-inspired mutation operators: parametric mutation of scenario attributes
|
| 8 |
+
3. Fitness-proportionate selection with novelty bonus
|
| 9 |
+
4. Difficulty calibration via Elo-like rating system
|
| 10 |
+
|
| 11 |
+
Mathematical Framework:
|
| 12 |
+
- Each scenario S has a difficulty vector d ∈ R^k (k dimensions of difficulty)
|
| 13 |
+
- Agent competence vector c ∈ R^k estimated from performance history
|
| 14 |
+
- α-Curriculum: optimal scenario difficulty where P(agent solves | S) ≈ α
|
| 15 |
+
- Mutation: S' = mutate(S, σ) where σ is mutation strength adapted by fitness
|
| 16 |
+
- Fitness: F(S) = -|success_rate(S) - α| + λ * novelty(S)
|
| 17 |
+
- Novelty: measured as distance to k-nearest scenarios in behavior space
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import copy
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import math
|
| 24 |
+
import random
|
| 25 |
+
from dataclasses import dataclass, field
|
| 26 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 27 |
+
|
| 28 |
+
# IMPORTANT: Use relative imports that work both ways
|
| 29 |
+
try:
|
| 30 |
+
from ..tasks.base import (
|
| 31 |
+
EndpointInfo, LogEntry, Scenario, ThreatIntelEntry, UserProfile
|
| 32 |
+
)
|
| 33 |
+
from ..models import ContainmentAction, Severity, ThreatCategory
|
| 34 |
+
except ImportError:
|
| 35 |
+
import sys, os
|
| 36 |
+
_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 37 |
+
if _parent not in sys.path:
|
| 38 |
+
sys.path.insert(0, _parent)
|
| 39 |
+
from tasks.base import (
|
| 40 |
+
EndpointInfo, LogEntry, Scenario, ThreatIntelEntry, UserProfile
|
| 41 |
+
)
|
| 42 |
+
from models import ContainmentAction, Severity, ThreatCategory
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 46 |
+
# Data Structures
|
| 47 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class ScenarioGenome:
|
| 51 |
+
"""
|
| 52 |
+
Genetic representation of a scenario for evolution.
|
| 53 |
+
Maps scenario parameters to a mutable genome vector.
|
| 54 |
+
"""
|
| 55 |
+
# Core parameters (these get mutated)
|
| 56 |
+
num_log_entries: int = 12 # How many log entries (complexity)
|
| 57 |
+
num_critical_evidence: int = 5 # Critical evidence items to find
|
| 58 |
+
num_iocs: int = 4 # Number of IOCs
|
| 59 |
+
num_endpoints: int = 3 # Network endpoints
|
| 60 |
+
num_users: int = 2 # User profiles
|
| 61 |
+
num_threat_intel: int = 3 # Threat intel entries
|
| 62 |
+
num_containment_targets: int = 3 # Things to contain
|
| 63 |
+
max_steps: int = 25 # Steps allowed
|
| 64 |
+
noise_ratio: float = 0.3 # Ratio of noise/decoy evidence
|
| 65 |
+
correlation_depth: int = 2 # How many cross-source correlations
|
| 66 |
+
escalation_required: bool = True
|
| 67 |
+
multi_stage_attack: bool = False # Whether attack has multiple phases
|
| 68 |
+
|
| 69 |
+
# Difficulty dimensions (0.0 = easiest, 1.0 = hardest)
|
| 70 |
+
evidence_obscurity: float = 0.3 # How hidden is critical evidence
|
| 71 |
+
ioc_complexity: float = 0.3 # Sophistication of IOCs
|
| 72 |
+
containment_complexity: float = 0.3 # Number/precision of containment
|
| 73 |
+
report_detail_required: float = 0.3 # How detailed report must be
|
| 74 |
+
time_pressure: float = 0.3 # Steps vs required actions ratio
|
| 75 |
+
|
| 76 |
+
# Metadata
|
| 77 |
+
generation: int = 0
|
| 78 |
+
parent_id: Optional[str] = None
|
| 79 |
+
genome_id: str = ""
|
| 80 |
+
|
| 81 |
+
def __post_init__(self):
|
| 82 |
+
if not self.genome_id:
|
| 83 |
+
self.genome_id = hashlib.md5(
|
| 84 |
+
json.dumps(self.__dict__, default=str).encode()
|
| 85 |
+
).hexdigest()[:12]
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def difficulty_vector(self) -> List[float]:
|
| 89 |
+
"""k-dimensional difficulty vector."""
|
| 90 |
+
return [
|
| 91 |
+
self.evidence_obscurity,
|
| 92 |
+
self.ioc_complexity,
|
| 93 |
+
self.containment_complexity,
|
| 94 |
+
self.report_detail_required,
|
| 95 |
+
self.time_pressure,
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def aggregate_difficulty(self) -> float:
|
| 100 |
+
"""Single scalar difficulty score in [0, 1]."""
|
| 101 |
+
return sum(self.difficulty_vector) / len(self.difficulty_vector)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class AgentPerformanceRecord:
|
| 106 |
+
"""Tracks agent performance for fitness evaluation."""
|
| 107 |
+
scenario_id: str
|
| 108 |
+
genome_id: str
|
| 109 |
+
score: float
|
| 110 |
+
steps_used: int
|
| 111 |
+
max_steps: int
|
| 112 |
+
evidence_found_ratio: float
|
| 113 |
+
iocs_found_ratio: float
|
| 114 |
+
correct_severity: bool
|
| 115 |
+
correct_category: bool
|
| 116 |
+
containment_score: float
|
| 117 |
+
report_quality: float
|
| 118 |
+
timestamp: float = 0.0
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass
|
| 122 |
+
class EvolutionState:
|
| 123 |
+
"""Persistent state of the evolution engine."""
|
| 124 |
+
generation: int = 0
|
| 125 |
+
population: List[ScenarioGenome] = field(default_factory=list)
|
| 126 |
+
archive: List[ScenarioGenome] = field(default_factory=list) # Hall of fame
|
| 127 |
+
performance_history: List[AgentPerformanceRecord] = field(default_factory=list)
|
| 128 |
+
agent_elo: float = 1000.0 # Agent Elo rating
|
| 129 |
+
scenario_elos: Dict[str, float] = field(default_factory=dict) # Per-scenario Elo
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 133 |
+
# Mutation Operators
|
| 134 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 135 |
+
|
| 136 |
+
class MutationOperator:
|
| 137 |
+
"""
|
| 138 |
+
POET-inspired parametric mutation operators for scenario genomes.
|
| 139 |
+
|
| 140 |
+
Mutations are applied with adaptive strength σ based on the
|
| 141 |
+
α-curriculum signal: if scenarios are too easy, increase difficulty;
|
| 142 |
+
if too hard, decrease.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
# Bounds for genome parameters
|
| 146 |
+
PARAM_BOUNDS = {
|
| 147 |
+
'num_log_entries': (6, 30),
|
| 148 |
+
'num_critical_evidence': (3, 12),
|
| 149 |
+
'num_iocs': (2, 10),
|
| 150 |
+
'num_endpoints': (2, 8),
|
| 151 |
+
'num_users': (1, 5),
|
| 152 |
+
'num_threat_intel': (2, 8),
|
| 153 |
+
'num_containment_targets': (2, 8),
|
| 154 |
+
'max_steps': (15, 40),
|
| 155 |
+
'noise_ratio': (0.0, 0.6),
|
| 156 |
+
'correlation_depth': (1, 5),
|
| 157 |
+
'evidence_obscurity': (0.0, 1.0),
|
| 158 |
+
'ioc_complexity': (0.0, 1.0),
|
| 159 |
+
'containment_complexity': (0.0, 1.0),
|
| 160 |
+
'report_detail_required': (0.0, 1.0),
|
| 161 |
+
'time_pressure': (0.0, 1.0),
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
@staticmethod
|
| 165 |
+
def mutate(genome: ScenarioGenome, sigma: float = 0.15) -> ScenarioGenome:
|
| 166 |
+
"""
|
| 167 |
+
Apply Gaussian mutation to genome parameters.
|
| 168 |
+
|
| 169 |
+
σ (sigma) controls mutation strength:
|
| 170 |
+
- Higher σ → more exploration (when agent is in comfort zone)
|
| 171 |
+
- Lower σ → fine-tuning (when near optimal difficulty)
|
| 172 |
+
"""
|
| 173 |
+
child = copy.deepcopy(genome)
|
| 174 |
+
child.generation = genome.generation + 1
|
| 175 |
+
child.parent_id = genome.genome_id
|
| 176 |
+
|
| 177 |
+
# Mutate numeric parameters with Gaussian noise
|
| 178 |
+
for param, (lo, hi) in MutationOperator.PARAM_BOUNDS.items():
|
| 179 |
+
current = getattr(child, param)
|
| 180 |
+
if isinstance(current, float):
|
| 181 |
+
noise = random.gauss(0, sigma * (hi - lo))
|
| 182 |
+
new_val = max(lo, min(hi, current + noise))
|
| 183 |
+
setattr(child, param, round(new_val, 3))
|
| 184 |
+
elif isinstance(current, int):
|
| 185 |
+
noise = random.gauss(0, sigma * (hi - lo))
|
| 186 |
+
new_val = max(lo, min(hi, round(current + noise)))
|
| 187 |
+
setattr(child, param, int(new_val))
|
| 188 |
+
|
| 189 |
+
# Flip boolean traits with small probability
|
| 190 |
+
if random.random() < 0.15 * sigma:
|
| 191 |
+
child.escalation_required = not child.escalation_required
|
| 192 |
+
if random.random() < 0.15 * sigma:
|
| 193 |
+
child.multi_stage_attack = not child.multi_stage_attack
|
| 194 |
+
|
| 195 |
+
# Regenerate ID
|
| 196 |
+
child.genome_id = hashlib.md5(
|
| 197 |
+
json.dumps(child.__dict__, default=str).encode()
|
| 198 |
+
).hexdigest()[:12]
|
| 199 |
+
|
| 200 |
+
return child
|
| 201 |
+
|
| 202 |
+
@staticmethod
|
| 203 |
+
def crossover(parent_a: ScenarioGenome, parent_b: ScenarioGenome) -> ScenarioGenome:
|
| 204 |
+
"""Uniform crossover between two parent genomes."""
|
| 205 |
+
child = copy.deepcopy(parent_a)
|
| 206 |
+
child.generation = max(parent_a.generation, parent_b.generation) + 1
|
| 207 |
+
child.parent_id = f"{parent_a.genome_id}x{parent_b.genome_id}"
|
| 208 |
+
|
| 209 |
+
for param in MutationOperator.PARAM_BOUNDS:
|
| 210 |
+
if random.random() < 0.5:
|
| 211 |
+
setattr(child, param, getattr(parent_b, param))
|
| 212 |
+
|
| 213 |
+
if random.random() < 0.5:
|
| 214 |
+
child.escalation_required = parent_b.escalation_required
|
| 215 |
+
if random.random() < 0.5:
|
| 216 |
+
child.multi_stage_attack = parent_b.multi_stage_attack
|
| 217 |
+
|
| 218 |
+
child.genome_id = hashlib.md5(
|
| 219 |
+
json.dumps(child.__dict__, default=str).encode()
|
| 220 |
+
).hexdigest()[:12]
|
| 221 |
+
return child
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 225 |
+
# Fitness & Selection
|
| 226 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 227 |
+
|
| 228 |
+
class FitnessEvaluator:
|
| 229 |
+
"""
|
| 230 |
+
Evaluates scenario fitness using α-Curriculum reward.
|
| 231 |
+
|
| 232 |
+
Core formula: F(S) = -|p̂(S) - α| + λ * novelty(S) + β * info_gain(S)
|
| 233 |
+
|
| 234 |
+
Where:
|
| 235 |
+
- p̂(S) = estimated agent success probability on scenario S
|
| 236 |
+
- α = target success rate (0.5 = zone of proximal development)
|
| 237 |
+
- novelty(S) = average distance to k-nearest neighbors in archive
|
| 238 |
+
- info_gain(S) = how much new the scenario teaches the agent
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
def __init__(self, alpha: float = 0.5, lambda_novelty: float = 0.2,
|
| 242 |
+
beta_info: float = 0.1, k_nearest: int = 5):
|
| 243 |
+
self.alpha = alpha # Target success rate
|
| 244 |
+
self.lambda_novelty = lambda_novelty # Novelty weight
|
| 245 |
+
self.beta_info = beta_info # Information gain weight
|
| 246 |
+
self.k_nearest = k_nearest # For novelty computation
|
| 247 |
+
|
| 248 |
+
def compute_fitness(
|
| 249 |
+
self,
|
| 250 |
+
genome: ScenarioGenome,
|
| 251 |
+
performance_records: List[AgentPerformanceRecord],
|
| 252 |
+
archive: List[ScenarioGenome],
|
| 253 |
+
) -> float:
|
| 254 |
+
"""
|
| 255 |
+
Compute composite fitness score for a scenario genome.
|
| 256 |
+
|
| 257 |
+
Returns value in approximately [-1, 1] range.
|
| 258 |
+
Higher is better (more useful for training).
|
| 259 |
+
"""
|
| 260 |
+
# 1. α-Curriculum component
|
| 261 |
+
alpha_reward = self._alpha_curriculum_reward(genome, performance_records)
|
| 262 |
+
|
| 263 |
+
# 2. Novelty component
|
| 264 |
+
novelty = self._compute_novelty(genome, archive)
|
| 265 |
+
|
| 266 |
+
# 3. Information gain estimate
|
| 267 |
+
info_gain = self._estimate_info_gain(genome, performance_records)
|
| 268 |
+
|
| 269 |
+
fitness = alpha_reward + self.lambda_novelty * novelty + self.beta_info * info_gain
|
| 270 |
+
return fitness
|
| 271 |
+
|
| 272 |
+
def _alpha_curriculum_reward(
|
| 273 |
+
self,
|
| 274 |
+
genome: ScenarioGenome,
|
| 275 |
+
records: List[AgentPerformanceRecord],
|
| 276 |
+
) -> float:
|
| 277 |
+
"""
|
| 278 |
+
α-Curriculum: R = -|p̂ - α|
|
| 279 |
+
|
| 280 |
+
Scenarios where the agent succeeds ~50% of the time are most useful
|
| 281 |
+
for learning (zone of proximal development).
|
| 282 |
+
"""
|
| 283 |
+
# Estimate success probability from performance records
|
| 284 |
+
matching = [r for r in records if r.genome_id == genome.genome_id]
|
| 285 |
+
if not matching:
|
| 286 |
+
# No data — use difficulty as proxy
|
| 287 |
+
# Assume harder scenarios have lower success probability
|
| 288 |
+
estimated_p = 1.0 - genome.aggregate_difficulty
|
| 289 |
+
else:
|
| 290 |
+
estimated_p = sum(r.score for r in matching) / len(matching)
|
| 291 |
+
|
| 292 |
+
return -abs(estimated_p - self.alpha)
|
| 293 |
+
|
| 294 |
+
def _compute_novelty(
|
| 295 |
+
self, genome: ScenarioGenome, archive: List[ScenarioGenome]
|
| 296 |
+
) -> float:
|
| 297 |
+
"""
|
| 298 |
+
Novelty search: distance to k-nearest neighbors in difficulty space.
|
| 299 |
+
Encourages diverse scenario population.
|
| 300 |
+
"""
|
| 301 |
+
if not archive:
|
| 302 |
+
return 1.0 # Maximum novelty if archive is empty
|
| 303 |
+
|
| 304 |
+
gv = genome.difficulty_vector
|
| 305 |
+
distances = []
|
| 306 |
+
for other in archive:
|
| 307 |
+
ov = other.difficulty_vector
|
| 308 |
+
dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(gv, ov)))
|
| 309 |
+
distances.append(dist)
|
| 310 |
+
|
| 311 |
+
distances.sort()
|
| 312 |
+
k = min(self.k_nearest, len(distances))
|
| 313 |
+
avg_dist = sum(distances[:k]) / k if k > 0 else 0.0
|
| 314 |
+
|
| 315 |
+
# Normalize to [0, 1] (max possible distance in unit hypercube is sqrt(k_dims))
|
| 316 |
+
max_dist = math.sqrt(len(gv))
|
| 317 |
+
return min(avg_dist / max_dist, 1.0)
|
| 318 |
+
|
| 319 |
+
def _estimate_info_gain(
|
| 320 |
+
self,
|
| 321 |
+
genome: ScenarioGenome,
|
| 322 |
+
records: List[AgentPerformanceRecord],
|
| 323 |
+
) -> float:
|
| 324 |
+
"""
|
| 325 |
+
Estimate how much new information a scenario provides.
|
| 326 |
+
Scenarios that expose agent weaknesses score higher.
|
| 327 |
+
"""
|
| 328 |
+
if not records:
|
| 329 |
+
return 0.5
|
| 330 |
+
|
| 331 |
+
# Look at what the agent is weak at
|
| 332 |
+
recent = records[-20:] # Last 20 episodes
|
| 333 |
+
|
| 334 |
+
weakness_dimensions = {
|
| 335 |
+
'evidence_obscurity': 1.0 - (sum(r.evidence_found_ratio for r in recent) / len(recent)),
|
| 336 |
+
'ioc_complexity': 1.0 - (sum(r.iocs_found_ratio for r in recent) / len(recent)),
|
| 337 |
+
'containment_complexity': 1.0 - (sum(r.containment_score for r in recent) / len(recent)),
|
| 338 |
+
'report_detail_required': 1.0 - (sum(r.report_quality for r in recent) / len(recent)),
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
# Scenarios that target agent weaknesses have higher info gain
|
| 342 |
+
dv = genome.difficulty_vector
|
| 343 |
+
dim_names = ['evidence_obscurity', 'ioc_complexity', 'containment_complexity',
|
| 344 |
+
'report_detail_required', 'time_pressure']
|
| 345 |
+
|
| 346 |
+
info = 0.0
|
| 347 |
+
for i, dim_name in enumerate(dim_names):
|
| 348 |
+
if dim_name in weakness_dimensions:
|
| 349 |
+
# Higher difficulty in weak dimensions = more info gain
|
| 350 |
+
info += dv[i] * weakness_dimensions[dim_name]
|
| 351 |
+
|
| 352 |
+
return info / len(dim_names) if dim_names else 0.0
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 356 |
+
# Evolution Engine (Main Class)
|
| 357 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 358 |
+
|
| 359 |
+
class EvolutionEngine:
|
| 360 |
+
"""
|
| 361 |
+
Self-evolving environment engine using POET + α-Curriculum.
|
| 362 |
+
|
| 363 |
+
Maintains a population of scenario genomes, evolves them based on
|
| 364 |
+
agent performance, and provides the next scenario to train on.
|
| 365 |
+
|
| 366 |
+
Usage:
|
| 367 |
+
engine = EvolutionEngine(population_size=10, alpha=0.5)
|
| 368 |
+
genome = engine.get_next_scenario()
|
| 369 |
+
scenario = engine.genome_to_scenario(genome)
|
| 370 |
+
# ... run agent on scenario ...
|
| 371 |
+
engine.record_performance(genome, performance_record)
|
| 372 |
+
engine.evolve() # Create next generation
|
| 373 |
+
"""
|
| 374 |
+
|
| 375 |
+
def __init__(
|
| 376 |
+
self,
|
| 377 |
+
population_size: int = 10,
|
| 378 |
+
alpha: float = 0.5,
|
| 379 |
+
mutation_sigma: float = 0.15,
|
| 380 |
+
elite_fraction: float = 0.2,
|
| 381 |
+
archive_size: int = 50,
|
| 382 |
+
):
|
| 383 |
+
self.population_size = population_size
|
| 384 |
+
self.mutation_sigma = mutation_sigma
|
| 385 |
+
self.elite_fraction = elite_fraction
|
| 386 |
+
self.archive_size = archive_size
|
| 387 |
+
|
| 388 |
+
self.fitness_evaluator = FitnessEvaluator(alpha=alpha)
|
| 389 |
+
self.state = EvolutionState()
|
| 390 |
+
|
| 391 |
+
# Initialize population with diverse seeds
|
| 392 |
+
self._initialize_population()
|
| 393 |
+
|
| 394 |
+
def _initialize_population(self):
|
| 395 |
+
"""Create initial diverse population spanning difficulty space."""
|
| 396 |
+
templates = [
|
| 397 |
+
# Easy
|
| 398 |
+
ScenarioGenome(num_log_entries=8, num_critical_evidence=3, num_iocs=2,
|
| 399 |
+
num_endpoints=2, max_steps=25, noise_ratio=0.1,
|
| 400 |
+
evidence_obscurity=0.1, ioc_complexity=0.1,
|
| 401 |
+
containment_complexity=0.1, time_pressure=0.1),
|
| 402 |
+
# Medium
|
| 403 |
+
ScenarioGenome(num_log_entries=12, num_critical_evidence=5, num_iocs=4,
|
| 404 |
+
num_endpoints=3, max_steps=25, noise_ratio=0.25,
|
| 405 |
+
evidence_obscurity=0.35, ioc_complexity=0.35,
|
| 406 |
+
containment_complexity=0.35, time_pressure=0.3),
|
| 407 |
+
# Hard
|
| 408 |
+
ScenarioGenome(num_log_entries=18, num_critical_evidence=7, num_iocs=6,
|
| 409 |
+
num_endpoints=4, max_steps=30, noise_ratio=0.4,
|
| 410 |
+
evidence_obscurity=0.6, ioc_complexity=0.6,
|
| 411 |
+
containment_complexity=0.6, time_pressure=0.5),
|
| 412 |
+
# Expert
|
| 413 |
+
ScenarioGenome(num_log_entries=25, num_critical_evidence=10, num_iocs=8,
|
| 414 |
+
num_endpoints=6, max_steps=35, noise_ratio=0.5,
|
| 415 |
+
multi_stage_attack=True,
|
| 416 |
+
evidence_obscurity=0.85, ioc_complexity=0.85,
|
| 417 |
+
containment_complexity=0.85, time_pressure=0.7),
|
| 418 |
+
]
|
| 419 |
+
|
| 420 |
+
# Fill population by mutating templates
|
| 421 |
+
self.state.population = []
|
| 422 |
+
for i in range(self.population_size):
|
| 423 |
+
template = templates[i % len(templates)]
|
| 424 |
+
if i < len(templates):
|
| 425 |
+
genome = copy.deepcopy(template)
|
| 426 |
+
else:
|
| 427 |
+
genome = MutationOperator.mutate(template, sigma=0.3)
|
| 428 |
+
genome.genome_id = hashlib.md5(
|
| 429 |
+
f"init_{i}_{json.dumps(genome.__dict__, default=str)}".encode()
|
| 430 |
+
).hexdigest()[:12]
|
| 431 |
+
self.state.population.append(genome)
|
| 432 |
+
|
| 433 |
+
def get_next_scenario_genome(self) -> ScenarioGenome:
|
| 434 |
+
"""
|
| 435 |
+
Select the next scenario genome for the agent to train on.
|
| 436 |
+
Uses fitness-proportionate selection favoring scenarios near α.
|
| 437 |
+
"""
|
| 438 |
+
if not self.state.population:
|
| 439 |
+
self._initialize_population()
|
| 440 |
+
|
| 441 |
+
# Compute fitness for each genome
|
| 442 |
+
fitnesses = []
|
| 443 |
+
for genome in self.state.population:
|
| 444 |
+
f = self.fitness_evaluator.compute_fitness(
|
| 445 |
+
genome, self.state.performance_history, self.state.archive
|
| 446 |
+
)
|
| 447 |
+
fitnesses.append(f)
|
| 448 |
+
|
| 449 |
+
# Softmax selection (temperature-based)
|
| 450 |
+
temperature = 0.5
|
| 451 |
+
max_f = max(fitnesses) if fitnesses else 0
|
| 452 |
+
exp_f = [math.exp((f - max_f) / temperature) for f in fitnesses]
|
| 453 |
+
total = sum(exp_f)
|
| 454 |
+
probs = [e / total for e in exp_f]
|
| 455 |
+
|
| 456 |
+
# Weighted random selection
|
| 457 |
+
selected = random.choices(self.state.population, weights=probs, k=1)[0]
|
| 458 |
+
return selected
|
| 459 |
+
|
| 460 |
+
def record_performance(self, genome: ScenarioGenome, record: AgentPerformanceRecord):
|
| 461 |
+
"""Record agent performance on a scenario for fitness evaluation."""
|
| 462 |
+
self.state.performance_history.append(record)
|
| 463 |
+
|
| 464 |
+
# Update Elo ratings
|
| 465 |
+
self._update_elo(genome, record)
|
| 466 |
+
|
| 467 |
+
# Keep history bounded
|
| 468 |
+
if len(self.state.performance_history) > 500:
|
| 469 |
+
self.state.performance_history = self.state.performance_history[-300:]
|
| 470 |
+
|
| 471 |
+
def evolve(self) -> List[ScenarioGenome]:
|
| 472 |
+
"""
|
| 473 |
+
Evolve the scenario population using:
|
| 474 |
+
1. Fitness evaluation
|
| 475 |
+
2. Elite preservation
|
| 476 |
+
3. Mutation + crossover
|
| 477 |
+
4. Archive update (novelty-based hall of fame)
|
| 478 |
+
|
| 479 |
+
Returns the new population.
|
| 480 |
+
"""
|
| 481 |
+
self.state.generation += 1
|
| 482 |
+
|
| 483 |
+
# Evaluate fitness
|
| 484 |
+
scored = []
|
| 485 |
+
for genome in self.state.population:
|
| 486 |
+
f = self.fitness_evaluator.compute_fitness(
|
| 487 |
+
genome, self.state.performance_history, self.state.archive
|
| 488 |
+
)
|
| 489 |
+
scored.append((genome, f))
|
| 490 |
+
|
| 491 |
+
scored.sort(key=lambda x: x[1], reverse=True)
|
| 492 |
+
|
| 493 |
+
# Elite preservation
|
| 494 |
+
n_elite = max(1, int(self.population_size * self.elite_fraction))
|
| 495 |
+
elites = [g for g, _ in scored[:n_elite]]
|
| 496 |
+
|
| 497 |
+
# Add best to archive
|
| 498 |
+
for genome, fitness in scored[:2]:
|
| 499 |
+
if len(self.state.archive) < self.archive_size:
|
| 500 |
+
self.state.archive.append(copy.deepcopy(genome))
|
| 501 |
+
elif fitness > 0: # Only archive reasonably fit scenarios
|
| 502 |
+
# Replace least novel archive member
|
| 503 |
+
self.state.archive.append(copy.deepcopy(genome))
|
| 504 |
+
if len(self.state.archive) > self.archive_size:
|
| 505 |
+
# Remove least novel
|
| 506 |
+
novelties = [
|
| 507 |
+
self.fitness_evaluator._compute_novelty(g, self.state.archive)
|
| 508 |
+
for g in self.state.archive
|
| 509 |
+
]
|
| 510 |
+
min_idx = novelties.index(min(novelties))
|
| 511 |
+
self.state.archive.pop(min_idx)
|
| 512 |
+
|
| 513 |
+
# Adaptive mutation strength
|
| 514 |
+
# If scenarios are too easy (high avg score), increase σ to find harder ones
|
| 515 |
+
# If too hard (low avg score), decrease σ to fine-tune
|
| 516 |
+
recent_scores = [r.score for r in self.state.performance_history[-20:]]
|
| 517 |
+
if recent_scores:
|
| 518 |
+
avg_score = sum(recent_scores) / len(recent_scores)
|
| 519 |
+
# σ peaks when avg_score is far from α
|
| 520 |
+
sigma = self.mutation_sigma * (1.0 + abs(avg_score - self.fitness_evaluator.alpha))
|
| 521 |
+
else:
|
| 522 |
+
sigma = self.mutation_sigma
|
| 523 |
+
|
| 524 |
+
# Generate children
|
| 525 |
+
new_population = list(elites)
|
| 526 |
+
while len(new_population) < self.population_size:
|
| 527 |
+
if random.random() < 0.7:
|
| 528 |
+
# Mutation
|
| 529 |
+
parent = random.choice(scored[:max(3, len(scored) // 2)])[0]
|
| 530 |
+
child = MutationOperator.mutate(parent, sigma=sigma)
|
| 531 |
+
else:
|
| 532 |
+
# Crossover
|
| 533 |
+
p1, p2 = random.sample(scored[:max(3, len(scored) // 2)], 2)
|
| 534 |
+
child = MutationOperator.crossover(p1[0], p2[0])
|
| 535 |
+
child = MutationOperator.mutate(child, sigma=sigma * 0.5)
|
| 536 |
+
|
| 537 |
+
new_population.append(child)
|
| 538 |
+
|
| 539 |
+
self.state.population = new_population[:self.population_size]
|
| 540 |
+
return self.state.population
|
| 541 |
+
|
| 542 |
+
def _update_elo(self, genome: ScenarioGenome, record: AgentPerformanceRecord):
|
| 543 |
+
"""
|
| 544 |
+
Update Elo ratings for agent and scenario.
|
| 545 |
+
|
| 546 |
+
Agent 'wins' if score > 0.7, 'loses' if score < 0.3, 'draw' otherwise.
|
| 547 |
+
This gives a natural difficulty calibration system.
|
| 548 |
+
"""
|
| 549 |
+
K = 32 # Elo K-factor
|
| 550 |
+
|
| 551 |
+
agent_elo = self.state.agent_elo
|
| 552 |
+
scenario_elo = self.state.scenario_elos.get(genome.genome_id, 1000.0)
|
| 553 |
+
|
| 554 |
+
# Expected scores
|
| 555 |
+
ea = 1.0 / (1.0 + 10 ** ((scenario_elo - agent_elo) / 400))
|
| 556 |
+
es = 1.0 - ea
|
| 557 |
+
|
| 558 |
+
# Actual outcome
|
| 559 |
+
if record.score > 0.7:
|
| 560 |
+
sa, ss = 1.0, 0.0 # Agent wins
|
| 561 |
+
elif record.score < 0.3:
|
| 562 |
+
sa, ss = 0.0, 1.0 # Scenario wins
|
| 563 |
+
else:
|
| 564 |
+
sa, ss = 0.5, 0.5 # Draw
|
| 565 |
+
|
| 566 |
+
self.state.agent_elo = agent_elo + K * (sa - ea)
|
| 567 |
+
self.state.scenario_elos[genome.genome_id] = scenario_elo + K * (ss - es)
|
| 568 |
+
|
| 569 |
+
def get_evolution_stats(self) -> Dict[str, Any]:
|
| 570 |
+
"""Get statistics about the current evolution state."""
|
| 571 |
+
recent = self.state.performance_history[-20:]
|
| 572 |
+
return {
|
| 573 |
+
"generation": self.state.generation,
|
| 574 |
+
"population_size": len(self.state.population),
|
| 575 |
+
"archive_size": len(self.state.archive),
|
| 576 |
+
"total_episodes": len(self.state.performance_history),
|
| 577 |
+
"agent_elo": round(self.state.agent_elo, 1),
|
| 578 |
+
"avg_recent_score": round(
|
| 579 |
+
sum(r.score for r in recent) / len(recent), 4
|
| 580 |
+
) if recent else None,
|
| 581 |
+
"avg_difficulty": round(
|
| 582 |
+
sum(g.aggregate_difficulty for g in self.state.population)
|
| 583 |
+
/ len(self.state.population), 3
|
| 584 |
+
) if self.state.population else None,
|
| 585 |
+
"difficulty_range": {
|
| 586 |
+
"min": round(min(g.aggregate_difficulty for g in self.state.population), 3),
|
| 587 |
+
"max": round(max(g.aggregate_difficulty for g in self.state.population), 3),
|
| 588 |
+
} if self.state.population else None,
|
| 589 |
+
}
|
|
@@ -0,0 +1,740 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Procedural Scenario Generator
|
| 3 |
+
|
| 4 |
+
Converts ScenarioGenome parameters into fully-realized Scenario objects
|
| 5 |
+
with realistic cybersecurity content (log entries, IOCs, endpoints, etc).
|
| 6 |
+
|
| 7 |
+
Uses template-based generation with parameterized complexity to ensure
|
| 8 |
+
scenarios are both varied and solvable.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
import random
|
| 13 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from ..tasks.base import (
|
| 17 |
+
EndpointInfo, LogEntry, Scenario, ThreatIntelEntry, UserProfile
|
| 18 |
+
)
|
| 19 |
+
from ..models import ContainmentAction, Severity, ThreatCategory
|
| 20 |
+
except ImportError:
|
| 21 |
+
import sys, os
|
| 22 |
+
_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 23 |
+
if _parent not in sys.path:
|
| 24 |
+
sys.path.insert(0, _parent)
|
| 25 |
+
from tasks.base import (
|
| 26 |
+
EndpointInfo, LogEntry, Scenario, ThreatIntelEntry, UserProfile
|
| 27 |
+
)
|
| 28 |
+
from models import ContainmentAction, Severity, ThreatCategory
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
from .evolution_engine import ScenarioGenome
|
| 32 |
+
except ImportError:
|
| 33 |
+
from self_evolving.evolution_engine import ScenarioGenome
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 37 |
+
# Content Templates (realistic cybersecurity data)
|
| 38 |
+
# ═══════════════════════════════════════════════════════════════════
|
| 39 |
+
|
| 40 |
+
ATTACK_TEMPLATES = {
|
| 41 |
+
"phishing": {
|
| 42 |
+
"categories": [ThreatCategory.PHISHING, ThreatCategory.MALWARE],
|
| 43 |
+
"severities": [Severity.HIGH, Severity.MEDIUM],
|
| 44 |
+
"alert_templates": [
|
| 45 |
+
"Suspicious email with malicious attachment detected targeting {user}",
|
| 46 |
+
"Spear phishing campaign detected - credential harvesting attempt on {user}",
|
| 47 |
+
"Business email compromise attempt flagged by DLP - sender: {attacker_email}",
|
| 48 |
+
],
|
| 49 |
+
"log_templates": {
|
| 50 |
+
"email": [
|
| 51 |
+
"Email received from {attacker_email} to {user}@corp.local - Subject: '{subject}' - Attachment: {filename}",
|
| 52 |
+
"DMARC FAIL for sender domain {attacker_domain} - SPF: fail, DKIM: fail",
|
| 53 |
+
"Attachment {filename} (SHA256: {file_hash}) downloaded by {user}",
|
| 54 |
+
],
|
| 55 |
+
"edr": [
|
| 56 |
+
"Process {proc_name} spawned by outlook.exe on {hostname} - PID: {pid}",
|
| 57 |
+
"Suspicious DLL injection detected: {filename} loaded into {proc_name}",
|
| 58 |
+
"Network beacon detected from {hostname} to {c2_ip}:{c2_port} every {interval}s",
|
| 59 |
+
],
|
| 60 |
+
"proxy": [
|
| 61 |
+
"HTTP POST to {c2_domain}/beacon - User-Agent: {ua} - Host: {hostname}",
|
| 62 |
+
"Data upload detected: {hostname} -> {c2_ip} ({data_size}MB)",
|
| 63 |
+
"SSL connection to known malicious domain: {c2_domain} from {hostname}",
|
| 64 |
+
],
|
| 65 |
+
"auth": [
|
| 66 |
+
"Successful login for {user} from {src_ip} at {timestamp}",
|
| 67 |
+
"Failed login attempt for {user} - source: {src_ip} - {attempts} attempts",
|
| 68 |
+
],
|
| 69 |
+
"firewall": [
|
| 70 |
+
"ALLOW: {hostname}({src_ip}) -> {c2_ip}:443 (HTTPS) - {bytes} bytes",
|
| 71 |
+
"BLOCK: {c2_ip} -> {hostname}:{port} (reverse shell attempt)",
|
| 72 |
+
],
|
| 73 |
+
"dns": [
|
| 74 |
+
"DNS query: {c2_domain} -> {c2_ip} from {hostname}",
|
| 75 |
+
"Suspicious DNS TXT record query: {domain} from {hostname}",
|
| 76 |
+
],
|
| 77 |
+
},
|
| 78 |
+
"containment_actions": [
|
| 79 |
+
(ContainmentAction.QUARANTINE_FILE, "{file_hash}"),
|
| 80 |
+
(ContainmentAction.ISOLATE_HOST, "{hostname}"),
|
| 81 |
+
(ContainmentAction.BLOCK_IP, "{c2_ip}"),
|
| 82 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{user}"),
|
| 83 |
+
],
|
| 84 |
+
"escalation": "tier3",
|
| 85 |
+
"report_keywords": ["phishing", "malicious attachment", "C2 beacon", "credential theft"],
|
| 86 |
+
},
|
| 87 |
+
"lateral_movement": {
|
| 88 |
+
"categories": [ThreatCategory.LATERAL_MOVEMENT, ThreatCategory.BRUTE_FORCE],
|
| 89 |
+
"severities": [Severity.CRITICAL, Severity.HIGH],
|
| 90 |
+
"alert_templates": [
|
| 91 |
+
"Multiple failed authentication attempts followed by lateral movement from {hostname}",
|
| 92 |
+
"Credential stuffing attack detected - {attempts} failed logins then successful RDP to {target_host}",
|
| 93 |
+
"Pass-the-hash attack detected: {user} authenticating across multiple systems",
|
| 94 |
+
],
|
| 95 |
+
"log_templates": {
|
| 96 |
+
"auth": [
|
| 97 |
+
"Failed login: {user} from {src_ip} - {attempts} attempts in {window}min",
|
| 98 |
+
"Successful login: {user} from {src_ip} after {failed_count} failures",
|
| 99 |
+
"Kerberos TGT request: {user} from {hostname} - ticket type: {ticket_type}",
|
| 100 |
+
"NTLM authentication: {user} from {src_ip} to {target_host}",
|
| 101 |
+
],
|
| 102 |
+
"edr": [
|
| 103 |
+
"PsExec.exe execution detected on {target_host} from {hostname}",
|
| 104 |
+
"Mimikatz-like memory access detected on {hostname} - LSASS.exe read",
|
| 105 |
+
"WMI remote execution: {hostname} -> {target_host} - command: {command}",
|
| 106 |
+
"Credential dump detected: {hostname} - tool: {tool_name}",
|
| 107 |
+
],
|
| 108 |
+
"firewall": [
|
| 109 |
+
"ALLOW: {hostname}({src_ip}) -> {target_host}:3389 (RDP)",
|
| 110 |
+
"ALLOW: {hostname} -> {target_host}:445 (SMB)",
|
| 111 |
+
"Spike in traffic: {hostname} -> internal subnets ({connection_count} connections in {window}min)",
|
| 112 |
+
],
|
| 113 |
+
"dns": [
|
| 114 |
+
"DNS query: {target_host}.corp.local from {hostname}",
|
| 115 |
+
"Internal DNS enumeration detected from {hostname} - {query_count} queries",
|
| 116 |
+
],
|
| 117 |
+
"proxy": [
|
| 118 |
+
"Internal proxy: {hostname} -> {target_host}:8080 ({data_size}MB transferred)",
|
| 119 |
+
],
|
| 120 |
+
"email": [
|
| 121 |
+
"No relevant email logs for this incident type",
|
| 122 |
+
],
|
| 123 |
+
},
|
| 124 |
+
"containment_actions": [
|
| 125 |
+
(ContainmentAction.ISOLATE_HOST, "{hostname}"),
|
| 126 |
+
(ContainmentAction.ISOLATE_HOST, "{target_host}"),
|
| 127 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{user}"),
|
| 128 |
+
(ContainmentAction.REVOKE_SESSIONS, "{user}"),
|
| 129 |
+
(ContainmentAction.BLOCK_IP, "{src_ip}"),
|
| 130 |
+
],
|
| 131 |
+
"escalation": "tier3",
|
| 132 |
+
"report_keywords": ["lateral movement", "credential theft", "brute force", "RDP", "privilege escalation"],
|
| 133 |
+
},
|
| 134 |
+
"insider_threat": {
|
| 135 |
+
"categories": [ThreatCategory.INSIDER_THREAT, ThreatCategory.DATA_EXFILTRATION],
|
| 136 |
+
"severities": [Severity.CRITICAL, Severity.HIGH],
|
| 137 |
+
"alert_templates": [
|
| 138 |
+
"Data exfiltration alert: {user} transferring sensitive files to external storage",
|
| 139 |
+
"Insider threat indicator: {user} accessing files outside normal scope at unusual hours",
|
| 140 |
+
"DLP alert: Bulk download of confidential documents by {user}",
|
| 141 |
+
],
|
| 142 |
+
"log_templates": {
|
| 143 |
+
"email": [
|
| 144 |
+
"Email from {user}@corp.local to {ext_email} - {attachment_count} attachments ({total_size}MB)",
|
| 145 |
+
"Email forwarding rule created by {user} to {ext_email}",
|
| 146 |
+
],
|
| 147 |
+
"auth": [
|
| 148 |
+
"Off-hours login: {user} at {timestamp} (normal hours: 9-17)",
|
| 149 |
+
"VPN connection: {user} from {src_ip} ({geo_location})",
|
| 150 |
+
"Privilege escalation: {user} added to {group_name} group",
|
| 151 |
+
],
|
| 152 |
+
"edr": [
|
| 153 |
+
"USB device connected: {device_name} on {hostname} - {user} session",
|
| 154 |
+
"File copy to removable media: {file_count} files ({total_size}MB) by {user}",
|
| 155 |
+
"Screen capture tool detected: {tool_name} on {hostname}",
|
| 156 |
+
],
|
| 157 |
+
"proxy": [
|
| 158 |
+
"Upload to {cloud_service}: {hostname} ({user}) - {data_size}MB",
|
| 159 |
+
"Connection to file sharing site: {cloud_service} from {hostname}",
|
| 160 |
+
],
|
| 161 |
+
"dns": [
|
| 162 |
+
"DNS query: {cloud_service_domain} from {hostname}",
|
| 163 |
+
],
|
| 164 |
+
"firewall": [
|
| 165 |
+
"Outbound data transfer: {hostname} -> {ext_ip}:{port} ({data_size}MB)",
|
| 166 |
+
"ALLOW: {hostname} -> {ext_ip}:443 (HTTPS to cloud storage)",
|
| 167 |
+
],
|
| 168 |
+
},
|
| 169 |
+
"containment_actions": [
|
| 170 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{user}"),
|
| 171 |
+
(ContainmentAction.REVOKE_SESSIONS, "{user}"),
|
| 172 |
+
(ContainmentAction.ISOLATE_HOST, "{hostname}"),
|
| 173 |
+
],
|
| 174 |
+
"escalation": "management",
|
| 175 |
+
"report_keywords": ["insider threat", "data exfiltration", "unauthorized access", "sensitive data"],
|
| 176 |
+
},
|
| 177 |
+
"ransomware": {
|
| 178 |
+
"categories": [ThreatCategory.RANSOMWARE, ThreatCategory.MALWARE],
|
| 179 |
+
"severities": [Severity.CRITICAL],
|
| 180 |
+
"alert_templates": [
|
| 181 |
+
"Ransomware activity detected: Mass file encryption on {target_host}",
|
| 182 |
+
"File encryption alert: {file_count} files encrypted on {target_host} in {window} minutes",
|
| 183 |
+
"Known ransomware variant {malware_name} detected on {hostname}",
|
| 184 |
+
],
|
| 185 |
+
"log_templates": {
|
| 186 |
+
"edr": [
|
| 187 |
+
"Mass file modification: {file_count} files renamed to .{extension} on {target_host}",
|
| 188 |
+
"Suspicious process: {proc_name} modifying files in {directory} on {target_host}",
|
| 189 |
+
"Ransom note created: {ransom_file} in {directory} on {target_host}",
|
| 190 |
+
"Shadow copy deletion: vssadmin.exe delete shadows on {target_host}",
|
| 191 |
+
],
|
| 192 |
+
"auth": [
|
| 193 |
+
"Service account {user} used to access {target_host} from {hostname}",
|
| 194 |
+
"Failed RDP attempts: {hostname} -> {target_host} ({attempts} attempts)",
|
| 195 |
+
"Successful authentication: {user} on {target_host} via {auth_method}",
|
| 196 |
+
],
|
| 197 |
+
"firewall": [
|
| 198 |
+
"ALLOW: {hostname} -> {c2_ip}:443 (C2 communication)",
|
| 199 |
+
"ALLOW: {hostname} -> {target_host}:445 (SMB lateral movement)",
|
| 200 |
+
"BLOCK: {target_host} -> {c2_ip}:{port} (ransom payment page)",
|
| 201 |
+
],
|
| 202 |
+
"dns": [
|
| 203 |
+
"DNS query: {c2_domain} from {hostname}",
|
| 204 |
+
"DNS query: {ransom_domain}.onion.to from {target_host}",
|
| 205 |
+
],
|
| 206 |
+
"proxy": [
|
| 207 |
+
"TOR traffic detected from {target_host}",
|
| 208 |
+
"HTTP GET to {c2_domain}/key - Encrypted payload received",
|
| 209 |
+
],
|
| 210 |
+
"email": [
|
| 211 |
+
"Phishing email to {user}: '{subject}' with malicious macro attachment",
|
| 212 |
+
],
|
| 213 |
+
},
|
| 214 |
+
"containment_actions": [
|
| 215 |
+
(ContainmentAction.ISOLATE_HOST, "{hostname}"),
|
| 216 |
+
(ContainmentAction.ISOLATE_HOST, "{target_host}"),
|
| 217 |
+
(ContainmentAction.BLOCK_IP, "{c2_ip}"),
|
| 218 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{user}"),
|
| 219 |
+
(ContainmentAction.QUARANTINE_FILE, "{file_hash}"),
|
| 220 |
+
],
|
| 221 |
+
"escalation": "tier3",
|
| 222 |
+
"report_keywords": ["ransomware", "encryption", "C2", "lateral movement", "shadow copies"],
|
| 223 |
+
},
|
| 224 |
+
"supply_chain": {
|
| 225 |
+
"categories": [ThreatCategory.SUPPLY_CHAIN, ThreatCategory.MALWARE],
|
| 226 |
+
"severities": [Severity.CRITICAL],
|
| 227 |
+
"alert_templates": [
|
| 228 |
+
"Supply chain compromise detected: Backdoor in {software_name} update",
|
| 229 |
+
"Compromised third-party library {library_name} detected in production",
|
| 230 |
+
"Suspicious code execution from auto-updated {software_name} package",
|
| 231 |
+
],
|
| 232 |
+
"log_templates": {
|
| 233 |
+
"edr": [
|
| 234 |
+
"Code execution from {software_name} update: {proc_name} spawned {child_proc}",
|
| 235 |
+
"Backdoor detected: {file_name} in {install_path} ({file_hash})",
|
| 236 |
+
"Persistence mechanism: Registry key added by {proc_name} on {hostname}",
|
| 237 |
+
"Memory injection detected: {proc_name} injected into {target_proc}",
|
| 238 |
+
],
|
| 239 |
+
"dns": [
|
| 240 |
+
"DNS query to staging server: {staging_domain} from {hostname}",
|
| 241 |
+
"DNS tunneling detected: long subdomain queries to {c2_domain} from {hostname}",
|
| 242 |
+
],
|
| 243 |
+
"firewall": [
|
| 244 |
+
"ALLOW: {hostname} -> {c2_ip}:{port} (backdoor C2)",
|
| 245 |
+
"ALLOW: {hostname} -> {staging_ip}:443 (data staging)",
|
| 246 |
+
],
|
| 247 |
+
"proxy": [
|
| 248 |
+
"HTTPS to {staging_domain}: {hostname} - Certificate mismatch detected",
|
| 249 |
+
"Data upload: {hostname} -> {staging_domain} ({data_size}MB)",
|
| 250 |
+
],
|
| 251 |
+
"auth": [
|
| 252 |
+
"Service account {svc_account} created by {software_name} installer",
|
| 253 |
+
"Elevated privileges: {svc_account} added to Administrators group on {hostname}",
|
| 254 |
+
],
|
| 255 |
+
"email": [
|
| 256 |
+
"Vendor notification email from {vendor_email} about update {version}",
|
| 257 |
+
],
|
| 258 |
+
},
|
| 259 |
+
"containment_actions": [
|
| 260 |
+
(ContainmentAction.ISOLATE_HOST, "{hostname}"),
|
| 261 |
+
(ContainmentAction.QUARANTINE_FILE, "{file_hash}"),
|
| 262 |
+
(ContainmentAction.BLOCK_IP, "{c2_ip}"),
|
| 263 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{svc_account}"),
|
| 264 |
+
],
|
| 265 |
+
"escalation": "management",
|
| 266 |
+
"report_keywords": ["supply chain", "backdoor", "compromise", "third-party", "update"],
|
| 267 |
+
},
|
| 268 |
+
"apt_zeroday": {
|
| 269 |
+
"categories": [ThreatCategory.APT_ZERO_DAY],
|
| 270 |
+
"severities": [Severity.CRITICAL],
|
| 271 |
+
"alert_templates": [
|
| 272 |
+
"APT activity detected: Zero-day exploit in {software_name} targeting {target_host}",
|
| 273 |
+
"Advanced persistent threat: Multi-stage attack with unknown exploit on {hostname}",
|
| 274 |
+
"Sophisticated attack chain: {exploit_name} vulnerability exploitation with DNS tunneling",
|
| 275 |
+
],
|
| 276 |
+
"log_templates": {
|
| 277 |
+
"edr": [
|
| 278 |
+
"Zero-day exploit: Buffer overflow in {software_name} on {target_host} (CVE pending)",
|
| 279 |
+
"Post-exploitation: {tool_name} dropped in {directory} on {target_host}",
|
| 280 |
+
"Living-off-the-land: {proc_name} executing encoded PowerShell on {hostname}",
|
| 281 |
+
"DCSync attack detected: {user} replicating AD credentials from {dc_host}",
|
| 282 |
+
"Keylogger installed: {file_name} injected into {target_proc} on {hostname}",
|
| 283 |
+
],
|
| 284 |
+
"dns": [
|
| 285 |
+
"DNS tunneling: {subdomain}.{c2_domain} from {hostname} ({query_count} queries/hr)",
|
| 286 |
+
"DNS C2 channel: TXT record responses from {c2_domain} to {hostname}",
|
| 287 |
+
"Fast-flux DNS detected: {c2_domain} resolving to {ip_count} IPs",
|
| 288 |
+
],
|
| 289 |
+
"auth": [
|
| 290 |
+
"Golden ticket detected: Kerberos ticket for {user} with suspicious lifetime",
|
| 291 |
+
"Admin account compromise: {admin_user} authenticating from {attacker_ip}",
|
| 292 |
+
"Privilege escalation: {user} -> {admin_user} via {technique}",
|
| 293 |
+
],
|
| 294 |
+
"firewall": [
|
| 295 |
+
"ALLOW: {hostname} -> {c2_ip}:{port} (encrypted C2)",
|
| 296 |
+
"Covert channel: {hostname} -> {c2_ip} via ICMP (data embedded in payload)",
|
| 297 |
+
"Suspicious outbound: {target_host} -> {exfil_ip}:53 ({data_size}MB via DNS)",
|
| 298 |
+
],
|
| 299 |
+
"proxy": [
|
| 300 |
+
"Encrypted traffic anomaly: {hostname} -> {c2_ip} ({data_size}MB, high entropy)",
|
| 301 |
+
"Certificate pinning bypass detected from {hostname} to {c2_domain}",
|
| 302 |
+
],
|
| 303 |
+
"email": [
|
| 304 |
+
"Initial access: Spear phishing to {user} from {attacker_email} with zero-day PDF",
|
| 305 |
+
],
|
| 306 |
+
},
|
| 307 |
+
"containment_actions": [
|
| 308 |
+
(ContainmentAction.ISOLATE_HOST, "{hostname}"),
|
| 309 |
+
(ContainmentAction.ISOLATE_HOST, "{target_host}"),
|
| 310 |
+
(ContainmentAction.BLOCK_IP, "{c2_ip}"),
|
| 311 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{user}"),
|
| 312 |
+
(ContainmentAction.DISABLE_ACCOUNT, "{admin_user}"),
|
| 313 |
+
(ContainmentAction.QUARANTINE_FILE, "{file_hash}"),
|
| 314 |
+
],
|
| 315 |
+
"escalation": "legal",
|
| 316 |
+
"report_keywords": ["APT", "zero-day", "DNS tunneling", "DCSync", "advanced persistent threat"],
|
| 317 |
+
},
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
# Random data pools for filling templates
|
| 321 |
+
IP_POOL = [
|
| 322 |
+
"185.220.101.42", "203.0.113.50", "198.51.100.23", "192.0.2.100",
|
| 323 |
+
"45.33.32.156", "94.102.49.190", "195.123.237.18", "104.248.29.53",
|
| 324 |
+
"64.225.8.203", "161.35.38.117", "178.128.83.12", "134.209.76.34",
|
| 325 |
+
"159.89.115.241", "167.172.182.15", "142.93.118.76", "68.183.44.143",
|
| 326 |
+
"10.50.25.101", "10.50.25.102", "10.50.25.103", "10.50.25.104",
|
| 327 |
+
]
|
| 328 |
+
DOMAIN_POOL = [
|
| 329 |
+
"malware-cdn.evil.com", "secure-update.net", "cdn-analytics.cloud",
|
| 330 |
+
"api-sync.services", "update-service.io", "data-backup.cloud",
|
| 331 |
+
"metrics-relay.net", "auth-verify.cloud", "cloud-sync.services",
|
| 332 |
+
"cdn-static.cloud", "api-gateway.services", "log-collector.net",
|
| 333 |
+
]
|
| 334 |
+
HOSTNAME_POOL = [
|
| 335 |
+
"WS-USER01-PC", "WS-ADMIN-PC", "SRV-DC-01", "SRV-FILE-01",
|
| 336 |
+
"SRV-WEB-01", "SRV-DB-01", "WS-DEV-PC", "SRV-APP-01",
|
| 337 |
+
"SRV-MAIL-01", "WS-FINANCE-PC", "SRV-BACKUP-01", "SRV-DNS-01",
|
| 338 |
+
]
|
| 339 |
+
USERNAME_POOL = [
|
| 340 |
+
"jsmith", "agarcia", "mbrown", "klee", "tnguyen",
|
| 341 |
+
"dwilson", "schen", "rjohnson", "lpatel", "mkim",
|
| 342 |
+
"svc_backup", "svc_deploy", "admin_ops", "svc_monitor",
|
| 343 |
+
]
|
| 344 |
+
HASH_POOL = [
|
| 345 |
+
"e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6",
|
| 346 |
+
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
| 347 |
+
"f0e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1b2a3f4e5d6c7b8a9f0e1",
|
| 348 |
+
"c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8",
|
| 349 |
+
]
|
| 350 |
+
FILENAMES = [
|
| 351 |
+
"svchost_update.exe", "chrome_helper.dll", "sys32_patch.bin",
|
| 352 |
+
"update_agent.ps1", "backup_tool.py", "report.xlsm",
|
| 353 |
+
"invoice_Q4.docm", "driver_update.exe", "monitoring_agent.dll",
|
| 354 |
+
]
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class ScenarioGenerator:
|
| 358 |
+
"""
|
| 359 |
+
Procedural scenario generator that converts genome parameters
|
| 360 |
+
into fully-realized Scenario objects.
|
| 361 |
+
"""
|
| 362 |
+
|
| 363 |
+
def __init__(self, seed: Optional[int] = None):
|
| 364 |
+
if seed is not None:
|
| 365 |
+
random.seed(seed)
|
| 366 |
+
|
| 367 |
+
def generate(self, genome: ScenarioGenome, seed: Optional[int] = None) -> Scenario:
|
| 368 |
+
"""
|
| 369 |
+
Generate a complete Scenario from a ScenarioGenome.
|
| 370 |
+
|
| 371 |
+
The genome's parameters control:
|
| 372 |
+
- Number and complexity of evidence items
|
| 373 |
+
- Number of IOCs and their types
|
| 374 |
+
- Network topology (endpoints, users)
|
| 375 |
+
- Time pressure (max_steps vs required actions)
|
| 376 |
+
- Noise level (decoy/irrelevant evidence)
|
| 377 |
+
"""
|
| 378 |
+
if seed is not None:
|
| 379 |
+
random.seed(seed)
|
| 380 |
+
|
| 381 |
+
# Select attack type based on difficulty
|
| 382 |
+
attack_type = self._select_attack_type(genome)
|
| 383 |
+
template = ATTACK_TEMPLATES[attack_type]
|
| 384 |
+
|
| 385 |
+
# Generate scenario variables
|
| 386 |
+
variables = self._generate_variables(genome)
|
| 387 |
+
|
| 388 |
+
# Build scenario components
|
| 389 |
+
severity = self._select_severity(genome, template)
|
| 390 |
+
category = random.choice(template["categories"])
|
| 391 |
+
|
| 392 |
+
log_entries = self._generate_logs(genome, template, variables)
|
| 393 |
+
threat_intel = self._generate_threat_intel(genome, variables)
|
| 394 |
+
endpoints = self._generate_endpoints(genome, variables)
|
| 395 |
+
users = self._generate_users(genome, variables)
|
| 396 |
+
correlations = self._generate_correlations(genome, variables)
|
| 397 |
+
containment = self._generate_containment(genome, template, variables)
|
| 398 |
+
|
| 399 |
+
# Determine difficulty label
|
| 400 |
+
diff = genome.aggregate_difficulty
|
| 401 |
+
if diff < 0.2:
|
| 402 |
+
difficulty = "easy"
|
| 403 |
+
task_id = f"evolved_easy_{genome.genome_id}"
|
| 404 |
+
elif diff < 0.4:
|
| 405 |
+
difficulty = "medium"
|
| 406 |
+
task_id = f"evolved_medium_{genome.genome_id}"
|
| 407 |
+
elif diff < 0.6:
|
| 408 |
+
difficulty = "medium_hard"
|
| 409 |
+
task_id = f"evolved_medhard_{genome.genome_id}"
|
| 410 |
+
elif diff < 0.8:
|
| 411 |
+
difficulty = "hard"
|
| 412 |
+
task_id = f"evolved_hard_{genome.genome_id}"
|
| 413 |
+
else:
|
| 414 |
+
difficulty = "expert"
|
| 415 |
+
task_id = f"evolved_expert_{genome.genome_id}"
|
| 416 |
+
|
| 417 |
+
# Build alert summary
|
| 418 |
+
alert_summary = random.choice(template["alert_templates"]).format(**variables)
|
| 419 |
+
|
| 420 |
+
# Determine critical evidence and IOCs
|
| 421 |
+
critical_evidence = set()
|
| 422 |
+
critical_iocs = set()
|
| 423 |
+
for entry in log_entries:
|
| 424 |
+
if entry.is_critical:
|
| 425 |
+
for kw in entry.keywords:
|
| 426 |
+
critical_evidence.add(kw)
|
| 427 |
+
for ti in threat_intel:
|
| 428 |
+
critical_iocs.add(ti.ioc)
|
| 429 |
+
|
| 430 |
+
# Build containment pairs
|
| 431 |
+
containment_actions = []
|
| 432 |
+
containment_targets = {}
|
| 433 |
+
required_containment_pairs = []
|
| 434 |
+
for action, target in containment:
|
| 435 |
+
containment_actions.append(action)
|
| 436 |
+
containment_targets[action.value] = target
|
| 437 |
+
required_containment_pairs.append((action.value, target))
|
| 438 |
+
|
| 439 |
+
scenario = Scenario(
|
| 440 |
+
scenario_id=f"evolved_{genome.genome_id}",
|
| 441 |
+
task_id=task_id,
|
| 442 |
+
difficulty=difficulty,
|
| 443 |
+
alert_summary=alert_summary,
|
| 444 |
+
alert_source="SOC-SIEM-EVOLVED",
|
| 445 |
+
alert_timestamp="2026-03-28T10:00:00Z",
|
| 446 |
+
initial_observation=f"[Evolved Scenario Gen-{genome.generation}] {alert_summary}. Begin investigation.",
|
| 447 |
+
true_severity=severity,
|
| 448 |
+
true_category=category,
|
| 449 |
+
required_containment=containment_actions,
|
| 450 |
+
containment_targets=containment_targets,
|
| 451 |
+
is_false_positive=False,
|
| 452 |
+
correct_escalation=template.get("escalation"),
|
| 453 |
+
log_entries=log_entries,
|
| 454 |
+
threat_intel=threat_intel,
|
| 455 |
+
endpoints=endpoints,
|
| 456 |
+
users=users,
|
| 457 |
+
correlation_findings=correlations,
|
| 458 |
+
critical_evidence=critical_evidence,
|
| 459 |
+
critical_iocs=critical_iocs,
|
| 460 |
+
max_steps=genome.max_steps,
|
| 461 |
+
report_keywords=template["report_keywords"],
|
| 462 |
+
required_containment_pairs=required_containment_pairs,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
return scenario
|
| 466 |
+
|
| 467 |
+
def _select_attack_type(self, genome: ScenarioGenome) -> str:
|
| 468 |
+
"""Select attack type based on difficulty."""
|
| 469 |
+
diff = genome.aggregate_difficulty
|
| 470 |
+
if diff < 0.25:
|
| 471 |
+
weights = {"phishing": 5, "lateral_movement": 2, "insider_threat": 1,
|
| 472 |
+
"ransomware": 0, "supply_chain": 0, "apt_zeroday": 0}
|
| 473 |
+
elif diff < 0.5:
|
| 474 |
+
weights = {"phishing": 2, "lateral_movement": 4, "insider_threat": 3,
|
| 475 |
+
"ransomware": 2, "supply_chain": 1, "apt_zeroday": 0}
|
| 476 |
+
elif diff < 0.75:
|
| 477 |
+
weights = {"phishing": 1, "lateral_movement": 2, "insider_threat": 2,
|
| 478 |
+
"ransomware": 4, "supply_chain": 3, "apt_zeroday": 1}
|
| 479 |
+
else:
|
| 480 |
+
weights = {"phishing": 0, "lateral_movement": 1, "insider_threat": 1,
|
| 481 |
+
"ransomware": 2, "supply_chain": 3, "apt_zeroday": 5}
|
| 482 |
+
|
| 483 |
+
types = list(weights.keys())
|
| 484 |
+
w = [weights[t] for t in types]
|
| 485 |
+
return random.choices(types, weights=w, k=1)[0]
|
| 486 |
+
|
| 487 |
+
def _generate_variables(self, genome: ScenarioGenome) -> Dict[str, str]:
|
| 488 |
+
"""Generate random scenario variables."""
|
| 489 |
+
hostnames = random.sample(HOSTNAME_POOL, min(genome.num_endpoints + 2, len(HOSTNAME_POOL)))
|
| 490 |
+
ips = random.sample(IP_POOL, min(genome.num_endpoints + 4, len(IP_POOL)))
|
| 491 |
+
users = random.sample(USERNAME_POOL, min(genome.num_users + 2, len(USERNAME_POOL)))
|
| 492 |
+
|
| 493 |
+
return {
|
| 494 |
+
"hostname": hostnames[0],
|
| 495 |
+
"target_host": hostnames[1] if len(hostnames) > 1 else hostnames[0],
|
| 496 |
+
"dc_host": "SRV-DC-01",
|
| 497 |
+
"user": users[0],
|
| 498 |
+
"admin_user": users[1] if len(users) > 1 else "admin_ops",
|
| 499 |
+
"svc_account": f"svc_{random.choice(['deploy', 'backup', 'monitor', 'update'])}",
|
| 500 |
+
"src_ip": ips[0],
|
| 501 |
+
"c2_ip": random.choice(IP_POOL[:10]), # External IPs
|
| 502 |
+
"ext_ip": random.choice(IP_POOL[:10]),
|
| 503 |
+
"attacker_ip": random.choice(IP_POOL[:10]),
|
| 504 |
+
"exfil_ip": random.choice(IP_POOL[:10]),
|
| 505 |
+
"staging_ip": random.choice(IP_POOL[:10]),
|
| 506 |
+
"c2_domain": random.choice(DOMAIN_POOL),
|
| 507 |
+
"staging_domain": random.choice(DOMAIN_POOL),
|
| 508 |
+
"attacker_domain": random.choice(DOMAIN_POOL),
|
| 509 |
+
"c2_port": str(random.choice([443, 8443, 4444, 9090, 8080])),
|
| 510 |
+
"port": str(random.choice([443, 445, 3389, 22, 8080])),
|
| 511 |
+
"file_hash": random.choice(HASH_POOL),
|
| 512 |
+
"filename": random.choice(FILENAMES),
|
| 513 |
+
"file_name": random.choice(FILENAMES),
|
| 514 |
+
"proc_name": random.choice(["svchost.exe", "rundll32.exe", "powershell.exe", "cmd.exe"]),
|
| 515 |
+
"child_proc": random.choice(["cmd.exe", "powershell.exe", "certutil.exe"]),
|
| 516 |
+
"target_proc": random.choice(["explorer.exe", "lsass.exe", "svchost.exe"]),
|
| 517 |
+
"tool_name": random.choice(["Cobalt Strike", "Mimikatz", "BloodHound", "PsExec"]),
|
| 518 |
+
"pid": str(random.randint(1000, 65535)),
|
| 519 |
+
"interval": str(random.choice([30, 60, 120, 300])),
|
| 520 |
+
"attempts": str(random.randint(5, 500)),
|
| 521 |
+
"failed_count": str(random.randint(3, 20)),
|
| 522 |
+
"window": str(random.choice([5, 10, 15, 30])),
|
| 523 |
+
"data_size": str(round(random.uniform(0.5, 500), 1)),
|
| 524 |
+
"total_size": str(round(random.uniform(10, 1000), 1)),
|
| 525 |
+
"bytes": str(random.randint(1024, 10485760)),
|
| 526 |
+
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
| 527 |
+
"timestamp": "2026-03-28T" + f"{random.randint(0,23):02d}:{random.randint(0,59):02d}:00Z",
|
| 528 |
+
"subject": random.choice([
|
| 529 |
+
"Urgent: Invoice Payment Required",
|
| 530 |
+
"Action Required: Account Verification",
|
| 531 |
+
"Q4 Financial Report - Confidential",
|
| 532 |
+
"Password Reset Request",
|
| 533 |
+
"IT Security Update - Please Install",
|
| 534 |
+
]),
|
| 535 |
+
"attacker_email": f"attacker_{random.randint(100,999)}@{random.choice(DOMAIN_POOL)}",
|
| 536 |
+
"ext_email": f"external_{random.randint(100,999)}@gmail.com",
|
| 537 |
+
"vendor_email": f"updates@vendor-{random.randint(1,99)}.com",
|
| 538 |
+
"device_name": random.choice(["USB_Drive_SanDisk", "External_HDD_WD", "USB_Kingston_128GB"]),
|
| 539 |
+
"cloud_service": random.choice(["Dropbox", "Google Drive", "OneDrive", "Mega.nz"]),
|
| 540 |
+
"cloud_service_domain": random.choice(["dropbox.com", "drive.google.com", "onedrive.live.com"]),
|
| 541 |
+
"group_name": random.choice(["Domain Admins", "Backup Operators", "Enterprise Admins"]),
|
| 542 |
+
"geo_location": random.choice(["Moscow, RU", "Beijing, CN", "Unknown VPN", "São Paulo, BR"]),
|
| 543 |
+
"software_name": random.choice(["SolarUpdate", "NetMonitor Pro", "CloudSync Agent", "DevPipeline"]),
|
| 544 |
+
"library_name": random.choice(["node-ipc", "ua-parser-js", "event-stream", "coa"]),
|
| 545 |
+
"version": f"v{random.randint(1,5)}.{random.randint(0,9)}.{random.randint(0,99)}",
|
| 546 |
+
"install_path": random.choice(["C:\\Program Files\\", "C:\\Windows\\System32\\", "/opt/", "/usr/local/bin/"]),
|
| 547 |
+
"exploit_name": random.choice(["CVE-2026-XXXX", "Log4Shell-variant", "ProxyNotShell", "ZeroLogon-v2"]),
|
| 548 |
+
"malware_name": random.choice(["LockBit 4.0", "BlackCat v3", "REvil-NG", "DarkSide-X"]),
|
| 549 |
+
"extension": random.choice(["locked", "encrypted", "crypt", "ransom"]),
|
| 550 |
+
"ransom_file": "README_DECRYPT.txt",
|
| 551 |
+
"ransom_domain": f"ransom-{random.randint(1000,9999)}",
|
| 552 |
+
"directory": random.choice(["C:\\Users\\", "D:\\Shares\\Finance\\", "C:\\Data\\", "/home/"]),
|
| 553 |
+
"file_count": str(random.randint(100, 50000)),
|
| 554 |
+
"attachment_count": str(random.randint(1, 10)),
|
| 555 |
+
"auth_method": random.choice(["RDP", "SMB", "WinRM", "NTLM"]),
|
| 556 |
+
"ticket_type": random.choice(["TGT", "TGS", "golden_ticket"]),
|
| 557 |
+
"command": random.choice(["whoami", "net user /domain", "ipconfig /all", "systeminfo"]),
|
| 558 |
+
"connection_count": str(random.randint(20, 200)),
|
| 559 |
+
"query_count": str(random.randint(50, 5000)),
|
| 560 |
+
"ip_count": str(random.randint(5, 50)),
|
| 561 |
+
"technique": random.choice(["token impersonation", "DLL hijacking", "service account abuse"]),
|
| 562 |
+
"domain": random.choice(DOMAIN_POOL),
|
| 563 |
+
"subdomain": f"{''.join(random.choices('abcdef0123456789', k=32))}",
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
def _generate_logs(
|
| 567 |
+
self, genome: ScenarioGenome, template: Dict, variables: Dict
|
| 568 |
+
) -> List[LogEntry]:
|
| 569 |
+
"""Generate log entries with appropriate noise and critical evidence."""
|
| 570 |
+
entries = []
|
| 571 |
+
sources = ["email", "edr", "auth", "proxy", "firewall", "dns"]
|
| 572 |
+
|
| 573 |
+
# Generate critical entries from template
|
| 574 |
+
critical_count = 0
|
| 575 |
+
for source in sources:
|
| 576 |
+
if source not in template["log_templates"]:
|
| 577 |
+
continue
|
| 578 |
+
source_templates = template["log_templates"][source]
|
| 579 |
+
|
| 580 |
+
# Number of entries per source
|
| 581 |
+
n = max(1, genome.num_log_entries // len(sources))
|
| 582 |
+
|
| 583 |
+
for i in range(min(n, len(source_templates))):
|
| 584 |
+
tmpl = source_templates[i % len(source_templates)]
|
| 585 |
+
try:
|
| 586 |
+
content = tmpl.format(**variables)
|
| 587 |
+
except (KeyError, IndexError):
|
| 588 |
+
content = tmpl # Use raw if format fails
|
| 589 |
+
|
| 590 |
+
is_crit = critical_count < genome.num_critical_evidence
|
| 591 |
+
keywords = self._extract_keywords(content, variables)
|
| 592 |
+
|
| 593 |
+
entries.append(LogEntry(
|
| 594 |
+
source=source,
|
| 595 |
+
content=content,
|
| 596 |
+
is_critical=is_crit,
|
| 597 |
+
keywords=keywords,
|
| 598 |
+
))
|
| 599 |
+
if is_crit:
|
| 600 |
+
critical_count += 1
|
| 601 |
+
|
| 602 |
+
# Add noise entries (decoys)
|
| 603 |
+
noise_count = int(len(entries) * genome.noise_ratio)
|
| 604 |
+
for _ in range(noise_count):
|
| 605 |
+
source = random.choice(sources)
|
| 606 |
+
entries.append(LogEntry(
|
| 607 |
+
source=source,
|
| 608 |
+
content=f"[BENIGN] Routine {source} activity - {random.choice(['scan', 'update', 'backup', 'maintenance'])} completed successfully",
|
| 609 |
+
is_critical=False,
|
| 610 |
+
keywords=["benign", "routine"],
|
| 611 |
+
))
|
| 612 |
+
|
| 613 |
+
random.shuffle(entries)
|
| 614 |
+
return entries
|
| 615 |
+
|
| 616 |
+
def _generate_threat_intel(
|
| 617 |
+
self, genome: ScenarioGenome, variables: Dict
|
| 618 |
+
) -> List[ThreatIntelEntry]:
|
| 619 |
+
"""Generate threat intelligence entries."""
|
| 620 |
+
entries = []
|
| 621 |
+
ioc_sources = [
|
| 622 |
+
(variables.get("c2_ip", ""), "ip", "Known C2 server", "critical"),
|
| 623 |
+
(variables.get("file_hash", ""), "hash", "Known malware hash", "high"),
|
| 624 |
+
(variables.get("c2_domain", ""), "domain", "Malicious domain", "high"),
|
| 625 |
+
(variables.get("attacker_email", ""), "email", "Phishing sender", "medium"),
|
| 626 |
+
(variables.get("attacker_ip", variables.get("src_ip", "")), "ip", "Attack source", "medium"),
|
| 627 |
+
]
|
| 628 |
+
|
| 629 |
+
for ioc, ioc_type, desc, sev in ioc_sources[:genome.num_threat_intel]:
|
| 630 |
+
if ioc:
|
| 631 |
+
entries.append(ThreatIntelEntry(
|
| 632 |
+
ioc=ioc,
|
| 633 |
+
ioc_type=ioc_type,
|
| 634 |
+
description=f"{desc} associated with current campaign",
|
| 635 |
+
severity=sev,
|
| 636 |
+
source=random.choice(["VirusTotal", "AlienVault OTX", "IBM X-Force", "Mandiant"]),
|
| 637 |
+
keywords=[ioc.lower(), ioc_type],
|
| 638 |
+
))
|
| 639 |
+
|
| 640 |
+
return entries
|
| 641 |
+
|
| 642 |
+
def _generate_endpoints(
|
| 643 |
+
self, genome: ScenarioGenome, variables: Dict
|
| 644 |
+
) -> List[EndpointInfo]:
|
| 645 |
+
"""Generate endpoint information."""
|
| 646 |
+
endpoints = []
|
| 647 |
+
hostnames = [variables.get("hostname", "WS-01"), variables.get("target_host", "SRV-01")]
|
| 648 |
+
hostnames.extend(random.sample(HOSTNAME_POOL, min(genome.num_endpoints, len(HOSTNAME_POOL))))
|
| 649 |
+
|
| 650 |
+
for i, host in enumerate(hostnames[:genome.num_endpoints]):
|
| 651 |
+
ip = variables.get("src_ip", "10.0.0.1") if i == 0 else f"10.50.25.{100+i}"
|
| 652 |
+
endpoints.append(EndpointInfo(
|
| 653 |
+
endpoint_id=host,
|
| 654 |
+
hostname=host,
|
| 655 |
+
os=random.choice(["Windows Server 2022", "Windows 11 Pro", "Ubuntu 22.04"]),
|
| 656 |
+
ip=ip,
|
| 657 |
+
status="compromised" if i < 2 else "active",
|
| 658 |
+
processes=[variables.get("proc_name", "svchost.exe")] if i < 2 else [],
|
| 659 |
+
connections=[f"-> {variables.get('c2_ip', '1.2.3.4')}:443"] if i == 0 else [],
|
| 660 |
+
is_compromised=i < 2,
|
| 661 |
+
))
|
| 662 |
+
|
| 663 |
+
return endpoints
|
| 664 |
+
|
| 665 |
+
def _generate_users(
|
| 666 |
+
self, genome: ScenarioGenome, variables: Dict
|
| 667 |
+
) -> List[UserProfile]:
|
| 668 |
+
"""Generate user profiles."""
|
| 669 |
+
users = []
|
| 670 |
+
usernames = [variables.get("user", "jsmith")]
|
| 671 |
+
if genome.num_users > 1:
|
| 672 |
+
usernames.append(variables.get("admin_user", "admin"))
|
| 673 |
+
usernames.extend(random.sample(USERNAME_POOL, min(genome.num_users, len(USERNAME_POOL))))
|
| 674 |
+
|
| 675 |
+
for i, uid in enumerate(usernames[:genome.num_users]):
|
| 676 |
+
users.append(UserProfile(
|
| 677 |
+
user_id=uid,
|
| 678 |
+
display_name=uid.replace("_", " ").title(),
|
| 679 |
+
department=random.choice(["IT", "Finance", "Engineering", "Operations", "HR"]),
|
| 680 |
+
role=random.choice(["Analyst", "Engineer", "Manager", "Admin", "Developer"]),
|
| 681 |
+
risk_score=0.8 if i == 0 else random.uniform(0.1, 0.5),
|
| 682 |
+
notes="Primary suspect" if i == 0 else "",
|
| 683 |
+
))
|
| 684 |
+
|
| 685 |
+
return users
|
| 686 |
+
|
| 687 |
+
def _generate_correlations(
|
| 688 |
+
self, genome: ScenarioGenome, variables: Dict
|
| 689 |
+
) -> List[str]:
|
| 690 |
+
"""Generate event correlations."""
|
| 691 |
+
correlations = [
|
| 692 |
+
f"Timeline correlation: Initial compromise at {variables.get('hostname', 'HOST')} "
|
| 693 |
+
f"followed by lateral movement to {variables.get('target_host', 'TARGET')}",
|
| 694 |
+
f"Network correlation: C2 traffic from {variables.get('hostname', 'HOST')} "
|
| 695 |
+
f"to {variables.get('c2_ip', 'C2_IP')} matches known threat pattern",
|
| 696 |
+
]
|
| 697 |
+
|
| 698 |
+
if genome.correlation_depth >= 3:
|
| 699 |
+
correlations.append(
|
| 700 |
+
f"Credential correlation: Compromised user {variables.get('user', 'USER')} "
|
| 701 |
+
f"was the initial access vector via {variables.get('attacker_email', 'EMAIL')}"
|
| 702 |
+
)
|
| 703 |
+
if genome.correlation_depth >= 4:
|
| 704 |
+
correlations.append(
|
| 705 |
+
f"Data flow correlation: {variables.get('data_size', '0')}MB exfiltrated "
|
| 706 |
+
f"from {variables.get('target_host', 'TARGET')} to {variables.get('c2_ip', 'C2_IP')}"
|
| 707 |
+
)
|
| 708 |
+
|
| 709 |
+
return correlations[:genome.correlation_depth]
|
| 710 |
+
|
| 711 |
+
def _generate_containment(
|
| 712 |
+
self, genome: ScenarioGenome, template: Dict, variables: Dict
|
| 713 |
+
) -> List[Tuple[ContainmentAction, str]]:
|
| 714 |
+
"""Generate containment actions with targets."""
|
| 715 |
+
containment = []
|
| 716 |
+
for action, target_template in template["containment_actions"][:genome.num_containment_targets]:
|
| 717 |
+
try:
|
| 718 |
+
target = target_template.format(**variables)
|
| 719 |
+
except (KeyError, IndexError):
|
| 720 |
+
target = target_template
|
| 721 |
+
containment.append((action, target))
|
| 722 |
+
|
| 723 |
+
return containment
|
| 724 |
+
|
| 725 |
+
def _select_severity(self, genome: ScenarioGenome, template: Dict) -> Severity:
|
| 726 |
+
"""Select severity based on genome difficulty."""
|
| 727 |
+
if genome.aggregate_difficulty > 0.6:
|
| 728 |
+
return Severity.CRITICAL
|
| 729 |
+
else:
|
| 730 |
+
return random.choice(template["severities"])
|
| 731 |
+
|
| 732 |
+
def _extract_keywords(self, content: str, variables: Dict) -> List[str]:
|
| 733 |
+
"""Extract searchable keywords from log content."""
|
| 734 |
+
keywords = []
|
| 735 |
+
for key in ["hostname", "target_host", "user", "c2_ip", "file_hash",
|
| 736 |
+
"c2_domain", "src_ip", "attacker_email", "proc_name", "filename"]:
|
| 737 |
+
val = variables.get(key, "")
|
| 738 |
+
if val and val.lower() in content.lower():
|
| 739 |
+
keywords.append(val.lower())
|
| 740 |
+
return keywords
|
|
@@ -181,6 +181,27 @@ async def run_baseline():
|
|
| 181 |
return JSONResponse(content=results)
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 185 |
"""Entry point for direct execution."""
|
| 186 |
import uvicorn
|
|
|
|
| 181 |
return JSONResponse(content=results)
|
| 182 |
|
| 183 |
|
| 184 |
+
@app.post("/env/evolve")
|
| 185 |
+
async def evolve_population():
|
| 186 |
+
"""Trigger evolution of the scenario population."""
|
| 187 |
+
if not hasattr(_shared_env, '_evolution_engine') or _shared_env._evolution_engine is None:
|
| 188 |
+
raise HTTPException(status_code=400, detail="Evolution engine not initialized. Reset with task_id='evolved' first.")
|
| 189 |
+
new_pop = _shared_env._evolution_engine.evolve()
|
| 190 |
+
return JSONResponse(content={
|
| 191 |
+
"status": "evolved",
|
| 192 |
+
"generation": _shared_env._evolution_engine.state.generation,
|
| 193 |
+
"population_size": len(new_pop),
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@app.get("/env/evolution-stats")
|
| 198 |
+
async def evolution_stats():
|
| 199 |
+
"""Get evolution engine statistics."""
|
| 200 |
+
if not hasattr(_shared_env, '_evolution_engine') or _shared_env._evolution_engine is None:
|
| 201 |
+
return JSONResponse(content={"status": "not_initialized", "message": "Reset with task_id='evolved' to activate"})
|
| 202 |
+
return JSONResponse(content=_shared_env._evolution_engine.get_evolution_stats())
|
| 203 |
+
|
| 204 |
+
|
| 205 |
def main(host: str = "0.0.0.0", port: int = 8000):
|
| 206 |
"""Entry point for direct execution."""
|
| 207 |
import uvicorn
|
|
@@ -52,6 +52,12 @@ try:
|
|
| 52 |
)
|
| 53 |
from ..tasks import SCENARIOS, TASK_DEFINITIONS
|
| 54 |
from ..tasks.base import Scenario
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
except ImportError:
|
| 56 |
# When running from server/ directory, add parent to path
|
| 57 |
_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -68,6 +74,12 @@ except ImportError:
|
|
| 68 |
)
|
| 69 |
from tasks import SCENARIOS, TASK_DEFINITIONS
|
| 70 |
from tasks.base import Scenario
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
class IncidentResponseEnvEnvironment(Environment):
|
|
@@ -126,6 +138,10 @@ class IncidentResponseEnvEnvironment(Environment):
|
|
| 126 |
self._ti_iocs_checked: Set[str] = set()
|
| 127 |
self._investigated_before_classify: bool = False
|
| 128 |
self._classified_before_contain: bool = True # starts true, set false if violated
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
def reset(self, seed=None, episode_id=None, task_id: str = None, **kwargs) -> IncidentObservation:
|
| 131 |
"""
|
|
@@ -142,6 +158,11 @@ class IncidentResponseEnvEnvironment(Environment):
|
|
| 142 |
# Determine task
|
| 143 |
if task_id is None:
|
| 144 |
task_id = kwargs.get("task_id", "easy")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
self._task_id = task_id if task_id in SCENARIOS else "easy"
|
| 146 |
|
| 147 |
# Load scenario
|
|
@@ -190,6 +211,74 @@ class IncidentResponseEnvEnvironment(Environment):
|
|
| 190 |
reward=0.0,
|
| 191 |
)
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def step(self, action: IncidentAction) -> IncidentObservation:
|
| 194 |
"""
|
| 195 |
Execute an investigation or response action.
|
|
@@ -1291,29 +1380,50 @@ class IncidentResponseEnvEnvironment(Environment):
|
|
| 1291 |
def get_grader_score(self) -> Dict[str, Any]:
|
| 1292 |
"""Return the grader score for the current/last episode."""
|
| 1293 |
final_score = self.grade()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1294 |
return {
|
| 1295 |
"score": final_score,
|
| 1296 |
"task_id": self._task_id,
|
| 1297 |
-
"breakdown":
|
| 1298 |
-
"investigation_completeness": round(self._calculate_investigation_completeness(), 4),
|
| 1299 |
-
"ioc_identification": round(
|
| 1300 |
-
len(self._iocs_discovered & self._scenario.critical_iocs) / max(len(self._scenario.critical_iocs), 1), 4
|
| 1301 |
-
) if self._scenario else 0.0,
|
| 1302 |
-
"severity_correct": self._severity_set == self._scenario.true_severity if self._scenario else False,
|
| 1303 |
-
"category_correct": self._category_set == self._scenario.true_category if self._scenario else False,
|
| 1304 |
-
"containment_score": round(self._grade_containment(), 4),
|
| 1305 |
-
"containment_precision": round(self._grade_containment_precision(), 4),
|
| 1306 |
-
"report_submitted": self._report_submitted,
|
| 1307 |
-
"escalation_correct": (
|
| 1308 |
-
self._escalated_to in ([self._scenario.correct_escalation] if self._task_id != "expert"
|
| 1309 |
-
else ["tier3", "management", "legal"])
|
| 1310 |
-
if self._scenario and self._scenario.correct_escalation and self._escalated_to
|
| 1311 |
-
else self._escalated_to is None and (not self._scenario or not self._scenario.correct_escalation)
|
| 1312 |
-
),
|
| 1313 |
-
"evidence_chain_coherence": self._investigated_before_classify,
|
| 1314 |
-
"phase_discipline": self._classified_before_contain,
|
| 1315 |
-
"log_sources_queried": len(self._log_sources_queried),
|
| 1316 |
-
"steps_used": self._state.step_count,
|
| 1317 |
-
"max_steps": self._scenario.max_steps if self._scenario else 0,
|
| 1318 |
-
},
|
| 1319 |
}
|
|
|
|
| 52 |
)
|
| 53 |
from ..tasks import SCENARIOS, TASK_DEFINITIONS
|
| 54 |
from ..tasks.base import Scenario
|
| 55 |
+
from ..self_evolving import (
|
| 56 |
+
AgentPerformanceRecord,
|
| 57 |
+
EvolutionEngine,
|
| 58 |
+
ScenarioGenerator,
|
| 59 |
+
ScenarioGenome,
|
| 60 |
+
)
|
| 61 |
except ImportError:
|
| 62 |
# When running from server/ directory, add parent to path
|
| 63 |
_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
| 74 |
)
|
| 75 |
from tasks import SCENARIOS, TASK_DEFINITIONS
|
| 76 |
from tasks.base import Scenario
|
| 77 |
+
from self_evolving import (
|
| 78 |
+
AgentPerformanceRecord,
|
| 79 |
+
EvolutionEngine,
|
| 80 |
+
ScenarioGenerator,
|
| 81 |
+
ScenarioGenome,
|
| 82 |
+
)
|
| 83 |
|
| 84 |
|
| 85 |
class IncidentResponseEnvEnvironment(Environment):
|
|
|
|
| 138 |
self._ti_iocs_checked: Set[str] = set()
|
| 139 |
self._investigated_before_classify: bool = False
|
| 140 |
self._classified_before_contain: bool = True # starts true, set false if violated
|
| 141 |
+
# Self-evolving engine
|
| 142 |
+
self._evolution_engine: Optional[EvolutionEngine] = None
|
| 143 |
+
self._scenario_generator: Optional[ScenarioGenerator] = None
|
| 144 |
+
self._current_genome: Optional[ScenarioGenome] = None
|
| 145 |
|
| 146 |
def reset(self, seed=None, episode_id=None, task_id: str = None, **kwargs) -> IncidentObservation:
|
| 147 |
"""
|
|
|
|
| 158 |
# Determine task
|
| 159 |
if task_id is None:
|
| 160 |
task_id = kwargs.get("task_id", "easy")
|
| 161 |
+
|
| 162 |
+
# Self-evolving mode
|
| 163 |
+
if task_id == "evolved" or (task_id and task_id.startswith("evolved_")):
|
| 164 |
+
return self._reset_evolved(seed=seed, episode_id=episode_id)
|
| 165 |
+
|
| 166 |
self._task_id = task_id if task_id in SCENARIOS else "easy"
|
| 167 |
|
| 168 |
# Load scenario
|
|
|
|
| 211 |
reward=0.0,
|
| 212 |
)
|
| 213 |
|
| 214 |
+
def _reset_evolved(self, seed=None, episode_id=None) -> IncidentObservation:
|
| 215 |
+
"""Reset with a procedurally generated evolved scenario."""
|
| 216 |
+
if self._evolution_engine is None:
|
| 217 |
+
self._evolution_engine = EvolutionEngine(population_size=10, alpha=0.5)
|
| 218 |
+
self._scenario_generator = ScenarioGenerator()
|
| 219 |
+
|
| 220 |
+
# Get next scenario genome from evolution engine
|
| 221 |
+
genome = self._evolution_engine.get_next_scenario_genome()
|
| 222 |
+
|
| 223 |
+
# Generate scenario from genome
|
| 224 |
+
scenario = self._scenario_generator.generate(genome, seed=seed)
|
| 225 |
+
|
| 226 |
+
# Store genome reference for performance recording
|
| 227 |
+
self._current_genome = genome
|
| 228 |
+
|
| 229 |
+
# Use standard reset logic with the generated scenario
|
| 230 |
+
self._task_id = scenario.task_id
|
| 231 |
+
self._scenario = scenario
|
| 232 |
+
|
| 233 |
+
# Reset all state (same as normal reset)
|
| 234 |
+
self._state = IncidentState(
|
| 235 |
+
episode_id=episode_id or str(uuid4()),
|
| 236 |
+
step_count=0,
|
| 237 |
+
current_task=self._task_id,
|
| 238 |
+
)
|
| 239 |
+
self._evidence_discovered = set()
|
| 240 |
+
self._iocs_discovered = set()
|
| 241 |
+
self._actions_history = []
|
| 242 |
+
self._containment_executed = []
|
| 243 |
+
self._severity_set = None
|
| 244 |
+
self._category_set = None
|
| 245 |
+
self._escalated_to = None
|
| 246 |
+
self._report_submitted = False
|
| 247 |
+
self._report_text = ""
|
| 248 |
+
self._episode_done = False
|
| 249 |
+
self._reward_this_step = 0.0
|
| 250 |
+
self._total_reward = 0.0
|
| 251 |
+
self._closed_as_fp = False
|
| 252 |
+
self._log_sources_queried = set()
|
| 253 |
+
self._ti_iocs_checked = set()
|
| 254 |
+
self._investigated_before_classify = False
|
| 255 |
+
self._classified_before_contain = True
|
| 256 |
+
|
| 257 |
+
if seed is not None:
|
| 258 |
+
random.seed(seed)
|
| 259 |
+
|
| 260 |
+
return IncidentObservation(
|
| 261 |
+
alert_id=scenario.scenario_id,
|
| 262 |
+
alert_summary=scenario.alert_summary,
|
| 263 |
+
alert_source=scenario.alert_source,
|
| 264 |
+
timestamp=scenario.alert_timestamp,
|
| 265 |
+
findings=scenario.initial_observation,
|
| 266 |
+
evidence_collected=[],
|
| 267 |
+
iocs_discovered=[],
|
| 268 |
+
action_result="[Self-Evolving Mode] Environment initialized with evolved scenario. Begin your investigation.",
|
| 269 |
+
available_actions=[at.value for at in ActionType],
|
| 270 |
+
steps_remaining=scenario.max_steps,
|
| 271 |
+
investigation_progress=0.0,
|
| 272 |
+
done=False,
|
| 273 |
+
reward=0.0,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def get_evolution_stats(self) -> Dict[str, Any]:
|
| 277 |
+
"""Get statistics about the current evolution state."""
|
| 278 |
+
if self._evolution_engine is None:
|
| 279 |
+
return {"status": "not_initialized", "message": "Reset with task_id='evolved' to activate"}
|
| 280 |
+
return self._evolution_engine.get_evolution_stats()
|
| 281 |
+
|
| 282 |
def step(self, action: IncidentAction) -> IncidentObservation:
|
| 283 |
"""
|
| 284 |
Execute an investigation or response action.
|
|
|
|
| 1380 |
def get_grader_score(self) -> Dict[str, Any]:
|
| 1381 |
"""Return the grader score for the current/last episode."""
|
| 1382 |
final_score = self.grade()
|
| 1383 |
+
scores = {
|
| 1384 |
+
"investigation_completeness": round(self._calculate_investigation_completeness(), 4),
|
| 1385 |
+
"ioc_identification": round(
|
| 1386 |
+
len(self._iocs_discovered & self._scenario.critical_iocs) / max(len(self._scenario.critical_iocs), 1), 4
|
| 1387 |
+
) if self._scenario else 0.0,
|
| 1388 |
+
"severity_correct": self._severity_set == self._scenario.true_severity if self._scenario else False,
|
| 1389 |
+
"category_correct": self._category_set == self._scenario.true_category if self._scenario else False,
|
| 1390 |
+
"containment_score": round(self._grade_containment(), 4),
|
| 1391 |
+
"containment_precision": round(self._grade_containment_precision(), 4),
|
| 1392 |
+
"report_submitted": self._report_submitted,
|
| 1393 |
+
"escalation_correct": (
|
| 1394 |
+
self._escalated_to in ([self._scenario.correct_escalation] if self._task_id != "expert"
|
| 1395 |
+
else ["tier3", "management", "legal"])
|
| 1396 |
+
if self._scenario and self._scenario.correct_escalation and self._escalated_to
|
| 1397 |
+
else self._escalated_to is None and (not self._scenario or not self._scenario.correct_escalation)
|
| 1398 |
+
),
|
| 1399 |
+
"evidence_chain_coherence": self._investigated_before_classify,
|
| 1400 |
+
"phase_discipline": self._classified_before_contain,
|
| 1401 |
+
"log_sources_queried": len(self._log_sources_queried),
|
| 1402 |
+
"steps_used": self._state.step_count,
|
| 1403 |
+
"max_steps": self._scenario.max_steps if self._scenario else 0,
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
# Record performance for evolution engine
|
| 1407 |
+
if self._evolution_engine and self._current_genome:
|
| 1408 |
+
import time
|
| 1409 |
+
record = AgentPerformanceRecord(
|
| 1410 |
+
scenario_id=self._scenario.scenario_id if self._scenario else "",
|
| 1411 |
+
genome_id=self._current_genome.genome_id,
|
| 1412 |
+
score=final_score,
|
| 1413 |
+
steps_used=self._state.step_count,
|
| 1414 |
+
max_steps=self._scenario.max_steps if self._scenario else 25,
|
| 1415 |
+
evidence_found_ratio=scores.get("investigation_completeness", 0),
|
| 1416 |
+
iocs_found_ratio=scores.get("ioc_identification", 0),
|
| 1417 |
+
correct_severity=scores.get("severity_correct", False),
|
| 1418 |
+
correct_category=scores.get("category_correct", False),
|
| 1419 |
+
containment_score=scores.get("containment_score", 0),
|
| 1420 |
+
report_quality=0.0,
|
| 1421 |
+
timestamp=time.time(),
|
| 1422 |
+
)
|
| 1423 |
+
self._evolution_engine.record_performance(self._current_genome, record)
|
| 1424 |
+
|
| 1425 |
return {
|
| 1426 |
"score": final_score,
|
| 1427 |
"task_id": self._task_id,
|
| 1428 |
+
"breakdown": scores,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1429 |
}
|